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
+4 -1
.gitignore
···
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
+
.env
# dependencies
/node_modules
/.pnp
.pnp.js
+
cli/target/
+
target/
# testing
/coverage
···
# production
/build
+
/result
# misc
.DS_Store
+3
.gitmodules
···
+
[submodule "cli/jacquard"]
+
path = cli/jacquard
+
url = https://tangled.org/@nonbinary.computer/jacquard
+50
.tangled/workflows/deploy-wisp.yml
···
+
# Deploy to Wisp.place
+
# This workflow builds your site and deploys it to Wisp.place using the wisp-cli
+
when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
engine: 'nixery'
+
clone:
+
skip: false
+
depth: 1
+
submodules: true
+
dependencies:
+
nixpkgs:
+
- git
+
- gcc
+
github:NixOS/nixpkgs/nixpkgs-unstable:
+
- rustc
+
- cargo
+
environment:
+
# Customize these for your project
+
SITE_PATH: 'testDeploy'
+
SITE_NAME: 'wispPlaceDocs'
+
steps:
+
- name: 'Initialize submodules'
+
command: |
+
git submodule update --init --recursive
+
+
- name: 'Build wisp-cli'
+
command: |
+
cd cli
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs
+
nix-channel --update
+
nix-shell -p pkg-config openssl --run '
+
export PKG_CONFIG_PATH="$(pkg-config --variable pc_path pkg-config)"
+
export OPENSSL_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.dev)"
+
export OPENSSL_NO_VENDOR=1
+
export OPENSSL_LIB_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.out)/lib"
+
cargo build --release
+
'
+
cd ..
+
+
- name: 'Deploy to Wisp.place'
+
command: |
+
echo
+
./cli/target/release/wisp-cli \
+
"$WISP_HANDLE" \
+
--path "$SITE_PATH" \
+
--site "$SITE_NAME" \
+
--password "$WISP_APP_PASSWORD"
+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
-
*/
+334
bun.lock
···
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
+
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
···
"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",
+
"typescript": "^5.9.3",
+
"zlib": "^1.0.5",
},
"devDependencies": {
"@types/react": "^19.2.2",
···
},
},
},
+
"trustedDependencies": [
+
"core-js",
+
"protobufjs",
+
],
"packages": {
"@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="],
···
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="],
+
"@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=="],
+
"@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="],
+
"@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=="],
+
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
+
"@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" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+
"@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=="],
+
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="],
···
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
+
"@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=="],
+
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
···
"@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=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "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=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
···
"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=="],
+
"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=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
···
"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=="],
···
"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=="],
···
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
+
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
···
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
"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=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
···
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
"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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
···
"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=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+
"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", "", {}, "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=="],
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
"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=="],
···
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+
+
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
···
"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=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
+
+
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
···
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"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=="],
+
+
"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": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
+
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
···
"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-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
···
"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=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
+
"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" } }, "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=="],
+
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
···
"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=="],
···
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
···
"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=="],
···
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"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=="],
+
"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=="],
+
"yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="],
+
+
"zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="],
"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=="],
···
"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/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"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=="],
}
}
+428
claude.md
···
+
# 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)
+
+
### oauth_sessions
+
- sub (primary key - subject/DID)
+
- data (JSON with OAuth session)
+
- updated_at, expires_at
+
+
### 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
+
+
### 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
+
+
---
+
+
## ๐ŸŽจ Frontend Structure
+
+
### `/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
+
+
### 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
+
+
---
+
+
## ๐Ÿ” 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/
+4530
cli/Cargo.lock
···
+
# This file is automatically @generated by Cargo.
+
# It is not intended for manual editing.
+
version = 4
+
+
[[package]]
+
name = "abnf"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a"
+
dependencies = [
+
"abnf-core",
+
"nom",
+
]
+
+
[[package]]
+
name = "abnf-core"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d"
+
dependencies = [
+
"nom",
+
]
+
+
[[package]]
+
name = "addr2line"
+
version = "0.25.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+
dependencies = [
+
"gimli",
+
]
+
+
[[package]]
+
name = "adler2"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+
[[package]]
+
name = "adler32"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
+
+
[[package]]
+
name = "aho-corasick"
+
version = "1.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+
dependencies = [
+
"memchr",
+
]
+
+
[[package]]
+
name = "aliasable"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
+
+
[[package]]
+
name = "alloc-no-stdlib"
+
version = "2.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+
[[package]]
+
name = "alloc-stdlib"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+
dependencies = [
+
"alloc-no-stdlib",
+
]
+
+
[[package]]
+
name = "android_system_properties"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
+
name = "anstream"
+
version = "0.6.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+
dependencies = [
+
"anstyle",
+
"anstyle-parse",
+
"anstyle-query",
+
"anstyle-wincon",
+
"colorchoice",
+
"is_terminal_polyfill",
+
"utf8parse",
+
]
+
+
[[package]]
+
name = "anstyle"
+
version = "1.0.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+
dependencies = [
+
"utf8parse",
+
]
+
+
[[package]]
+
name = "anstyle-query"
+
version = "1.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
+
dependencies = [
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "anstyle-wincon"
+
version = "3.0.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
+
dependencies = [
+
"anstyle",
+
"once_cell_polyfill",
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "ascii"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
+
[[package]]
+
name = "async-compression"
+
version = "0.4.32"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
+
dependencies = [
+
"compression-codecs",
+
"compression-core",
+
"futures-core",
+
"pin-project-lite",
+
"tokio",
+
]
+
+
[[package]]
+
name = "async-trait"
+
version = "0.1.89"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "atomic-waker"
+
version = "1.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+
[[package]]
+
name = "autocfg"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+
[[package]]
+
name = "backtrace"
+
version = "0.3.76"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+
dependencies = [
+
"addr2line",
+
"cfg-if",
+
"libc",
+
"miniz_oxide",
+
"object",
+
"rustc-demangle",
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "backtrace-ext"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+
dependencies = [
+
"backtrace",
+
]
+
+
[[package]]
+
name = "base-x"
+
version = "0.2.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+
+
[[package]]
+
name = "base16ct"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+
[[package]]
+
name = "base256emoji"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c"
+
dependencies = [
+
"const-str",
+
"match-lookup",
+
]
+
+
[[package]]
+
name = "base64"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+
[[package]]
+
name = "base64"
+
version = "0.22.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+
[[package]]
+
name = "base64ct"
+
version = "1.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
+
+
[[package]]
+
name = "bitflags"
+
version = "2.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+
[[package]]
+
name = "block-buffer"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+
dependencies = [
+
"generic-array",
+
]
+
+
[[package]]
+
name = "bon"
+
version = "3.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1"
+
dependencies = [
+
"bon-macros",
+
"rustversion",
+
]
+
+
[[package]]
+
name = "bon-macros"
+
version = "3.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645"
+
dependencies = [
+
"darling",
+
"ident_case",
+
"prettyplease",
+
"proc-macro2",
+
"quote",
+
"rustversion",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "borsh"
+
version = "1.5.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce"
+
dependencies = [
+
"cfg_aliases",
+
]
+
+
[[package]]
+
name = "brotli"
+
version = "3.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391"
+
dependencies = [
+
"alloc-no-stdlib",
+
"alloc-stdlib",
+
"brotli-decompressor",
+
]
+
+
[[package]]
+
name = "brotli-decompressor"
+
version = "2.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
+
dependencies = [
+
"alloc-no-stdlib",
+
"alloc-stdlib",
+
]
+
+
[[package]]
+
name = "btree-range-map"
+
version = "0.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1be5c9672446d3800bcbcaabaeba121fe22f1fb25700c4562b22faf76d377c33"
+
dependencies = [
+
"btree-slab",
+
"cc-traits",
+
"range-traits",
+
"serde",
+
"slab",
+
]
+
+
[[package]]
+
name = "btree-slab"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a2b56d3029f075c4fa892428a098425b86cef5c89ae54073137ece416aef13c"
+
dependencies = [
+
"cc-traits",
+
"slab",
+
"smallvec",
+
]
+
+
[[package]]
+
name = "buf_redux"
+
version = "0.8.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f"
+
dependencies = [
+
"memchr",
+
"safemem",
+
]
+
+
[[package]]
+
name = "bumpalo"
+
version = "3.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+
[[package]]
+
name = "bytes"
+
version = "1.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
dependencies = [
+
"serde",
+
]
+
+
[[package]]
+
name = "cbor4ii"
+
version = "0.2.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4"
+
dependencies = [
+
"serde",
+
]
+
+
[[package]]
+
name = "cc"
+
version = "1.2.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
+
dependencies = [
+
"find-msvc-tools",
+
"shlex",
+
]
+
+
[[package]]
+
name = "cc-traits"
+
version = "2.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "060303ef31ef4a522737e1b1ab68c67916f2a787bb2f4f54f383279adba962b5"
+
dependencies = [
+
"slab",
+
]
+
+
[[package]]
+
name = "cesu8"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+
[[package]]
+
name = "cfg-if"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+
[[package]]
+
name = "cfg_aliases"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+
[[package]]
+
name = "chrono"
+
version = "0.4.42"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+
dependencies = [
+
"iana-time-zone",
+
"js-sys",
+
"num-traits",
+
"serde",
+
"wasm-bindgen",
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "chunked_transfer"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
+
[[package]]
+
name = "ciborium"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+
dependencies = [
+
"ciborium-io",
+
"ciborium-ll",
+
"serde",
+
]
+
+
[[package]]
+
name = "ciborium-io"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+
[[package]]
+
name = "ciborium-ll"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+
dependencies = [
+
"ciborium-io",
+
"half",
+
]
+
+
[[package]]
+
name = "cid"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a"
+
dependencies = [
+
"core2",
+
"multibase",
+
"multihash",
+
"serde",
+
"serde_bytes",
+
"unsigned-varint",
+
]
+
+
[[package]]
+
name = "clap"
+
version = "4.5.51"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
+
dependencies = [
+
"clap_builder",
+
"clap_derive",
+
]
+
+
[[package]]
+
name = "clap_builder"
+
version = "4.5.51"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
+
dependencies = [
+
"anstream",
+
"anstyle",
+
"clap_lex",
+
"strsim",
+
]
+
+
[[package]]
+
name = "clap_derive"
+
version = "4.5.49"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+
dependencies = [
+
"heck 0.5.0",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "clap_lex"
+
version = "0.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
+
[[package]]
+
name = "colorchoice"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+
[[package]]
+
name = "combine"
+
version = "4.6.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+
dependencies = [
+
"bytes",
+
"memchr",
+
]
+
+
[[package]]
+
name = "compression-codecs"
+
version = "0.4.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
+
dependencies = [
+
"compression-core",
+
"flate2",
+
"memchr",
+
]
+
+
[[package]]
+
name = "compression-core"
+
version = "0.4.29"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
+
+
[[package]]
+
name = "const-oid"
+
version = "0.9.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+
[[package]]
+
name = "const-str"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
+
+
[[package]]
+
name = "core-foundation"
+
version = "0.9.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+
dependencies = [
+
"core-foundation-sys",
+
"libc",
+
]
+
+
[[package]]
+
name = "core-foundation"
+
version = "0.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+
dependencies = [
+
"core-foundation-sys",
+
"libc",
+
]
+
+
[[package]]
+
name = "core-foundation-sys"
+
version = "0.8.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+
[[package]]
+
name = "core2"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
+
dependencies = [
+
"memchr",
+
]
+
+
[[package]]
+
name = "cpufeatures"
+
version = "0.2.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
+
name = "crc32fast"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+
dependencies = [
+
"cfg-if",
+
]
+
+
[[package]]
+
name = "crossbeam-channel"
+
version = "0.5.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+
dependencies = [
+
"crossbeam-utils",
+
]
+
+
[[package]]
+
name = "crossbeam-utils"
+
version = "0.8.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+
[[package]]
+
name = "crunchy"
+
version = "0.2.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+
[[package]]
+
name = "crypto-bigint"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+
dependencies = [
+
"generic-array",
+
"rand_core 0.6.4",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "crypto-common"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+
dependencies = [
+
"generic-array",
+
"typenum",
+
]
+
+
[[package]]
+
name = "darling"
+
version = "0.21.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+
dependencies = [
+
"darling_core",
+
"darling_macro",
+
]
+
+
[[package]]
+
name = "darling_core"
+
version = "0.21.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+
dependencies = [
+
"fnv",
+
"ident_case",
+
"proc-macro2",
+
"quote",
+
"strsim",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "darling_macro"
+
version = "0.21.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+
dependencies = [
+
"darling_core",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "dashmap"
+
version = "6.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+
dependencies = [
+
"cfg-if",
+
"crossbeam-utils",
+
"hashbrown 0.14.5",
+
"lock_api",
+
"once_cell",
+
"parking_lot_core",
+
]
+
+
[[package]]
+
name = "data-encoding"
+
version = "2.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
+
+
[[package]]
+
name = "data-encoding-macro"
+
version = "0.1.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d"
+
dependencies = [
+
"data-encoding",
+
"data-encoding-macro-internal",
+
]
+
+
[[package]]
+
name = "data-encoding-macro-internal"
+
version = "0.1.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
+
dependencies = [
+
"data-encoding",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "deflate"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f"
+
dependencies = [
+
"adler32",
+
"gzip-header",
+
]
+
+
[[package]]
+
name = "der"
+
version = "0.7.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+
dependencies = [
+
"const-oid",
+
"pem-rfc7468",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "deranged"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
+
dependencies = [
+
"powerfmt",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "digest"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+
dependencies = [
+
"block-buffer",
+
"const-oid",
+
"crypto-common",
+
"subtle",
+
]
+
+
[[package]]
+
name = "dirs"
+
version = "6.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+
dependencies = [
+
"dirs-sys",
+
]
+
+
[[package]]
+
name = "dirs-sys"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+
dependencies = [
+
"libc",
+
"option-ext",
+
"redox_users",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "displaydoc"
+
version = "0.2.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "dyn-clone"
+
version = "1.0.20"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+
[[package]]
+
name = "ecdsa"
+
version = "0.16.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+
dependencies = [
+
"der",
+
"digest",
+
"elliptic-curve",
+
"rfc6979",
+
"signature",
+
"spki",
+
]
+
+
[[package]]
+
name = "elliptic-curve"
+
version = "0.13.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+
dependencies = [
+
"base16ct",
+
"crypto-bigint",
+
"digest",
+
"ff",
+
"generic-array",
+
"group",
+
"pem-rfc7468",
+
"pkcs8",
+
"rand_core 0.6.4",
+
"sec1",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "encoding_rs"
+
version = "0.8.35"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+
dependencies = [
+
"cfg-if",
+
]
+
+
[[package]]
+
name = "enum-as-inner"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+
dependencies = [
+
"heck 0.5.0",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "equivalent"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+
[[package]]
+
name = "errno"
+
version = "0.3.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+
dependencies = [
+
"libc",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "fastrand"
+
version = "2.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+
[[package]]
+
name = "ff"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+
dependencies = [
+
"rand_core 0.6.4",
+
"subtle",
+
]
+
+
[[package]]
+
name = "filetime"
+
version = "0.2.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
+
dependencies = [
+
"cfg-if",
+
"libc",
+
"libredox",
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "find-msvc-tools"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
+
+
[[package]]
+
name = "flate2"
+
version = "1.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
+
dependencies = [
+
"crc32fast",
+
"miniz_oxide",
+
]
+
+
[[package]]
+
name = "fnv"
+
version = "1.0.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+
[[package]]
+
name = "form_urlencoded"
+
version = "1.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+
dependencies = [
+
"percent-encoding",
+
]
+
+
[[package]]
+
name = "futf"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+
dependencies = [
+
"mac",
+
"new_debug_unreachable",
+
]
+
+
[[package]]
+
name = "futures"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+
dependencies = [
+
"futures-channel",
+
"futures-core",
+
"futures-executor",
+
"futures-io",
+
"futures-sink",
+
"futures-task",
+
"futures-util",
+
]
+
+
[[package]]
+
name = "futures-channel"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+
dependencies = [
+
"futures-core",
+
"futures-sink",
+
]
+
+
[[package]]
+
name = "futures-core"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+
[[package]]
+
name = "futures-executor"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+
dependencies = [
+
"futures-core",
+
"futures-task",
+
"futures-util",
+
]
+
+
[[package]]
+
name = "futures-io"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+
[[package]]
+
name = "futures-macro"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "futures-sink"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+
[[package]]
+
name = "futures-task"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+
[[package]]
+
name = "futures-util"
+
version = "0.3.31"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+
dependencies = [
+
"futures-channel",
+
"futures-core",
+
"futures-io",
+
"futures-macro",
+
"futures-sink",
+
"futures-task",
+
"memchr",
+
"pin-project-lite",
+
"pin-utils",
+
"slab",
+
]
+
+
[[package]]
+
name = "generic-array"
+
version = "0.14.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+
dependencies = [
+
"typenum",
+
"version_check",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "getrandom"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+
dependencies = [
+
"cfg-if",
+
"js-sys",
+
"libc",
+
"wasi",
+
"wasm-bindgen",
+
]
+
+
[[package]]
+
name = "getrandom"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+
dependencies = [
+
"cfg-if",
+
"js-sys",
+
"libc",
+
"r-efi",
+
"wasip2",
+
"wasm-bindgen",
+
]
+
+
[[package]]
+
name = "gimli"
+
version = "0.32.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+
[[package]]
+
name = "group"
+
version = "0.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+
dependencies = [
+
"ff",
+
"rand_core 0.6.4",
+
"subtle",
+
]
+
+
[[package]]
+
name = "gzip-header"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2"
+
dependencies = [
+
"crc32fast",
+
]
+
+
[[package]]
+
name = "h2"
+
version = "0.4.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
+
dependencies = [
+
"atomic-waker",
+
"bytes",
+
"fnv",
+
"futures-core",
+
"futures-sink",
+
"http",
+
"indexmap 2.12.0",
+
"slab",
+
"tokio",
+
"tokio-util",
+
"tracing",
+
]
+
+
[[package]]
+
name = "half"
+
version = "2.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+
dependencies = [
+
"cfg-if",
+
"crunchy",
+
"zerocopy",
+
]
+
+
[[package]]
+
name = "hashbrown"
+
version = "0.12.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+
[[package]]
+
name = "hashbrown"
+
version = "0.14.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+
[[package]]
+
name = "hashbrown"
+
version = "0.16.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+
+
[[package]]
+
name = "heck"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+
[[package]]
+
name = "heck"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+
[[package]]
+
name = "hermit-abi"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+
[[package]]
+
name = "hex"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+
[[package]]
+
name = "hex_fmt"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f"
+
+
[[package]]
+
name = "hickory-proto"
+
version = "0.24.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248"
+
dependencies = [
+
"async-trait",
+
"cfg-if",
+
"data-encoding",
+
"enum-as-inner",
+
"futures-channel",
+
"futures-io",
+
"futures-util",
+
"idna",
+
"ipnet",
+
"once_cell",
+
"rand 0.8.5",
+
"thiserror 1.0.69",
+
"tinyvec",
+
"tokio",
+
"tracing",
+
"url",
+
]
+
+
[[package]]
+
name = "hickory-resolver"
+
version = "0.24.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e"
+
dependencies = [
+
"cfg-if",
+
"futures-util",
+
"hickory-proto",
+
"ipconfig",
+
"lru-cache",
+
"once_cell",
+
"parking_lot",
+
"rand 0.8.5",
+
"resolv-conf",
+
"smallvec",
+
"thiserror 1.0.69",
+
"tokio",
+
"tracing",
+
]
+
+
[[package]]
+
name = "hmac"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+
dependencies = [
+
"digest",
+
]
+
+
[[package]]
+
name = "html5ever"
+
version = "0.27.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
+
dependencies = [
+
"log",
+
"mac",
+
"markup5ever",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "http"
+
version = "1.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
+
dependencies = [
+
"bytes",
+
"fnv",
+
"itoa",
+
]
+
+
[[package]]
+
name = "http-body"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+
dependencies = [
+
"bytes",
+
"http",
+
]
+
+
[[package]]
+
name = "http-body-util"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+
dependencies = [
+
"bytes",
+
"futures-core",
+
"http",
+
"http-body",
+
"pin-project-lite",
+
]
+
+
[[package]]
+
name = "httparse"
+
version = "1.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+
[[package]]
+
name = "httpdate"
+
version = "1.0.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+
[[package]]
+
name = "hyper"
+
version = "1.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
+
dependencies = [
+
"atomic-waker",
+
"bytes",
+
"futures-channel",
+
"futures-core",
+
"h2",
+
"http",
+
"http-body",
+
"httparse",
+
"itoa",
+
"pin-project-lite",
+
"pin-utils",
+
"smallvec",
+
"tokio",
+
"want",
+
]
+
+
[[package]]
+
name = "hyper-rustls"
+
version = "0.27.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+
dependencies = [
+
"http",
+
"hyper",
+
"hyper-util",
+
"rustls",
+
"rustls-pki-types",
+
"tokio",
+
"tokio-rustls",
+
"tower-service",
+
"webpki-roots",
+
]
+
+
[[package]]
+
name = "hyper-util"
+
version = "0.1.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
+
dependencies = [
+
"base64 0.22.1",
+
"bytes",
+
"futures-channel",
+
"futures-core",
+
"futures-util",
+
"http",
+
"http-body",
+
"hyper",
+
"ipnet",
+
"libc",
+
"percent-encoding",
+
"pin-project-lite",
+
"socket2 0.6.1",
+
"system-configuration",
+
"tokio",
+
"tower-service",
+
"tracing",
+
"windows-registry",
+
]
+
+
[[package]]
+
name = "iana-time-zone"
+
version = "0.1.64"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+
dependencies = [
+
"android_system_properties",
+
"core-foundation-sys",
+
"iana-time-zone-haiku",
+
"js-sys",
+
"log",
+
"wasm-bindgen",
+
"windows-core",
+
]
+
+
[[package]]
+
name = "iana-time-zone-haiku"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+
dependencies = [
+
"cc",
+
]
+
+
[[package]]
+
name = "icu_collections"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+
dependencies = [
+
"displaydoc",
+
"potential_utf",
+
"yoke",
+
"zerofrom",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "icu_locale_core"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+
dependencies = [
+
"displaydoc",
+
"litemap",
+
"tinystr",
+
"writeable",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "icu_normalizer"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+
dependencies = [
+
"icu_collections",
+
"icu_normalizer_data",
+
"icu_properties",
+
"icu_provider",
+
"smallvec",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "icu_normalizer_data"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+
[[package]]
+
name = "icu_properties"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
+
dependencies = [
+
"icu_collections",
+
"icu_locale_core",
+
"icu_properties_data",
+
"icu_provider",
+
"zerotrie",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "icu_properties_data"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
+
+
[[package]]
+
name = "icu_provider"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+
dependencies = [
+
"displaydoc",
+
"icu_locale_core",
+
"writeable",
+
"yoke",
+
"zerofrom",
+
"zerotrie",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "ident_case"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+
[[package]]
+
name = "idna"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+
dependencies = [
+
"idna_adapter",
+
"smallvec",
+
"utf8_iter",
+
]
+
+
[[package]]
+
name = "idna_adapter"
+
version = "1.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+
dependencies = [
+
"icu_normalizer",
+
"icu_properties",
+
]
+
+
[[package]]
+
name = "indexmap"
+
version = "1.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+
dependencies = [
+
"autocfg",
+
"hashbrown 0.12.3",
+
"serde",
+
]
+
+
[[package]]
+
name = "indexmap"
+
version = "2.12.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
+
dependencies = [
+
"equivalent",
+
"hashbrown 0.16.0",
+
"serde",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "indoc"
+
version = "2.0.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+
dependencies = [
+
"rustversion",
+
]
+
+
[[package]]
+
name = "inventory"
+
version = "0.3.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e"
+
dependencies = [
+
"rustversion",
+
]
+
+
[[package]]
+
name = "ipconfig"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
+
dependencies = [
+
"socket2 0.5.10",
+
"widestring",
+
"windows-sys 0.48.0",
+
"winreg",
+
]
+
+
[[package]]
+
name = "ipld-core"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db"
+
dependencies = [
+
"cid",
+
"serde",
+
"serde_bytes",
+
]
+
+
[[package]]
+
name = "ipnet"
+
version = "2.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+
[[package]]
+
name = "iri-string"
+
version = "0.7.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+
dependencies = [
+
"memchr",
+
"serde",
+
]
+
+
[[package]]
+
name = "is_ci"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
+
[[package]]
+
name = "is_terminal_polyfill"
+
version = "1.70.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+
[[package]]
+
name = "itoa"
+
version = "1.0.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+
[[package]]
+
name = "jacquard"
+
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"bytes",
+
"getrandom 0.2.16",
+
"http",
+
"jacquard-api",
+
"jacquard-common",
+
"jacquard-derive",
+
"jacquard-identity",
+
"jacquard-oauth",
+
"jose-jwk",
+
"miette",
+
"regex",
+
"reqwest",
+
"serde",
+
"serde_html_form",
+
"serde_json",
+
"smol_str",
+
"thiserror 2.0.17",
+
"tokio",
+
"trait-variant",
+
"url",
+
"webpage",
+
]
+
+
[[package]]
+
name = "jacquard-api"
+
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"bon",
+
"bytes",
+
"jacquard-common",
+
"jacquard-derive",
+
"jacquard-lexicon",
+
"miette",
+
"rustversion",
+
"serde",
+
"serde_ipld_dagcbor",
+
"thiserror 2.0.17",
+
"unicode-segmentation",
+
]
+
+
[[package]]
+
name = "jacquard-common"
+
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"base64 0.22.1",
+
"bon",
+
"bytes",
+
"chrono",
+
"cid",
+
"getrandom 0.2.16",
+
"getrandom 0.3.4",
+
"http",
+
"ipld-core",
+
"k256",
+
"langtag",
+
"miette",
+
"multibase",
+
"multihash",
+
"ouroboros",
+
"p256",
+
"rand 0.9.2",
+
"regex",
+
"reqwest",
+
"serde",
+
"serde_html_form",
+
"serde_ipld_dagcbor",
+
"serde_json",
+
"signature",
+
"smol_str",
+
"thiserror 2.0.17",
+
"tokio",
+
"tokio-util",
+
"trait-variant",
+
"url",
+
]
+
+
[[package]]
+
name = "jacquard-derive"
+
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"heck 0.5.0",
+
"jacquard-lexicon",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "jacquard-identity"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"bon",
+
"bytes",
+
"hickory-resolver",
+
"http",
+
"jacquard-api",
+
"jacquard-common",
+
"jacquard-lexicon",
+
"miette",
+
"mini-moka",
+
"percent-encoding",
+
"reqwest",
+
"serde",
+
"serde_html_form",
+
"serde_json",
+
"thiserror 2.0.17",
+
"tokio",
+
"trait-variant",
+
"url",
+
"urlencoding",
+
]
+
+
[[package]]
+
name = "jacquard-lexicon"
+
version = "0.9.1"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"cid",
+
"dashmap",
+
"heck 0.5.0",
+
"inventory",
+
"jacquard-common",
+
"miette",
+
"multihash",
+
"prettyplease",
+
"proc-macro2",
+
"quote",
+
"serde",
+
"serde_ipld_dagcbor",
+
"serde_json",
+
"serde_repr",
+
"serde_with",
+
"sha2",
+
"syn 2.0.108",
+
"thiserror 2.0.17",
+
"unicode-segmentation",
+
]
+
+
[[package]]
+
name = "jacquard-oauth"
+
version = "0.9.0"
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
+
dependencies = [
+
"base64 0.22.1",
+
"bytes",
+
"chrono",
+
"dashmap",
+
"elliptic-curve",
+
"http",
+
"jacquard-common",
+
"jacquard-identity",
+
"jose-jwa",
+
"jose-jwk",
+
"miette",
+
"p256",
+
"rand 0.8.5",
+
"rouille",
+
"serde",
+
"serde_html_form",
+
"serde_json",
+
"sha2",
+
"signature",
+
"smol_str",
+
"thiserror 2.0.17",
+
"tokio",
+
"trait-variant",
+
"url",
+
"webbrowser",
+
]
+
+
[[package]]
+
name = "jni"
+
version = "0.21.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+
dependencies = [
+
"cesu8",
+
"cfg-if",
+
"combine",
+
"jni-sys",
+
"log",
+
"thiserror 1.0.69",
+
"walkdir",
+
"windows-sys 0.45.0",
+
]
+
+
[[package]]
+
name = "jni-sys"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+
[[package]]
+
name = "jose-b64"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56"
+
dependencies = [
+
"base64ct",
+
"serde",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "jose-jwa"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7"
+
dependencies = [
+
"serde",
+
]
+
+
[[package]]
+
name = "jose-jwk"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7"
+
dependencies = [
+
"jose-b64",
+
"jose-jwa",
+
"p256",
+
"p384",
+
"rsa",
+
"serde",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "js-sys"
+
version = "0.3.82"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
+
dependencies = [
+
"once_cell",
+
"wasm-bindgen",
+
]
+
+
[[package]]
+
name = "k256"
+
version = "0.13.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+
dependencies = [
+
"cfg-if",
+
"ecdsa",
+
"elliptic-curve",
+
"sha2",
+
]
+
+
[[package]]
+
name = "langtag"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ecb4c689a30e48ebeaa14237f34037e300dd072e6ad21a9ec72e810ff3c6600"
+
dependencies = [
+
"serde",
+
"static-regular-grammar",
+
"thiserror 1.0.69",
+
]
+
+
[[package]]
+
name = "lazy_static"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
dependencies = [
+
"spin",
+
]
+
+
[[package]]
+
name = "libc"
+
version = "0.2.177"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
+
+
[[package]]
+
name = "libm"
+
version = "0.2.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
+
+
[[package]]
+
name = "libredox"
+
version = "0.1.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
+
dependencies = [
+
"bitflags",
+
"libc",
+
"redox_syscall",
+
]
+
+
[[package]]
+
name = "linked-hash-map"
+
version = "0.5.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+
[[package]]
+
name = "linux-raw-sys"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+
+
[[package]]
+
name = "litemap"
+
version = "0.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+
[[package]]
+
name = "lock_api"
+
version = "0.4.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+
dependencies = [
+
"scopeguard",
+
]
+
+
[[package]]
+
name = "log"
+
version = "0.4.28"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
+
[[package]]
+
name = "lru-cache"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
+
dependencies = [
+
"linked-hash-map",
+
]
+
+
[[package]]
+
name = "lru-slab"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+
[[package]]
+
name = "mac"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+
[[package]]
+
name = "markup5ever"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
+
dependencies = [
+
"log",
+
"phf",
+
"phf_codegen",
+
"string_cache",
+
"string_cache_codegen",
+
"tendril",
+
]
+
+
[[package]]
+
name = "markup5ever_rcdom"
+
version = "0.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
+
dependencies = [
+
"html5ever",
+
"markup5ever",
+
"tendril",
+
"xml5ever",
+
]
+
+
[[package]]
+
name = "match-lookup"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 1.0.109",
+
]
+
+
[[package]]
+
name = "memchr"
+
version = "2.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+
[[package]]
+
name = "miette"
+
version = "7.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+
dependencies = [
+
"backtrace",
+
"backtrace-ext",
+
"cfg-if",
+
"miette-derive",
+
"owo-colors",
+
"supports-color",
+
"supports-hyperlinks",
+
"supports-unicode",
+
"terminal_size",
+
"textwrap",
+
"unicode-width 0.1.14",
+
]
+
+
[[package]]
+
name = "miette-derive"
+
version = "7.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "mime"
+
version = "0.3.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+
[[package]]
+
name = "mime_guess"
+
version = "2.0.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+
dependencies = [
+
"mime",
+
"unicase",
+
]
+
+
[[package]]
+
name = "mini-moka"
+
version = "0.11.0"
+
source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d"
+
dependencies = [
+
"crossbeam-channel",
+
"crossbeam-utils",
+
"dashmap",
+
"smallvec",
+
"tagptr",
+
"triomphe",
+
"web-time",
+
]
+
+
[[package]]
+
name = "minimal-lexical"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+
[[package]]
+
name = "miniz_oxide"
+
version = "0.8.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+
dependencies = [
+
"adler2",
+
"simd-adler32",
+
]
+
+
[[package]]
+
name = "mio"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
+
dependencies = [
+
"libc",
+
"wasi",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "multibase"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77"
+
dependencies = [
+
"base-x",
+
"base256emoji",
+
"data-encoding",
+
"data-encoding-macro",
+
]
+
+
[[package]]
+
name = "multihash"
+
version = "0.19.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d"
+
dependencies = [
+
"core2",
+
"serde",
+
"unsigned-varint",
+
]
+
+
[[package]]
+
name = "multipart"
+
version = "0.18.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182"
+
dependencies = [
+
"buf_redux",
+
"httparse",
+
"log",
+
"mime",
+
"mime_guess",
+
"quick-error",
+
"rand 0.8.5",
+
"safemem",
+
"tempfile",
+
"twoway",
+
]
+
+
[[package]]
+
name = "ndk-context"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+
[[package]]
+
name = "new_debug_unreachable"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+
[[package]]
+
name = "nom"
+
version = "7.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+
dependencies = [
+
"memchr",
+
"minimal-lexical",
+
]
+
+
[[package]]
+
name = "num-bigint-dig"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b"
+
dependencies = [
+
"lazy_static",
+
"libm",
+
"num-integer",
+
"num-iter",
+
"num-traits",
+
"rand 0.8.5",
+
"smallvec",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "num-conv"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+
[[package]]
+
name = "num-integer"
+
version = "0.1.46"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+
dependencies = [
+
"num-traits",
+
]
+
+
[[package]]
+
name = "num-iter"
+
version = "0.1.45"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+
dependencies = [
+
"autocfg",
+
"num-integer",
+
"num-traits",
+
]
+
+
[[package]]
+
name = "num-traits"
+
version = "0.2.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+
dependencies = [
+
"autocfg",
+
"libm",
+
]
+
+
[[package]]
+
name = "num_cpus"
+
version = "1.17.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+
dependencies = [
+
"hermit-abi",
+
"libc",
+
]
+
+
[[package]]
+
name = "num_threads"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
+
name = "objc2"
+
version = "0.6.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
+
dependencies = [
+
"objc2-encode",
+
]
+
+
[[package]]
+
name = "objc2-encode"
+
version = "4.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+
[[package]]
+
name = "objc2-foundation"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+
dependencies = [
+
"bitflags",
+
"objc2",
+
]
+
+
[[package]]
+
name = "object"
+
version = "0.37.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+
dependencies = [
+
"memchr",
+
]
+
+
[[package]]
+
name = "once_cell"
+
version = "1.21.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+
[[package]]
+
name = "once_cell_polyfill"
+
version = "1.70.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+
[[package]]
+
name = "option-ext"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+
[[package]]
+
name = "ouroboros"
+
version = "0.18.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59"
+
dependencies = [
+
"aliasable",
+
"ouroboros_macro",
+
"static_assertions",
+
]
+
+
[[package]]
+
name = "ouroboros_macro"
+
version = "0.18.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
+
dependencies = [
+
"heck 0.4.1",
+
"proc-macro2",
+
"proc-macro2-diagnostics",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "owo-colors"
+
version = "4.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
+
+
[[package]]
+
name = "p256"
+
version = "0.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+
dependencies = [
+
"ecdsa",
+
"elliptic-curve",
+
"primeorder",
+
"sha2",
+
]
+
+
[[package]]
+
name = "p384"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+
dependencies = [
+
"elliptic-curve",
+
"primeorder",
+
]
+
+
[[package]]
+
name = "parking_lot"
+
version = "0.12.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+
dependencies = [
+
"lock_api",
+
"parking_lot_core",
+
]
+
+
[[package]]
+
name = "parking_lot_core"
+
version = "0.9.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+
dependencies = [
+
"cfg-if",
+
"libc",
+
"redox_syscall",
+
"smallvec",
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "pem-rfc7468"
+
version = "0.7.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+
dependencies = [
+
"base64ct",
+
]
+
+
[[package]]
+
name = "percent-encoding"
+
version = "2.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+
[[package]]
+
name = "phf"
+
version = "0.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+
dependencies = [
+
"phf_shared",
+
]
+
+
[[package]]
+
name = "phf_codegen"
+
version = "0.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+
dependencies = [
+
"phf_generator",
+
"phf_shared",
+
]
+
+
[[package]]
+
name = "phf_generator"
+
version = "0.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+
dependencies = [
+
"phf_shared",
+
"rand 0.8.5",
+
]
+
+
[[package]]
+
name = "phf_shared"
+
version = "0.11.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+
dependencies = [
+
"siphasher",
+
]
+
+
[[package]]
+
name = "pin-project-lite"
+
version = "0.2.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+
[[package]]
+
name = "pin-utils"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+
[[package]]
+
name = "pkcs1"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+
dependencies = [
+
"der",
+
"pkcs8",
+
"spki",
+
]
+
+
[[package]]
+
name = "pkcs8"
+
version = "0.10.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+
dependencies = [
+
"der",
+
"spki",
+
]
+
+
[[package]]
+
name = "potential_utf"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+
dependencies = [
+
"zerovec",
+
]
+
+
[[package]]
+
name = "powerfmt"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+
[[package]]
+
name = "ppv-lite86"
+
version = "0.2.21"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+
dependencies = [
+
"zerocopy",
+
]
+
+
[[package]]
+
name = "precomputed-hash"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+
[[package]]
+
name = "prettyplease"
+
version = "0.2.37"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+
dependencies = [
+
"proc-macro2",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "primeorder"
+
version = "0.13.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+
dependencies = [
+
"elliptic-curve",
+
]
+
+
[[package]]
+
name = "proc-macro-error"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+
dependencies = [
+
"proc-macro-error-attr",
+
"proc-macro2",
+
"quote",
+
"syn 1.0.109",
+
"version_check",
+
]
+
+
[[package]]
+
name = "proc-macro-error-attr"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"version_check",
+
]
+
+
[[package]]
+
name = "proc-macro2"
+
version = "1.0.103"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+
dependencies = [
+
"unicode-ident",
+
]
+
+
[[package]]
+
name = "proc-macro2-diagnostics"
+
version = "0.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
"version_check",
+
"yansi",
+
]
+
+
[[package]]
+
name = "quick-error"
+
version = "1.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+
[[package]]
+
name = "quinn"
+
version = "0.11.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+
dependencies = [
+
"bytes",
+
"cfg_aliases",
+
"pin-project-lite",
+
"quinn-proto",
+
"quinn-udp",
+
"rustc-hash",
+
"rustls",
+
"socket2 0.6.1",
+
"thiserror 2.0.17",
+
"tokio",
+
"tracing",
+
"web-time",
+
]
+
+
[[package]]
+
name = "quinn-proto"
+
version = "0.11.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+
dependencies = [
+
"bytes",
+
"getrandom 0.3.4",
+
"lru-slab",
+
"rand 0.9.2",
+
"ring",
+
"rustc-hash",
+
"rustls",
+
"rustls-pki-types",
+
"slab",
+
"thiserror 2.0.17",
+
"tinyvec",
+
"tracing",
+
"web-time",
+
]
+
+
[[package]]
+
name = "quinn-udp"
+
version = "0.5.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+
dependencies = [
+
"cfg_aliases",
+
"libc",
+
"once_cell",
+
"socket2 0.6.1",
+
"tracing",
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "quote"
+
version = "1.0.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+
dependencies = [
+
"proc-macro2",
+
]
+
+
[[package]]
+
name = "r-efi"
+
version = "5.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+
[[package]]
+
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
"libc",
+
"rand_chacha 0.3.1",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+
dependencies = [
+
"rand_chacha 0.9.0",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
"getrandom 0.2.16",
+
]
+
+
[[package]]
+
name = "rand_core"
+
version = "0.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+
dependencies = [
+
"getrandom 0.3.4",
+
]
+
+
[[package]]
+
name = "range-traits"
+
version = "0.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
+
+
[[package]]
+
name = "redox_syscall"
+
version = "0.5.18"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+
dependencies = [
+
"bitflags",
+
]
+
+
[[package]]
+
name = "redox_users"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+
dependencies = [
+
"getrandom 0.2.16",
+
"libredox",
+
"thiserror 2.0.17",
+
]
+
+
[[package]]
+
name = "ref-cast"
+
version = "1.0.25"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+
dependencies = [
+
"ref-cast-impl",
+
]
+
+
[[package]]
+
name = "ref-cast-impl"
+
version = "1.0.25"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "regex"
+
version = "1.12.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+
dependencies = [
+
"aho-corasick",
+
"memchr",
+
"regex-automata",
+
"regex-syntax",
+
]
+
+
[[package]]
+
name = "regex-automata"
+
version = "0.4.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+
dependencies = [
+
"aho-corasick",
+
"memchr",
+
"regex-syntax",
+
]
+
+
[[package]]
+
name = "regex-syntax"
+
version = "0.8.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+
[[package]]
+
name = "reqwest"
+
version = "0.12.24"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
+
dependencies = [
+
"async-compression",
+
"base64 0.22.1",
+
"bytes",
+
"encoding_rs",
+
"futures-core",
+
"futures-util",
+
"h2",
+
"http",
+
"http-body",
+
"http-body-util",
+
"hyper",
+
"hyper-rustls",
+
"hyper-util",
+
"js-sys",
+
"log",
+
"mime",
+
"percent-encoding",
+
"pin-project-lite",
+
"quinn",
+
"rustls",
+
"rustls-pki-types",
+
"serde",
+
"serde_json",
+
"serde_urlencoded",
+
"sync_wrapper",
+
"tokio",
+
"tokio-rustls",
+
"tokio-util",
+
"tower",
+
"tower-http",
+
"tower-service",
+
"url",
+
"wasm-bindgen",
+
"wasm-bindgen-futures",
+
"wasm-streams",
+
"web-sys",
+
"webpki-roots",
+
]
+
+
[[package]]
+
name = "resolv-conf"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
+
+
[[package]]
+
name = "rfc6979"
+
version = "0.4.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+
dependencies = [
+
"hmac",
+
"subtle",
+
]
+
+
[[package]]
+
name = "ring"
+
version = "0.17.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+
dependencies = [
+
"cc",
+
"cfg-if",
+
"getrandom 0.2.16",
+
"libc",
+
"untrusted",
+
"windows-sys 0.52.0",
+
]
+
+
[[package]]
+
name = "rouille"
+
version = "3.6.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921"
+
dependencies = [
+
"base64 0.13.1",
+
"brotli",
+
"chrono",
+
"deflate",
+
"filetime",
+
"multipart",
+
"percent-encoding",
+
"rand 0.8.5",
+
"serde",
+
"serde_derive",
+
"serde_json",
+
"sha1_smol",
+
"threadpool",
+
"time",
+
"tiny_http",
+
"url",
+
]
+
+
[[package]]
+
name = "rsa"
+
version = "0.9.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
+
dependencies = [
+
"const-oid",
+
"digest",
+
"num-bigint-dig",
+
"num-integer",
+
"num-traits",
+
"pkcs1",
+
"pkcs8",
+
"rand_core 0.6.4",
+
"signature",
+
"spki",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "rustc-demangle"
+
version = "0.1.26"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
+
+
[[package]]
+
name = "rustc-hash"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+
[[package]]
+
name = "rustix"
+
version = "1.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
+
dependencies = [
+
"bitflags",
+
"errno",
+
"libc",
+
"linux-raw-sys",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "rustls"
+
version = "0.23.34"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
+
dependencies = [
+
"once_cell",
+
"ring",
+
"rustls-pki-types",
+
"rustls-webpki",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "rustls-pki-types"
+
version = "1.13.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
+
dependencies = [
+
"web-time",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "rustls-webpki"
+
version = "0.103.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
+
dependencies = [
+
"ring",
+
"rustls-pki-types",
+
"untrusted",
+
]
+
+
[[package]]
+
name = "rustversion"
+
version = "1.0.22"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+
[[package]]
+
name = "ryu"
+
version = "1.0.20"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+
[[package]]
+
name = "safemem"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
+
+
[[package]]
+
name = "same-file"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+
dependencies = [
+
"winapi-util",
+
]
+
+
[[package]]
+
name = "schemars"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+
dependencies = [
+
"dyn-clone",
+
"ref-cast",
+
"serde",
+
"serde_json",
+
]
+
+
[[package]]
+
name = "schemars"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
+
dependencies = [
+
"dyn-clone",
+
"ref-cast",
+
"serde",
+
"serde_json",
+
]
+
+
[[package]]
+
name = "scopeguard"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+
[[package]]
+
name = "sec1"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+
dependencies = [
+
"base16ct",
+
"der",
+
"generic-array",
+
"pkcs8",
+
"subtle",
+
"zeroize",
+
]
+
+
[[package]]
+
name = "serde"
+
version = "1.0.228"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+
dependencies = [
+
"serde_core",
+
"serde_derive",
+
]
+
+
[[package]]
+
name = "serde_bytes"
+
version = "0.11.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
+
dependencies = [
+
"serde",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "serde_core"
+
version = "1.0.228"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+
dependencies = [
+
"serde_derive",
+
]
+
+
[[package]]
+
name = "serde_derive"
+
version = "1.0.228"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "serde_html_form"
+
version = "0.2.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
+
dependencies = [
+
"form_urlencoded",
+
"indexmap 2.12.0",
+
"itoa",
+
"ryu",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "serde_ipld_dagcbor"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778"
+
dependencies = [
+
"cbor4ii",
+
"ipld-core",
+
"scopeguard",
+
"serde",
+
]
+
+
[[package]]
+
name = "serde_json"
+
version = "1.0.145"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+
dependencies = [
+
"itoa",
+
"memchr",
+
"ryu",
+
"serde",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "serde_repr"
+
version = "0.1.20"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "serde_urlencoded"
+
version = "0.7.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+
dependencies = [
+
"form_urlencoded",
+
"itoa",
+
"ryu",
+
"serde",
+
]
+
+
[[package]]
+
name = "serde_with"
+
version = "3.15.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04"
+
dependencies = [
+
"base64 0.22.1",
+
"chrono",
+
"hex",
+
"indexmap 1.9.3",
+
"indexmap 2.12.0",
+
"schemars 0.9.0",
+
"schemars 1.0.4",
+
"serde_core",
+
"serde_json",
+
"serde_with_macros",
+
"time",
+
]
+
+
[[package]]
+
name = "serde_with_macros"
+
version = "3.15.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955"
+
dependencies = [
+
"darling",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "sha1_smol"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+
[[package]]
+
name = "sha2"
+
version = "0.10.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+
dependencies = [
+
"cfg-if",
+
"cpufeatures",
+
"digest",
+
]
+
+
[[package]]
+
name = "shellexpand"
+
version = "3.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
+
dependencies = [
+
"dirs",
+
]
+
+
[[package]]
+
name = "shlex"
+
version = "1.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+
[[package]]
+
name = "signal-hook-registry"
+
version = "1.4.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
+
name = "signature"
+
version = "2.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+
dependencies = [
+
"digest",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "simd-adler32"
+
version = "0.3.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
+
[[package]]
+
name = "siphasher"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+
+
[[package]]
+
name = "slab"
+
version = "0.4.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+
[[package]]
+
name = "smallvec"
+
version = "1.15.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+
[[package]]
+
name = "smol_str"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3498b0a27f93ef1402f20eefacfaa1691272ac4eca1cdc8c596cb0a245d6cbf5"
+
dependencies = [
+
"borsh",
+
"serde_core",
+
]
+
+
[[package]]
+
name = "socket2"
+
version = "0.5.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+
dependencies = [
+
"libc",
+
"windows-sys 0.52.0",
+
]
+
+
[[package]]
+
name = "socket2"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+
dependencies = [
+
"libc",
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "spin"
+
version = "0.9.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+
[[package]]
+
name = "spki"
+
version = "0.7.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+
dependencies = [
+
"base64ct",
+
"der",
+
]
+
+
[[package]]
+
name = "stable_deref_trait"
+
version = "1.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+
[[package]]
+
name = "static-regular-grammar"
+
version = "2.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4f4a6c40247579acfbb138c3cd7de3dab113ab4ac6227f1b7de7d626ee667957"
+
dependencies = [
+
"abnf",
+
"btree-range-map",
+
"ciborium",
+
"hex_fmt",
+
"indoc",
+
"proc-macro-error",
+
"proc-macro2",
+
"quote",
+
"serde",
+
"sha2",
+
"syn 2.0.108",
+
"thiserror 1.0.69",
+
]
+
+
[[package]]
+
name = "static_assertions"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+
[[package]]
+
name = "string_cache"
+
version = "0.8.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+
dependencies = [
+
"new_debug_unreachable",
+
"parking_lot",
+
"phf_shared",
+
"precomputed-hash",
+
"serde",
+
]
+
+
[[package]]
+
name = "string_cache_codegen"
+
version = "0.5.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+
dependencies = [
+
"phf_generator",
+
"phf_shared",
+
"proc-macro2",
+
"quote",
+
]
+
+
[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+
[[package]]
+
name = "subtle"
+
version = "2.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+
[[package]]
+
name = "supports-color"
+
version = "3.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+
dependencies = [
+
"is_ci",
+
]
+
+
[[package]]
+
name = "supports-hyperlinks"
+
version = "3.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
+
+
[[package]]
+
name = "supports-unicode"
+
version = "3.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
+
[[package]]
+
name = "syn"
+
version = "1.0.109"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"unicode-ident",
+
]
+
+
[[package]]
+
name = "syn"
+
version = "2.0.108"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"unicode-ident",
+
]
+
+
[[package]]
+
name = "sync_wrapper"
+
version = "1.0.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
dependencies = [
+
"futures-core",
+
]
+
+
[[package]]
+
name = "synstructure"
+
version = "0.13.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "system-configuration"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+
dependencies = [
+
"bitflags",
+
"core-foundation 0.9.4",
+
"system-configuration-sys",
+
]
+
+
[[package]]
+
name = "system-configuration-sys"
+
version = "0.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+
dependencies = [
+
"core-foundation-sys",
+
"libc",
+
]
+
+
[[package]]
+
name = "tagptr"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+
[[package]]
+
name = "tempfile"
+
version = "3.23.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+
dependencies = [
+
"fastrand",
+
"getrandom 0.3.4",
+
"once_cell",
+
"rustix",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "tendril"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+
dependencies = [
+
"futf",
+
"mac",
+
"utf-8",
+
]
+
+
[[package]]
+
name = "terminal_size"
+
version = "0.4.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
+
dependencies = [
+
"rustix",
+
"windows-sys 0.60.2",
+
]
+
+
[[package]]
+
name = "textwrap"
+
version = "0.16.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+
dependencies = [
+
"unicode-linebreak",
+
"unicode-width 0.2.2",
+
]
+
+
[[package]]
+
name = "thiserror"
+
version = "1.0.69"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+
dependencies = [
+
"thiserror-impl 1.0.69",
+
]
+
+
[[package]]
+
name = "thiserror"
+
version = "2.0.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+
dependencies = [
+
"thiserror-impl 2.0.17",
+
]
+
+
[[package]]
+
name = "thiserror-impl"
+
version = "1.0.69"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "thiserror-impl"
+
version = "2.0.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "threadpool"
+
version = "1.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
+
dependencies = [
+
"num_cpus",
+
]
+
+
[[package]]
+
name = "time"
+
version = "0.3.44"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+
dependencies = [
+
"deranged",
+
"itoa",
+
"libc",
+
"num-conv",
+
"num_threads",
+
"powerfmt",
+
"serde",
+
"time-core",
+
"time-macros",
+
]
+
+
[[package]]
+
name = "time-core"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+
+
[[package]]
+
name = "time-macros"
+
version = "0.2.24"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+
dependencies = [
+
"num-conv",
+
"time-core",
+
]
+
+
[[package]]
+
name = "tiny_http"
+
version = "0.12.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+
dependencies = [
+
"ascii",
+
"chunked_transfer",
+
"httpdate",
+
"log",
+
]
+
+
[[package]]
+
name = "tinystr"
+
version = "0.8.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+
dependencies = [
+
"displaydoc",
+
"zerovec",
+
]
+
+
[[package]]
+
name = "tinyvec"
+
version = "1.10.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+
dependencies = [
+
"tinyvec_macros",
+
]
+
+
[[package]]
+
name = "tinyvec_macros"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+
[[package]]
+
name = "tokio"
+
version = "1.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
+
dependencies = [
+
"bytes",
+
"libc",
+
"mio",
+
"parking_lot",
+
"pin-project-lite",
+
"signal-hook-registry",
+
"socket2 0.6.1",
+
"tokio-macros",
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "tokio-macros"
+
version = "2.6.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "tokio-rustls"
+
version = "0.26.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+
dependencies = [
+
"rustls",
+
"tokio",
+
]
+
+
[[package]]
+
name = "tokio-util"
+
version = "0.7.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
+
dependencies = [
+
"bytes",
+
"futures-core",
+
"futures-sink",
+
"pin-project-lite",
+
"tokio",
+
]
+
+
[[package]]
+
name = "tower"
+
version = "0.5.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+
dependencies = [
+
"futures-core",
+
"futures-util",
+
"pin-project-lite",
+
"sync_wrapper",
+
"tokio",
+
"tower-layer",
+
"tower-service",
+
]
+
+
[[package]]
+
name = "tower-http"
+
version = "0.6.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
+
dependencies = [
+
"bitflags",
+
"bytes",
+
"futures-util",
+
"http",
+
"http-body",
+
"iri-string",
+
"pin-project-lite",
+
"tower",
+
"tower-layer",
+
"tower-service",
+
]
+
+
[[package]]
+
name = "tower-layer"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+
[[package]]
+
name = "tower-service"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+
[[package]]
+
name = "tracing"
+
version = "0.1.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+
dependencies = [
+
"pin-project-lite",
+
"tracing-attributes",
+
"tracing-core",
+
]
+
+
[[package]]
+
name = "tracing-attributes"
+
version = "0.1.30"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "tracing-core"
+
version = "0.1.34"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+
dependencies = [
+
"once_cell",
+
]
+
+
[[package]]
+
name = "trait-variant"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "triomphe"
+
version = "0.1.15"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
+
+
[[package]]
+
name = "try-lock"
+
version = "0.2.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+
[[package]]
+
name = "twoway"
+
version = "0.1.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1"
+
dependencies = [
+
"memchr",
+
]
+
+
[[package]]
+
name = "typenum"
+
version = "1.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+
[[package]]
+
name = "unicase"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
+
[[package]]
+
name = "unicode-ident"
+
version = "1.0.22"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+
[[package]]
+
name = "unicode-linebreak"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+
[[package]]
+
name = "unicode-segmentation"
+
version = "1.12.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+
[[package]]
+
name = "unicode-width"
+
version = "0.1.14"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+
[[package]]
+
name = "unicode-width"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+
[[package]]
+
name = "unsigned-varint"
+
version = "0.8.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
+
+
[[package]]
+
name = "untrusted"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+
[[package]]
+
name = "url"
+
version = "2.5.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
+
dependencies = [
+
"form_urlencoded",
+
"idna",
+
"percent-encoding",
+
"serde",
+
]
+
+
[[package]]
+
name = "urlencoding"
+
version = "2.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+
[[package]]
+
name = "utf-8"
+
version = "0.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+
[[package]]
+
name = "utf8_iter"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+
[[package]]
+
name = "utf8parse"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+
[[package]]
+
name = "version_check"
+
version = "0.9.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+
[[package]]
+
name = "walkdir"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+
dependencies = [
+
"same-file",
+
"winapi-util",
+
]
+
+
[[package]]
+
name = "want"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+
dependencies = [
+
"try-lock",
+
]
+
+
[[package]]
+
name = "wasi"
+
version = "0.11.1+wasi-snapshot-preview1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+
[[package]]
+
name = "wasip2"
+
version = "1.0.1+wasi-0.2.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+
dependencies = [
+
"wit-bindgen",
+
]
+
+
[[package]]
+
name = "wasm-bindgen"
+
version = "0.2.105"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
+
dependencies = [
+
"cfg-if",
+
"once_cell",
+
"rustversion",
+
"wasm-bindgen-macro",
+
"wasm-bindgen-shared",
+
]
+
+
[[package]]
+
name = "wasm-bindgen-futures"
+
version = "0.4.55"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
+
dependencies = [
+
"cfg-if",
+
"js-sys",
+
"once_cell",
+
"wasm-bindgen",
+
"web-sys",
+
]
+
+
[[package]]
+
name = "wasm-bindgen-macro"
+
version = "0.2.105"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
+
dependencies = [
+
"quote",
+
"wasm-bindgen-macro-support",
+
]
+
+
[[package]]
+
name = "wasm-bindgen-macro-support"
+
version = "0.2.105"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
+
dependencies = [
+
"bumpalo",
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
"wasm-bindgen-shared",
+
]
+
+
[[package]]
+
name = "wasm-bindgen-shared"
+
version = "0.2.105"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
+
dependencies = [
+
"unicode-ident",
+
]
+
+
[[package]]
+
name = "wasm-streams"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+
dependencies = [
+
"futures-util",
+
"js-sys",
+
"wasm-bindgen",
+
"wasm-bindgen-futures",
+
"web-sys",
+
]
+
+
[[package]]
+
name = "web-sys"
+
version = "0.3.82"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
+
dependencies = [
+
"js-sys",
+
"wasm-bindgen",
+
]
+
+
[[package]]
+
name = "web-time"
+
version = "1.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+
dependencies = [
+
"js-sys",
+
"wasm-bindgen",
+
]
+
+
[[package]]
+
name = "webbrowser"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
+
dependencies = [
+
"core-foundation 0.10.1",
+
"jni",
+
"log",
+
"ndk-context",
+
"objc2",
+
"objc2-foundation",
+
"url",
+
"web-sys",
+
]
+
+
[[package]]
+
name = "webpage"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70862efc041d46e6bbaa82bb9c34ae0596d090e86cbd14bd9e93b36ee6802eac"
+
dependencies = [
+
"html5ever",
+
"markup5ever_rcdom",
+
"serde_json",
+
"url",
+
]
+
+
[[package]]
+
name = "webpki-roots"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
+
dependencies = [
+
"rustls-pki-types",
+
]
+
+
[[package]]
+
name = "widestring"
+
version = "1.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+
[[package]]
+
name = "winapi-util"
+
version = "0.1.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+
dependencies = [
+
"windows-sys 0.61.2",
+
]
+
+
[[package]]
+
name = "windows-core"
+
version = "0.62.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+
dependencies = [
+
"windows-implement",
+
"windows-interface",
+
"windows-link 0.2.1",
+
"windows-result 0.4.1",
+
"windows-strings 0.5.1",
+
]
+
+
[[package]]
+
name = "windows-implement"
+
version = "0.60.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "windows-interface"
+
version = "0.59.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "windows-link"
+
version = "0.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+
[[package]]
+
name = "windows-link"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+
[[package]]
+
name = "windows-registry"
+
version = "0.5.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+
dependencies = [
+
"windows-link 0.1.3",
+
"windows-result 0.3.4",
+
"windows-strings 0.4.2",
+
]
+
+
[[package]]
+
name = "windows-result"
+
version = "0.3.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+
dependencies = [
+
"windows-link 0.1.3",
+
]
+
+
[[package]]
+
name = "windows-result"
+
version = "0.4.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+
dependencies = [
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "windows-strings"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+
dependencies = [
+
"windows-link 0.1.3",
+
]
+
+
[[package]]
+
name = "windows-strings"
+
version = "0.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+
dependencies = [
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "windows-sys"
+
version = "0.45.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+
dependencies = [
+
"windows-targets 0.42.2",
+
]
+
+
[[package]]
+
name = "windows-sys"
+
version = "0.48.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+
dependencies = [
+
"windows-targets 0.48.5",
+
]
+
+
[[package]]
+
name = "windows-sys"
+
version = "0.52.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+
dependencies = [
+
"windows-targets 0.52.6",
+
]
+
+
[[package]]
+
name = "windows-sys"
+
version = "0.60.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+
dependencies = [
+
"windows-targets 0.53.5",
+
]
+
+
[[package]]
+
name = "windows-sys"
+
version = "0.61.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+
dependencies = [
+
"windows-link 0.2.1",
+
]
+
+
[[package]]
+
name = "windows-targets"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+
dependencies = [
+
"windows_aarch64_gnullvm 0.42.2",
+
"windows_aarch64_msvc 0.42.2",
+
"windows_i686_gnu 0.42.2",
+
"windows_i686_msvc 0.42.2",
+
"windows_x86_64_gnu 0.42.2",
+
"windows_x86_64_gnullvm 0.42.2",
+
"windows_x86_64_msvc 0.42.2",
+
]
+
+
[[package]]
+
name = "windows-targets"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+
dependencies = [
+
"windows_aarch64_gnullvm 0.48.5",
+
"windows_aarch64_msvc 0.48.5",
+
"windows_i686_gnu 0.48.5",
+
"windows_i686_msvc 0.48.5",
+
"windows_x86_64_gnu 0.48.5",
+
"windows_x86_64_gnullvm 0.48.5",
+
"windows_x86_64_msvc 0.48.5",
+
]
+
+
[[package]]
+
name = "windows-targets"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+
dependencies = [
+
"windows_aarch64_gnullvm 0.52.6",
+
"windows_aarch64_msvc 0.52.6",
+
"windows_i686_gnu 0.52.6",
+
"windows_i686_gnullvm 0.52.6",
+
"windows_i686_msvc 0.52.6",
+
"windows_x86_64_gnu 0.52.6",
+
"windows_x86_64_gnullvm 0.52.6",
+
"windows_x86_64_msvc 0.52.6",
+
]
+
+
[[package]]
+
name = "windows-targets"
+
version = "0.53.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+
dependencies = [
+
"windows-link 0.2.1",
+
"windows_aarch64_gnullvm 0.53.1",
+
"windows_aarch64_msvc 0.53.1",
+
"windows_i686_gnu 0.53.1",
+
"windows_i686_gnullvm 0.53.1",
+
"windows_i686_msvc 0.53.1",
+
"windows_x86_64_gnu 0.53.1",
+
"windows_x86_64_gnullvm 0.53.1",
+
"windows_x86_64_msvc 0.53.1",
+
]
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+
[[package]]
+
name = "windows_aarch64_gnullvm"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+
[[package]]
+
name = "windows_aarch64_msvc"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+
[[package]]
+
name = "windows_i686_gnu"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+
[[package]]
+
name = "windows_i686_gnullvm"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+
[[package]]
+
name = "windows_i686_gnullvm"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+
[[package]]
+
name = "windows_i686_msvc"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+
[[package]]
+
name = "windows_x86_64_gnu"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+
[[package]]
+
name = "windows_x86_64_gnullvm"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.42.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.48.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.52.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+
[[package]]
+
name = "windows_x86_64_msvc"
+
version = "0.53.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+
[[package]]
+
name = "winreg"
+
version = "0.50.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+
dependencies = [
+
"cfg-if",
+
"windows-sys 0.48.0",
+
]
+
+
[[package]]
+
name = "wisp-cli"
+
version = "0.1.0"
+
dependencies = [
+
"base64 0.22.1",
+
"bytes",
+
"clap",
+
"flate2",
+
"futures",
+
"jacquard",
+
"jacquard-api",
+
"jacquard-common",
+
"jacquard-derive",
+
"jacquard-identity",
+
"jacquard-lexicon",
+
"jacquard-oauth",
+
"miette",
+
"mime_guess",
+
"reqwest",
+
"rustversion",
+
"serde",
+
"serde_json",
+
"shellexpand",
+
"tokio",
+
"walkdir",
+
]
+
+
[[package]]
+
name = "wit-bindgen"
+
version = "0.46.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+
+
[[package]]
+
name = "writeable"
+
version = "0.6.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+
[[package]]
+
name = "xml5ever"
+
version = "0.18.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
+
dependencies = [
+
"log",
+
"mac",
+
"markup5ever",
+
]
+
+
[[package]]
+
name = "yansi"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+
[[package]]
+
name = "yoke"
+
version = "0.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+
dependencies = [
+
"stable_deref_trait",
+
"yoke-derive",
+
"zerofrom",
+
]
+
+
[[package]]
+
name = "yoke-derive"
+
version = "0.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
"synstructure",
+
]
+
+
[[package]]
+
name = "zerocopy"
+
version = "0.8.27"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
+
dependencies = [
+
"zerocopy-derive",
+
]
+
+
[[package]]
+
name = "zerocopy-derive"
+
version = "0.8.27"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+
+
[[package]]
+
name = "zerofrom"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+
dependencies = [
+
"zerofrom-derive",
+
]
+
+
[[package]]
+
name = "zerofrom-derive"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
"synstructure",
+
]
+
+
[[package]]
+
name = "zeroize"
+
version = "1.8.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
dependencies = [
+
"serde",
+
]
+
+
[[package]]
+
name = "zerotrie"
+
version = "0.2.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+
dependencies = [
+
"displaydoc",
+
"yoke",
+
"zerofrom",
+
]
+
+
[[package]]
+
name = "zerovec"
+
version = "0.11.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+
dependencies = [
+
"yoke",
+
"zerofrom",
+
"zerovec-derive",
+
]
+
+
[[package]]
+
name = "zerovec-derive"
+
version = "0.11.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.108",
+
]
+32
cli/Cargo.toml
···
+
[package]
+
name = "wisp-cli"
+
version = "0.1.0"
+
edition = "2024"
+
+
[features]
+
default = ["place_wisp"]
+
place_wisp = []
+
+
[dependencies]
+
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 = { version = "0.12", default-features = false, features = ["rustls-tls"] }
+
rustversion = "1.0"
+
flate2 = "1.0"
+
base64 = "0.22"
+
walkdir = "2.5"
+
mime_guess = "2.0"
+
bytes = "1.10"
+
futures = "0.3.31"
+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
+51
cli/lexicons/place/wisp/fs.json
···
+
{
+
"lexicon": 1,
+
"id": "place.wisp.fs",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Virtual filesystem manifest for a Wisp site",
+
"record": {
+
"type": "object",
+
"required": ["site", "root", "createdAt"],
+
"properties": {
+
"site": { "type": "string" },
+
"root": { "type": "ref", "ref": "#directory" },
+
"fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
+
"createdAt": { "type": "string", "format": "datetime" }
+
}
+
}
+
},
+
"file": {
+
"type": "object",
+
"required": ["type", "blob"],
+
"properties": {
+
"type": { "type": "string", "const": "file" },
+
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" },
+
"encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" },
+
"mimeType": { "type": "string", "description": "Original MIME type before compression" },
+
"base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" }
+
}
+
},
+
"directory": {
+
"type": "object",
+
"required": ["type", "entries"],
+
"properties": {
+
"type": { "type": "string", "const": "directory" },
+
"entries": {
+
"type": "array",
+
"maxLength": 500,
+
"items": { "type": "ref", "ref": "#entry" }
+
}
+
}
+
},
+
"entry": {
+
"type": "object",
+
"required": ["name", "node"],
+
"properties": {
+
"name": { "type": "string", "maxLength": 255 },
+
"node": { "type": "union", "refs": ["#file", "#directory"] }
+
}
+
}
+
}
+
}
+43
cli/src/builder_types.rs
···
+
// @generated by jacquard-lexicon. DO NOT EDIT.
+
//
+
// This file was automatically generated from Lexicon schemas.
+
// Any manual changes will be overwritten on the next regeneration.
+
+
/// Marker type indicating a builder field has been set
+
pub struct Set<T>(pub T);
+
impl<T> Set<T> {
+
/// Extract the inner value
+
#[inline]
+
pub fn into_inner(self) -> T {
+
self.0
+
}
+
}
+
+
/// Marker type indicating a builder field has not been set
+
pub struct Unset;
+
/// Trait indicating a builder field is set (has a value)
+
#[rustversion::attr(
+
since(1.78.0),
+
diagnostic::on_unimplemented(
+
message = "the field `{Self}` was not set, but this method requires it to be set",
+
label = "the field `{Self}` was not set"
+
)
+
)]
+
pub trait IsSet: private::Sealed {}
+
/// Trait indicating a builder field is unset (no value yet)
+
#[rustversion::attr(
+
since(1.78.0),
+
diagnostic::on_unimplemented(
+
message = "the field `{Self}` was already set, but this method requires it to be unset",
+
label = "the field `{Self}` was already set"
+
)
+
)]
+
pub trait IsUnset: private::Sealed {}
+
impl<T> IsSet for Set<T> {}
+
impl IsUnset for Unset {}
+
mod private {
+
/// Sealed trait to prevent external implementations
+
pub trait Sealed {}
+
impl<T> Sealed for super::Set<T> {}
+
impl Sealed for super::Unset {}
+
}
+263
cli/src/main.rs
···
+
mod builder_types;
+
mod place_wisp;
+
+
use clap::Parser;
+
use jacquard::CowStr;
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
+
use jacquard::oauth::client::OAuthClient;
+
use jacquard::oauth::loopback::LoopbackConfig;
+
use jacquard::prelude::IdentityResolver;
+
use jacquard_common::types::string::{Datetime, Rkey, RecordKey};
+
use jacquard_common::types::blob::MimeType;
+
use miette::IntoDiagnostic;
+
use std::path::{Path, PathBuf};
+
use flate2::Compression;
+
use flate2::write::GzEncoder;
+
use std::io::Write;
+
use base64::Engine;
+
use futures::stream::{self, StreamExt};
+
+
use place_wisp::fs::*;
+
+
#[derive(Parser, Debug)]
+
#[command(author, version, about = "Deploy a static site to wisp.place")]
+
struct Args {
+
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
+
input: CowStr<'static>,
+
+
/// Path to the directory containing your static site
+
#[arg(short, long, default_value = ".")]
+
path: PathBuf,
+
+
/// Site name (defaults to directory name)
+
#[arg(short, long)]
+
site: Option<String>,
+
+
/// Path to auth store file (will be created if missing, only used with OAuth)
+
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
+
store: String,
+
+
/// App Password for authentication (alternative to OAuth)
+
#[arg(long)]
+
password: Option<CowStr<'static>>,
+
}
+
+
#[tokio::main]
+
async fn main() -> miette::Result<()> {
+
let args = Args::parse();
+
+
// Dispatch to appropriate authentication method
+
if let Some(password) = args.password {
+
run_with_app_password(args.input, password, args.path, args.site).await
+
} else {
+
run_with_oauth(args.input, args.store, args.path, args.site).await
+
}
+
}
+
+
/// Run deployment with app password authentication
+
async fn run_with_app_password(
+
input: CowStr<'static>,
+
password: CowStr<'static>,
+
path: PathBuf,
+
site: Option<String>,
+
) -> miette::Result<()> {
+
let (session, auth) =
+
MemoryCredentialSession::authenticated(input, password, None).await?;
+
println!("Signed in as {}", auth.handle);
+
+
let agent: Agent<_> = Agent::from(session);
+
deploy_site(&agent, path, site).await
+
}
+
+
/// Run deployment with OAuth authentication
+
async fn run_with_oauth(
+
input: CowStr<'static>,
+
store: String,
+
path: PathBuf,
+
site: Option<String>,
+
) -> miette::Result<()> {
+
let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store));
+
let session = oauth
+
.login_with_local_server(input, Default::default(), LoopbackConfig::default())
+
.await?;
+
+
let agent: Agent<_> = Agent::from(session);
+
deploy_site(&agent, path, site).await
+
}
+
+
/// Deploy the site using the provided agent
+
async fn deploy_site(
+
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
+
path: PathBuf,
+
site: Option<String>,
+
) -> miette::Result<()> {
+
// Verify the path exists
+
if !path.exists() {
+
return Err(miette::miette!("Path does not exist: {}", path.display()));
+
}
+
+
// Get site name
+
let site_name = site.unwrap_or_else(|| {
+
path
+
.file_name()
+
.and_then(|n| n.to_str())
+
.unwrap_or("site")
+
.to_string()
+
});
+
+
println!("Deploying site '{}'...", site_name);
+
+
// Build directory tree
+
let root_dir = build_directory(agent, &path).await?;
+
+
// Count total files
+
let file_count = count_files(&root_dir);
+
+
// Create the Fs record
+
let fs_record = Fs::new()
+
.site(CowStr::from(site_name.clone()))
+
.root(root_dir)
+
.file_count(file_count as i64)
+
.created_at(Datetime::now())
+
.build();
+
+
// Use site name as the record key
+
let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?;
+
let output = agent.put_record(RecordKey::from(rkey), fs_record).await?;
+
+
// Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey)
+
let uri_str = output.uri.to_string();
+
let did = uri_str
+
.strip_prefix("at://")
+
.and_then(|s| s.split('/').next())
+
.ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
+
+
println!("Deployed site '{}': {}", site_name, output.uri);
+
println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
+
+
Ok(())
+
}
+
+
/// Recursively build a Directory from a filesystem path
+
fn build_directory<'a>(
+
agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
+
dir_path: &'a Path,
+
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
+
{
+
Box::pin(async move {
+
// Collect all directory entries first
+
let dir_entries: Vec<_> = std::fs::read_dir(dir_path)
+
.into_diagnostic()?
+
.collect::<Result<Vec<_>, _>>()
+
.into_diagnostic()?;
+
+
// Separate files and directories
+
let mut file_tasks = Vec::new();
+
let mut dir_tasks = Vec::new();
+
+
for entry in dir_entries {
+
let path = entry.path();
+
let name = entry.file_name();
+
let name_str = name.to_str()
+
.ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?
+
.to_string();
+
+
// Skip hidden files
+
if name_str.starts_with('.') {
+
continue;
+
}
+
+
let metadata = entry.metadata().into_diagnostic()?;
+
+
if metadata.is_file() {
+
file_tasks.push((name_str, path));
+
} else if metadata.is_dir() {
+
dir_tasks.push((name_str, path));
+
}
+
}
+
+
// Process files concurrently with a limit of 5
+
let file_entries: Vec<Entry> = stream::iter(file_tasks)
+
.map(|(name, path)| async move {
+
let file_node = process_file(agent, &path).await?;
+
Ok::<_, miette::Report>(Entry::new()
+
.name(CowStr::from(name))
+
.node(EntryNode::File(Box::new(file_node)))
+
.build())
+
})
+
.buffer_unordered(5)
+
.collect::<Vec<_>>()
+
.await
+
.into_iter()
+
.collect::<miette::Result<Vec<_>>>()?;
+
+
// Process directories recursively (sequentially to avoid too much nesting)
+
let mut dir_entries = Vec::new();
+
for (name, path) in dir_tasks {
+
let subdir = build_directory(agent, &path).await?;
+
dir_entries.push(Entry::new()
+
.name(CowStr::from(name))
+
.node(EntryNode::Directory(Box::new(subdir)))
+
.build());
+
}
+
+
// Combine file and directory entries
+
let mut entries = file_entries;
+
entries.extend(dir_entries);
+
+
Ok(Directory::new()
+
.r#type(CowStr::from("directory"))
+
.entries(entries)
+
.build())
+
})
+
}
+
+
/// Process a single file: gzip -> base64 -> upload blob
+
async fn process_file(
+
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
+
file_path: &Path,
+
) -> miette::Result<File<'static>>
+
{
+
// Read file
+
let file_data = std::fs::read(file_path).into_diagnostic()?;
+
+
// Detect original MIME type
+
let original_mime = mime_guess::from_path(file_path)
+
.first_or_octet_stream()
+
.to_string();
+
+
// Gzip compress
+
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
+
encoder.write_all(&file_data).into_diagnostic()?;
+
let gzipped = encoder.finish().into_diagnostic()?;
+
+
// Base64 encode the gzipped data
+
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
+
+
// Upload blob as octet-stream
+
let blob = agent.upload_blob(
+
base64_bytes,
+
MimeType::new_static("application/octet-stream"),
+
).await?;
+
+
Ok(File::new()
+
.r#type(CowStr::from("file"))
+
.blob(blob)
+
.encoding(CowStr::from("gzip"))
+
.mime_type(CowStr::from(original_mime))
+
.base64(true)
+
.build())
+
}
+
+
/// Count total files in a directory tree
+
fn count_files(dir: &Directory) -> usize {
+
let mut count = 0;
+
for entry in &dir.entries {
+
match &entry.node {
+
EntryNode::File(_) => count += 1,
+
EntryNode::Directory(subdir) => count += count_files(subdir),
+
_ => {} // Unknown variants
+
}
+
}
+
count
+
}
+9
cli/src/mod.rs
···
+
// @generated by jacquard-lexicon. DO NOT EDIT.
+
//
+
// This file was automatically generated from Lexicon schemas.
+
// Any manual changes will be overwritten on the next regeneration.
+
+
pub mod builder_types;
+
+
#[cfg(feature = "place_wisp")]
+
pub mod place_wisp;
+1230
cli/src/place_wisp/fs.rs
···
+
// @generated by jacquard-lexicon. DO NOT EDIT.
+
//
+
// Lexicon: place.wisp.fs
+
//
+
// This file was automatically generated from Lexicon schemas.
+
// Any manual changes will be overwritten on the next regeneration.
+
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Directory<'a> {
+
#[serde(borrow)]
+
pub entries: Vec<crate::place_wisp::fs::Entry<'a>>,
+
#[serde(borrow)]
+
pub r#type: jacquard_common::CowStr<'a>,
+
}
+
+
pub mod directory_state {
+
+
pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
+
#[allow(unused)]
+
use ::core::marker::PhantomData;
+
mod sealed {
+
pub trait Sealed {}
+
}
+
/// State trait tracking which required fields have been set
+
pub trait State: sealed::Sealed {
+
type Type;
+
type Entries;
+
}
+
/// Empty state - all required fields are unset
+
pub struct Empty(());
+
impl sealed::Sealed for Empty {}
+
impl State for Empty {
+
type Type = Unset;
+
type Entries = Unset;
+
}
+
///State transition - sets the `type` field to Set
+
pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetType<S> {}
+
impl<S: State> State for SetType<S> {
+
type Type = Set<members::r#type>;
+
type Entries = S::Entries;
+
}
+
///State transition - sets the `entries` field to Set
+
pub struct SetEntries<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetEntries<S> {}
+
impl<S: State> State for SetEntries<S> {
+
type Type = S::Type;
+
type Entries = Set<members::entries>;
+
}
+
/// Marker types for field names
+
#[allow(non_camel_case_types)]
+
pub mod members {
+
///Marker type for the `type` field
+
pub struct r#type(());
+
///Marker type for the `entries` field
+
pub struct entries(());
+
}
+
}
+
+
/// Builder for constructing an instance of this type
+
pub struct DirectoryBuilder<'a, S: directory_state::State> {
+
_phantom_state: ::core::marker::PhantomData<fn() -> S>,
+
__unsafe_private_named: (
+
::core::option::Option<Vec<crate::place_wisp::fs::Entry<'a>>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
),
+
_phantom: ::core::marker::PhantomData<&'a ()>,
+
}
+
+
impl<'a> Directory<'a> {
+
/// Create a new builder for this type
+
pub fn new() -> DirectoryBuilder<'a, directory_state::Empty> {
+
DirectoryBuilder::new()
+
}
+
}
+
+
impl<'a> DirectoryBuilder<'a, directory_state::Empty> {
+
/// Create a new builder with all fields unset
+
pub fn new() -> Self {
+
DirectoryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: (None, None),
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> DirectoryBuilder<'a, S>
+
where
+
S: directory_state::State,
+
S::Entries: directory_state::IsUnset,
+
{
+
/// Set the `entries` field (required)
+
pub fn entries(
+
mut self,
+
value: impl Into<Vec<crate::place_wisp::fs::Entry<'a>>>,
+
) -> DirectoryBuilder<'a, directory_state::SetEntries<S>> {
+
self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into());
+
DirectoryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> DirectoryBuilder<'a, S>
+
where
+
S: directory_state::State,
+
S::Type: directory_state::IsUnset,
+
{
+
/// Set the `type` field (required)
+
pub fn r#type(
+
mut self,
+
value: impl Into<jacquard_common::CowStr<'a>>,
+
) -> DirectoryBuilder<'a, directory_state::SetType<S>> {
+
self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into());
+
DirectoryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> DirectoryBuilder<'a, S>
+
where
+
S: directory_state::State,
+
S::Type: directory_state::IsSet,
+
S::Entries: directory_state::IsSet,
+
{
+
/// Build the final struct
+
pub fn build(self) -> Directory<'a> {
+
Directory {
+
entries: self.__unsafe_private_named.0.unwrap(),
+
r#type: self.__unsafe_private_named.1.unwrap(),
+
extra_data: Default::default(),
+
}
+
}
+
/// Build the final struct with custom extra_data
+
pub fn build_with_data(
+
self,
+
extra_data: std::collections::BTreeMap<
+
jacquard_common::smol_str::SmolStr,
+
jacquard_common::types::value::Data<'a>,
+
>,
+
) -> Directory<'a> {
+
Directory {
+
entries: self.__unsafe_private_named.0.unwrap(),
+
r#type: self.__unsafe_private_named.1.unwrap(),
+
extra_data: Some(extra_data),
+
}
+
}
+
}
+
+
fn lexicon_doc_place_wisp_fs() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
::jacquard_lexicon::lexicon::LexiconDoc {
+
lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1,
+
id: ::jacquard_common::CowStr::new_static("place.wisp.fs"),
+
revision: None,
+
description: None,
+
defs: {
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("directory"),
+
::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: None,
+
required: Some(
+
vec![
+
::jacquard_common::smol_str::SmolStr::new_static("type"),
+
::jacquard_common::smol_str::SmolStr::new_static("entries")
+
],
+
),
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("entries"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray {
+
description: None,
+
items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef {
+
description: None,
+
r#ref: ::jacquard_common::CowStr::new_static("#entry"),
+
}),
+
min_length: None,
+
max_length: Some(500usize),
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("type"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map
+
},
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("entry"),
+
::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: None,
+
required: Some(
+
vec![
+
::jacquard_common::smol_str::SmolStr::new_static("name"),
+
::jacquard_common::smol_str::SmolStr::new_static("node")
+
],
+
),
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("name"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: Some(255usize),
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("node"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Union(::jacquard_lexicon::lexicon::LexRefUnion {
+
description: None,
+
refs: vec![
+
::jacquard_common::CowStr::new_static("#file"),
+
::jacquard_common::CowStr::new_static("#directory")
+
],
+
closed: None,
+
}),
+
);
+
map
+
},
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("file"),
+
::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: None,
+
required: Some(
+
vec![
+
::jacquard_common::smol_str::SmolStr::new_static("type"),
+
::jacquard_common::smol_str::SmolStr::new_static("blob")
+
],
+
),
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("base64"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean {
+
description: None,
+
default: None,
+
r#const: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("blob"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Blob(::jacquard_lexicon::lexicon::LexBlob {
+
description: None,
+
accept: None,
+
max_size: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("encoding"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Content encoding (e.g., gzip for compressed files)",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("mimeType"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Original MIME type before compression",
+
),
+
),
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("type"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map
+
},
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("main"),
+
::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord {
+
description: Some(
+
::jacquard_common::CowStr::new_static(
+
"Virtual filesystem manifest for a Wisp site",
+
),
+
),
+
key: None,
+
record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject {
+
description: None,
+
required: Some(
+
vec![
+
::jacquard_common::smol_str::SmolStr::new_static("site"),
+
::jacquard_common::smol_str::SmolStr::new_static("root"),
+
::jacquard_common::smol_str::SmolStr::new_static("createdAt")
+
],
+
),
+
nullable: None,
+
properties: {
+
#[allow(unused_mut)]
+
let mut map = ::std::collections::BTreeMap::new();
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"createdAt",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: Some(
+
::jacquard_lexicon::lexicon::LexStringFormat::Datetime,
+
),
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static(
+
"fileCount",
+
),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Integer(::jacquard_lexicon::lexicon::LexInteger {
+
description: None,
+
default: None,
+
minimum: Some(0i64),
+
maximum: Some(1000i64),
+
r#enum: None,
+
r#const: None,
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("root"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef {
+
description: None,
+
r#ref: ::jacquard_common::CowStr::new_static("#directory"),
+
}),
+
);
+
map.insert(
+
::jacquard_common::smol_str::SmolStr::new_static("site"),
+
::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString {
+
description: None,
+
format: None,
+
default: None,
+
min_length: None,
+
max_length: None,
+
min_graphemes: None,
+
max_graphemes: None,
+
r#enum: None,
+
r#const: None,
+
known_values: None,
+
}),
+
);
+
map
+
},
+
}),
+
}),
+
);
+
map
+
},
+
}
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Directory<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.fs"
+
}
+
fn def_name() -> &'static str {
+
"directory"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_fs()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
{
+
let value = &self.entries;
+
#[allow(unused_comparisons)]
+
if value.len() > 500usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"entries",
+
),
+
max: 500usize,
+
actual: value.len(),
+
});
+
}
+
}
+
Ok(())
+
}
+
}
+
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Entry<'a> {
+
#[serde(borrow)]
+
pub name: jacquard_common::CowStr<'a>,
+
#[serde(borrow)]
+
pub node: EntryNode<'a>,
+
}
+
+
pub mod entry_state {
+
+
pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
+
#[allow(unused)]
+
use ::core::marker::PhantomData;
+
mod sealed {
+
pub trait Sealed {}
+
}
+
/// State trait tracking which required fields have been set
+
pub trait State: sealed::Sealed {
+
type Name;
+
type Node;
+
}
+
/// Empty state - all required fields are unset
+
pub struct Empty(());
+
impl sealed::Sealed for Empty {}
+
impl State for Empty {
+
type Name = Unset;
+
type Node = Unset;
+
}
+
///State transition - sets the `name` field to Set
+
pub struct SetName<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetName<S> {}
+
impl<S: State> State for SetName<S> {
+
type Name = Set<members::name>;
+
type Node = S::Node;
+
}
+
///State transition - sets the `node` field to Set
+
pub struct SetNode<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetNode<S> {}
+
impl<S: State> State for SetNode<S> {
+
type Name = S::Name;
+
type Node = Set<members::node>;
+
}
+
/// Marker types for field names
+
#[allow(non_camel_case_types)]
+
pub mod members {
+
///Marker type for the `name` field
+
pub struct name(());
+
///Marker type for the `node` field
+
pub struct node(());
+
}
+
}
+
+
/// Builder for constructing an instance of this type
+
pub struct EntryBuilder<'a, S: entry_state::State> {
+
_phantom_state: ::core::marker::PhantomData<fn() -> S>,
+
__unsafe_private_named: (
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
::core::option::Option<EntryNode<'a>>,
+
),
+
_phantom: ::core::marker::PhantomData<&'a ()>,
+
}
+
+
impl<'a> Entry<'a> {
+
/// Create a new builder for this type
+
pub fn new() -> EntryBuilder<'a, entry_state::Empty> {
+
EntryBuilder::new()
+
}
+
}
+
+
impl<'a> EntryBuilder<'a, entry_state::Empty> {
+
/// Create a new builder with all fields unset
+
pub fn new() -> Self {
+
EntryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: (None, None),
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> EntryBuilder<'a, S>
+
where
+
S: entry_state::State,
+
S::Name: entry_state::IsUnset,
+
{
+
/// Set the `name` field (required)
+
pub fn name(
+
mut self,
+
value: impl Into<jacquard_common::CowStr<'a>>,
+
) -> EntryBuilder<'a, entry_state::SetName<S>> {
+
self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into());
+
EntryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> EntryBuilder<'a, S>
+
where
+
S: entry_state::State,
+
S::Node: entry_state::IsUnset,
+
{
+
/// Set the `node` field (required)
+
pub fn node(
+
mut self,
+
value: impl Into<EntryNode<'a>>,
+
) -> EntryBuilder<'a, entry_state::SetNode<S>> {
+
self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into());
+
EntryBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> EntryBuilder<'a, S>
+
where
+
S: entry_state::State,
+
S::Name: entry_state::IsSet,
+
S::Node: entry_state::IsSet,
+
{
+
/// Build the final struct
+
pub fn build(self) -> Entry<'a> {
+
Entry {
+
name: self.__unsafe_private_named.0.unwrap(),
+
node: self.__unsafe_private_named.1.unwrap(),
+
extra_data: Default::default(),
+
}
+
}
+
/// Build the final struct with custom extra_data
+
pub fn build_with_data(
+
self,
+
extra_data: std::collections::BTreeMap<
+
jacquard_common::smol_str::SmolStr,
+
jacquard_common::types::value::Data<'a>,
+
>,
+
) -> Entry<'a> {
+
Entry {
+
name: self.__unsafe_private_named.0.unwrap(),
+
node: self.__unsafe_private_named.1.unwrap(),
+
extra_data: Some(extra_data),
+
}
+
}
+
}
+
+
#[jacquard_derive::open_union]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(tag = "$type")]
+
#[serde(bound(deserialize = "'de: 'a"))]
+
pub enum EntryNode<'a> {
+
#[serde(rename = "place.wisp.fs#file")]
+
File(Box<crate::place_wisp::fs::File<'a>>),
+
#[serde(rename = "place.wisp.fs#directory")]
+
Directory(Box<crate::place_wisp::fs::Directory<'a>>),
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Entry<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.fs"
+
}
+
fn def_name() -> &'static str {
+
"entry"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_fs()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
{
+
let value = &self.name;
+
#[allow(unused_comparisons)]
+
if <str>::len(value.as_ref()) > 255usize {
+
return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"name",
+
),
+
max: 255usize,
+
actual: <str>::len(value.as_ref()),
+
});
+
}
+
}
+
Ok(())
+
}
+
}
+
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct File<'a> {
+
/// True if blob content is base64-encoded (used to bypass PDS content sniffing)
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub base64: Option<bool>,
+
/// Content blob ref
+
#[serde(borrow)]
+
pub blob: jacquard_common::types::blob::BlobRef<'a>,
+
/// Content encoding (e.g., gzip for compressed files)
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub encoding: Option<jacquard_common::CowStr<'a>>,
+
/// Original MIME type before compression
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub mime_type: Option<jacquard_common::CowStr<'a>>,
+
#[serde(borrow)]
+
pub r#type: jacquard_common::CowStr<'a>,
+
}
+
+
pub mod file_state {
+
+
pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
+
#[allow(unused)]
+
use ::core::marker::PhantomData;
+
mod sealed {
+
pub trait Sealed {}
+
}
+
/// State trait tracking which required fields have been set
+
pub trait State: sealed::Sealed {
+
type Type;
+
type Blob;
+
}
+
/// Empty state - all required fields are unset
+
pub struct Empty(());
+
impl sealed::Sealed for Empty {}
+
impl State for Empty {
+
type Type = Unset;
+
type Blob = Unset;
+
}
+
///State transition - sets the `type` field to Set
+
pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetType<S> {}
+
impl<S: State> State for SetType<S> {
+
type Type = Set<members::r#type>;
+
type Blob = S::Blob;
+
}
+
///State transition - sets the `blob` field to Set
+
pub struct SetBlob<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetBlob<S> {}
+
impl<S: State> State for SetBlob<S> {
+
type Type = S::Type;
+
type Blob = Set<members::blob>;
+
}
+
/// Marker types for field names
+
#[allow(non_camel_case_types)]
+
pub mod members {
+
///Marker type for the `type` field
+
pub struct r#type(());
+
///Marker type for the `blob` field
+
pub struct blob(());
+
}
+
}
+
+
/// Builder for constructing an instance of this type
+
pub struct FileBuilder<'a, S: file_state::State> {
+
_phantom_state: ::core::marker::PhantomData<fn() -> S>,
+
__unsafe_private_named: (
+
::core::option::Option<bool>,
+
::core::option::Option<jacquard_common::types::blob::BlobRef<'a>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
),
+
_phantom: ::core::marker::PhantomData<&'a ()>,
+
}
+
+
impl<'a> File<'a> {
+
/// Create a new builder for this type
+
pub fn new() -> FileBuilder<'a, file_state::Empty> {
+
FileBuilder::new()
+
}
+
}
+
+
impl<'a> FileBuilder<'a, file_state::Empty> {
+
/// Create a new builder with all fields unset
+
pub fn new() -> Self {
+
FileBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: (None, None, None, None, None),
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S: file_state::State> FileBuilder<'a, S> {
+
/// Set the `base64` field (optional)
+
pub fn base64(mut self, value: impl Into<Option<bool>>) -> Self {
+
self.__unsafe_private_named.0 = value.into();
+
self
+
}
+
/// Set the `base64` field to an Option value (optional)
+
pub fn maybe_base64(mut self, value: Option<bool>) -> Self {
+
self.__unsafe_private_named.0 = value;
+
self
+
}
+
}
+
+
impl<'a, S> FileBuilder<'a, S>
+
where
+
S: file_state::State,
+
S::Blob: file_state::IsUnset,
+
{
+
/// Set the `blob` field (required)
+
pub fn blob(
+
mut self,
+
value: impl Into<jacquard_common::types::blob::BlobRef<'a>>,
+
) -> FileBuilder<'a, file_state::SetBlob<S>> {
+
self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into());
+
FileBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S: file_state::State> FileBuilder<'a, S> {
+
/// Set the `encoding` field (optional)
+
pub fn encoding(
+
mut self,
+
value: impl Into<Option<jacquard_common::CowStr<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.2 = value.into();
+
self
+
}
+
/// Set the `encoding` field to an Option value (optional)
+
pub fn maybe_encoding(mut self, value: Option<jacquard_common::CowStr<'a>>) -> Self {
+
self.__unsafe_private_named.2 = value;
+
self
+
}
+
}
+
+
impl<'a, S: file_state::State> FileBuilder<'a, S> {
+
/// Set the `mimeType` field (optional)
+
pub fn mime_type(
+
mut self,
+
value: impl Into<Option<jacquard_common::CowStr<'a>>>,
+
) -> Self {
+
self.__unsafe_private_named.3 = value.into();
+
self
+
}
+
/// Set the `mimeType` field to an Option value (optional)
+
pub fn maybe_mime_type(
+
mut self,
+
value: Option<jacquard_common::CowStr<'a>>,
+
) -> Self {
+
self.__unsafe_private_named.3 = value;
+
self
+
}
+
}
+
+
impl<'a, S> FileBuilder<'a, S>
+
where
+
S: file_state::State,
+
S::Type: file_state::IsUnset,
+
{
+
/// Set the `type` field (required)
+
pub fn r#type(
+
mut self,
+
value: impl Into<jacquard_common::CowStr<'a>>,
+
) -> FileBuilder<'a, file_state::SetType<S>> {
+
self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into());
+
FileBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> FileBuilder<'a, S>
+
where
+
S: file_state::State,
+
S::Type: file_state::IsSet,
+
S::Blob: file_state::IsSet,
+
{
+
/// Build the final struct
+
pub fn build(self) -> File<'a> {
+
File {
+
base64: self.__unsafe_private_named.0,
+
blob: self.__unsafe_private_named.1.unwrap(),
+
encoding: self.__unsafe_private_named.2,
+
mime_type: self.__unsafe_private_named.3,
+
r#type: self.__unsafe_private_named.4.unwrap(),
+
extra_data: Default::default(),
+
}
+
}
+
/// Build the final struct with custom extra_data
+
pub fn build_with_data(
+
self,
+
extra_data: std::collections::BTreeMap<
+
jacquard_common::smol_str::SmolStr,
+
jacquard_common::types::value::Data<'a>,
+
>,
+
) -> File<'a> {
+
File {
+
base64: self.__unsafe_private_named.0,
+
blob: self.__unsafe_private_named.1.unwrap(),
+
encoding: self.__unsafe_private_named.2,
+
mime_type: self.__unsafe_private_named.3,
+
r#type: self.__unsafe_private_named.4.unwrap(),
+
extra_data: Some(extra_data),
+
}
+
}
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for File<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.fs"
+
}
+
fn def_name() -> &'static str {
+
"file"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_fs()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
Ok(())
+
}
+
}
+
+
/// Virtual filesystem manifest for a Wisp site
+
#[jacquard_derive::lexicon]
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Fs<'a> {
+
pub created_at: jacquard_common::types::string::Datetime,
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
pub file_count: Option<i64>,
+
#[serde(borrow)]
+
pub root: crate::place_wisp::fs::Directory<'a>,
+
#[serde(borrow)]
+
pub site: jacquard_common::CowStr<'a>,
+
}
+
+
pub mod fs_state {
+
+
pub use crate::builder_types::{Set, Unset, IsSet, IsUnset};
+
#[allow(unused)]
+
use ::core::marker::PhantomData;
+
mod sealed {
+
pub trait Sealed {}
+
}
+
/// State trait tracking which required fields have been set
+
pub trait State: sealed::Sealed {
+
type Site;
+
type Root;
+
type CreatedAt;
+
}
+
/// Empty state - all required fields are unset
+
pub struct Empty(());
+
impl sealed::Sealed for Empty {}
+
impl State for Empty {
+
type Site = Unset;
+
type Root = Unset;
+
type CreatedAt = Unset;
+
}
+
///State transition - sets the `site` field to Set
+
pub struct SetSite<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetSite<S> {}
+
impl<S: State> State for SetSite<S> {
+
type Site = Set<members::site>;
+
type Root = S::Root;
+
type CreatedAt = S::CreatedAt;
+
}
+
///State transition - sets the `root` field to Set
+
pub struct SetRoot<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetRoot<S> {}
+
impl<S: State> State for SetRoot<S> {
+
type Site = S::Site;
+
type Root = Set<members::root>;
+
type CreatedAt = S::CreatedAt;
+
}
+
///State transition - sets the `created_at` field to Set
+
pub struct SetCreatedAt<S: State = Empty>(PhantomData<fn() -> S>);
+
impl<S: State> sealed::Sealed for SetCreatedAt<S> {}
+
impl<S: State> State for SetCreatedAt<S> {
+
type Site = S::Site;
+
type Root = S::Root;
+
type CreatedAt = Set<members::created_at>;
+
}
+
/// Marker types for field names
+
#[allow(non_camel_case_types)]
+
pub mod members {
+
///Marker type for the `site` field
+
pub struct site(());
+
///Marker type for the `root` field
+
pub struct root(());
+
///Marker type for the `created_at` field
+
pub struct created_at(());
+
}
+
}
+
+
/// Builder for constructing an instance of this type
+
pub struct FsBuilder<'a, S: fs_state::State> {
+
_phantom_state: ::core::marker::PhantomData<fn() -> S>,
+
__unsafe_private_named: (
+
::core::option::Option<jacquard_common::types::string::Datetime>,
+
::core::option::Option<i64>,
+
::core::option::Option<crate::place_wisp::fs::Directory<'a>>,
+
::core::option::Option<jacquard_common::CowStr<'a>>,
+
),
+
_phantom: ::core::marker::PhantomData<&'a ()>,
+
}
+
+
impl<'a> Fs<'a> {
+
/// Create a new builder for this type
+
pub fn new() -> FsBuilder<'a, fs_state::Empty> {
+
FsBuilder::new()
+
}
+
}
+
+
impl<'a> FsBuilder<'a, fs_state::Empty> {
+
/// Create a new builder with all fields unset
+
pub fn new() -> Self {
+
FsBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: (None, None, None, None),
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> FsBuilder<'a, S>
+
where
+
S: fs_state::State,
+
S::CreatedAt: fs_state::IsUnset,
+
{
+
/// Set the `createdAt` field (required)
+
pub fn created_at(
+
mut self,
+
value: impl Into<jacquard_common::types::string::Datetime>,
+
) -> FsBuilder<'a, fs_state::SetCreatedAt<S>> {
+
self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into());
+
FsBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S: fs_state::State> FsBuilder<'a, S> {
+
/// Set the `fileCount` field (optional)
+
pub fn file_count(mut self, value: impl Into<Option<i64>>) -> Self {
+
self.__unsafe_private_named.1 = value.into();
+
self
+
}
+
/// Set the `fileCount` field to an Option value (optional)
+
pub fn maybe_file_count(mut self, value: Option<i64>) -> Self {
+
self.__unsafe_private_named.1 = value;
+
self
+
}
+
}
+
+
impl<'a, S> FsBuilder<'a, S>
+
where
+
S: fs_state::State,
+
S::Root: fs_state::IsUnset,
+
{
+
/// Set the `root` field (required)
+
pub fn root(
+
mut self,
+
value: impl Into<crate::place_wisp::fs::Directory<'a>>,
+
) -> FsBuilder<'a, fs_state::SetRoot<S>> {
+
self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into());
+
FsBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> FsBuilder<'a, S>
+
where
+
S: fs_state::State,
+
S::Site: fs_state::IsUnset,
+
{
+
/// Set the `site` field (required)
+
pub fn site(
+
mut self,
+
value: impl Into<jacquard_common::CowStr<'a>>,
+
) -> FsBuilder<'a, fs_state::SetSite<S>> {
+
self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into());
+
FsBuilder {
+
_phantom_state: ::core::marker::PhantomData,
+
__unsafe_private_named: self.__unsafe_private_named,
+
_phantom: ::core::marker::PhantomData,
+
}
+
}
+
}
+
+
impl<'a, S> FsBuilder<'a, S>
+
where
+
S: fs_state::State,
+
S::Site: fs_state::IsSet,
+
S::Root: fs_state::IsSet,
+
S::CreatedAt: fs_state::IsSet,
+
{
+
/// Build the final struct
+
pub fn build(self) -> Fs<'a> {
+
Fs {
+
created_at: self.__unsafe_private_named.0.unwrap(),
+
file_count: self.__unsafe_private_named.1,
+
root: self.__unsafe_private_named.2.unwrap(),
+
site: self.__unsafe_private_named.3.unwrap(),
+
extra_data: Default::default(),
+
}
+
}
+
/// Build the final struct with custom extra_data
+
pub fn build_with_data(
+
self,
+
extra_data: std::collections::BTreeMap<
+
jacquard_common::smol_str::SmolStr,
+
jacquard_common::types::value::Data<'a>,
+
>,
+
) -> Fs<'a> {
+
Fs {
+
created_at: self.__unsafe_private_named.0.unwrap(),
+
file_count: self.__unsafe_private_named.1,
+
root: self.__unsafe_private_named.2.unwrap(),
+
site: self.__unsafe_private_named.3.unwrap(),
+
extra_data: Some(extra_data),
+
}
+
}
+
}
+
+
impl<'a> Fs<'a> {
+
pub fn uri(
+
uri: impl Into<jacquard_common::CowStr<'a>>,
+
) -> Result<
+
jacquard_common::types::uri::RecordUri<'a, FsRecord>,
+
jacquard_common::types::uri::UriError,
+
> {
+
jacquard_common::types::uri::RecordUri::try_from_uri(
+
jacquard_common::types::string::AtUri::new_cow(uri.into())?,
+
)
+
}
+
}
+
+
/// Typed wrapper for GetRecord response with this collection's record type.
+
#[derive(
+
serde::Serialize,
+
serde::Deserialize,
+
Debug,
+
Clone,
+
PartialEq,
+
Eq,
+
jacquard_derive::IntoStatic
+
)]
+
#[serde(rename_all = "camelCase")]
+
pub struct FsGetRecordOutput<'a> {
+
#[serde(skip_serializing_if = "std::option::Option::is_none")]
+
#[serde(borrow)]
+
pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
+
#[serde(borrow)]
+
pub uri: jacquard_common::types::string::AtUri<'a>,
+
#[serde(borrow)]
+
pub value: Fs<'a>,
+
}
+
+
impl From<FsGetRecordOutput<'_>> for Fs<'_> {
+
fn from(output: FsGetRecordOutput<'_>) -> Self {
+
use jacquard_common::IntoStatic;
+
output.value.into_static()
+
}
+
}
+
+
impl jacquard_common::types::collection::Collection for Fs<'_> {
+
const NSID: &'static str = "place.wisp.fs";
+
type Record = FsRecord;
+
}
+
+
/// Marker type for deserializing records from this collection.
+
#[derive(Debug, serde::Serialize, serde::Deserialize)]
+
pub struct FsRecord;
+
impl jacquard_common::xrpc::XrpcResp for FsRecord {
+
const NSID: &'static str = "place.wisp.fs";
+
const ENCODING: &'static str = "application/json";
+
type Output<'de> = FsGetRecordOutput<'de>;
+
type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
+
}
+
+
impl jacquard_common::types::collection::Collection for FsRecord {
+
const NSID: &'static str = "place.wisp.fs";
+
type Record = FsRecord;
+
}
+
+
impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Fs<'a> {
+
fn nsid() -> &'static str {
+
"place.wisp.fs"
+
}
+
fn def_name() -> &'static str {
+
"main"
+
}
+
fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> {
+
lexicon_doc_place_wisp_fs()
+
}
+
fn validate(
+
&self,
+
) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> {
+
if let Some(ref value) = self.file_count {
+
if *value > 1000i64 {
+
return Err(::jacquard_lexicon::validation::ConstraintError::Maximum {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"file_count",
+
),
+
max: 1000i64,
+
actual: *value,
+
});
+
}
+
}
+
if let Some(ref value) = self.file_count {
+
if *value < 0i64 {
+
return Err(::jacquard_lexicon::validation::ConstraintError::Minimum {
+
path: ::jacquard_lexicon::validation::ValidationPath::from_field(
+
"file_count",
+
),
+
min: 0i64,
+
actual: *value,
+
});
+
}
+
}
+
Ok(())
+
}
+
}
+6
cli/src/place_wisp.rs
···
+
// @generated by jacquard-lexicon. DO NOT EDIT.
+
//
+
// This file was automatically generated from Lexicon schemas.
+
// Any manual changes will be overwritten on the next regeneration.
+
+
pub mod fs;
+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;
+
};
+
};
+
}
+336 -21
hosting-service/bun.lock
···
"": {
"name": "wisp-hosting-service",
"dependencies": {
-
"@atproto/api": "^0.13.20",
-
"@atproto/xrpc": "^0.6.4",
-
"hono": "^4.6.14",
+
"@atproto/api": "^0.17.4",
+
"@atproto/identity": "^0.4.9",
+
"@atproto/lexicon": "^0.5.1",
+
"@atproto/sync": "^0.1.36",
+
"@atproto/xrpc": "^0.7.5",
+
"@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": "latest",
+
"@types/bun": "^1.3.1",
+
"@types/mime-types": "^2.1.4",
+
"@types/node": "^22.10.5",
+
"tsx": "^4.19.2",
},
},
},
"packages": {
-
"@atproto/api": ["@atproto/api@0.13.35", "", { "dependencies": { "@atproto/common-web": "^0.4.0", "@atproto/lexicon": "^0.4.6", "@atproto/syntax": "^0.3.2", "@atproto/xrpc": "^0.6.8", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g=="],
+
"@atproto/api": ["@atproto/api@0.17.4", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, ""],
+
+
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, ""],
+
+
"@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, ""],
+
+
"@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, ""],
+
+
"@atproto/identity": ["@atproto/identity@0.4.9", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4" } }, ""],
+
+
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, ""],
+
+
"@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.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": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, ""],
+
+
"@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" } }, ""],
+
+
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
+
+
"@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/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="],
+
+
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="],
+
+
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="],
+
+
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="],
+
+
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="],
+
+
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="],
+
+
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="],
+
+
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="],
+
+
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="],
+
+
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="],
+
+
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="],
+
+
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="],
+
+
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="],
+
+
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="],
+
+
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="],
+
+
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="],
+
+
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="],
+
+
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="],
+
+
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="],
+
+
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="],
+
+
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="],
+
+
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="],
+
+
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="],
-
"@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="],
+
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
-
"@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="],
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
-
"@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="],
+
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""],
-
"@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="],
+
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""],
+
+
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
-
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
+
"@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/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
-
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
+
"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" } }, ""],
+
+
"array-flatten": ["array-flatten@1.1.1", "", {}, ""],
+
+
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""],
+
+
"await-lock": ["await-lock@2.2.2", "", {}, ""],
+
+
"base64-js": ["base64-js@1.5.1", "", {}, ""],
+
+
"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", "", {}, ""],
+
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""],
+
+
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, ""],
+
+
"cbor-extract": ["cbor-extract@2.2.0", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, ""],
+
+
"cbor-x": ["cbor-x@1.6.0", "", { "optionalDependencies": { "cbor-extract": "^2.2.0" } }, ""],
+
+
"cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""],
+
+
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""],
+
+
"content-type": ["content-type@1.0.5", "", {}, ""],
+
+
"cookie": ["cookie@0.7.1", "", {}, ""],
+
+
"cookie-signature": ["cookie-signature@1.0.6", "", {}, ""],
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
-
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""],
+
+
"depd": ["depd@2.0.0", "", {}, ""],
+
+
"destroy": ["destroy@1.2.0", "", {}, ""],
+
+
"detect-libc": ["detect-libc@2.1.2", "", {}, ""],
+
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, ""],
+
+
"ee-first": ["ee-first@1.1.1", "", {}, ""],
+
+
"encodeurl": ["encodeurl@2.0.0", "", {}, ""],
+
+
"es-define-property": ["es-define-property@1.0.1", "", {}, ""],
+
+
"es-errors": ["es-errors@1.3.0", "", {}, ""],
+
+
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, ""],
+
+
"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=="],
+
+
"escape-html": ["escape-html@1.0.3", "", {}, ""],
+
+
"etag": ["etag@1.8.1", "", {}, ""],
+
+
"event-target-shim": ["event-target-shim@5.0.1", "", {}, ""],
+
+
"eventemitter3": ["eventemitter3@4.0.7", "", {}, ""],
+
+
"events": ["events@3.3.0", "", {}, ""],
+
+
"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-redact": ["fast-redact@3.5.0", "", {}, ""],
+
+
"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" } }, ""],
+
+
"forwarded": ["forwarded@0.2.0", "", {}, ""],
+
+
"fresh": ["fresh@0.5.2", "", {}, ""],
+
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+
"function-bind": ["function-bind@1.1.2", "", {}, ""],
+
+
"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" } }, ""],
+
+
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
+
+
"gopd": ["gopd@1.2.0", "", {}, ""],
+
+
"graphemer": ["graphemer@1.4.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" } }, ""],
+
+
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, ""],
+
+
"ieee754": ["ieee754@1.2.1", "", {}, ""],
+
+
"inherits": ["inherits@2.0.4", "", {}, ""],
+
+
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""],
+
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""],
+
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""],
+
+
"media-typer": ["media-typer@0.3.0", "", {}, ""],
+
+
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""],
+
+
"methods": ["methods@1.1.2", "", {}, ""],
+
+
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, ""],
-
"hono": ["hono@4.10.2", "", {}, "sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg=="],
+
"mime-db": ["mime-db@1.52.0", "", {}, ""],
-
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
+
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""],
-
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
"ms": ["ms@2.0.0", "", {}, ""],
-
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
+
"multiformats": ["multiformats@13.4.1", "", {}, ""],
-
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
+
"negotiator": ["negotiator@0.6.3", "", {}, ""],
-
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
+
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, ""],
+
+
"object-inspect": ["object-inspect@1.13.4", "", {}, ""],
+
+
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""],
-
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""],
-
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
"p-finally": ["p-finally@1.0.0", "", {}, ""],
-
"@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
+
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""],
+
+
"p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, ""],
+
+
"parseurl": ["parseurl@1.3.3", "", {}, ""],
+
+
"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" }, ""],
+
+
"pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, ""],
+
+
"pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, ""],
+
+
"postgres": ["postgres@3.4.7", "", {}, ""],
+
+
"process": ["process@0.11.10", "", {}, ""],
+
+
"process-warning": ["process-warning@3.0.0", "", {}, ""],
+
+
"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" } }, ""],
+
+
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, ""],
+
+
"range-parser": ["range-parser@1.2.1", "", {}, ""],
+
+
"rate-limiter-flexible": ["rate-limiter-flexible@2.4.2", "", {}, ""],
+
+
"raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, ""],
+
+
"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" } }, ""],
+
+
"real-require": ["real-require@0.2.0", "", {}, ""],
+
+
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
+
"safe-buffer": ["safe-buffer@5.2.1", "", {}, ""],
+
+
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, ""],
+
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, ""],
+
+
"send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, ""],
+
+
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, ""],
+
+
"setprototypeof": ["setprototypeof@1.2.0", "", {}, ""],
+
+
"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" } }, ""],
+
+
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, ""],
+
+
"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" } }, ""],
+
+
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, ""],
+
+
"split2": ["split2@4.2.0", "", {}, ""],
+
+
"statuses": ["statuses@2.0.1", "", {}, ""],
+
+
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""],
+
+
"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", "", {}, ""],
+
+
"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" } }, ""],
+
+
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""],
+
+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+
"unpipe": ["unpipe@1.0.0", "", {}, ""],
+
+
"utils-merge": ["utils-merge@1.0.1", "", {}, ""],
+
+
"varint": ["varint@6.0.0", "", {}, ""],
+
+
"vary": ["vary@1.1.2", "", {}, ""],
+
+
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""],
+
+
"zod": ["zod@3.25.76", "", {}, ""],
+
+
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@atproto/repo/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@atproto/sync/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""],
+
+
"send/encodeurl": ["encodeurl@1.0.2", "", {}, ""],
+
+
"send/ms": ["ms@2.1.3", "", {}, ""],
+
+
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
}
}
+17 -6
hosting-service/package.json
···
"version": "1.0.0",
"type": "module",
"scripts": {
-
"dev": "bun --watch src/index.ts",
-
"start": "bun 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": {
-
"hono": "^4.6.14",
-
"@atproto/api": "^0.13.20",
-
"@atproto/xrpc": "^0.6.4",
+
"@atproto/api": "^0.17.4",
+
"@atproto/identity": "^0.4.9",
+
"@atproto/lexicon": "^0.5.1",
+
"@atproto/sync": "^0.1.36",
+
"@atproto/xrpc": "^0.7.5",
+
"@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": "latest"
+
"@types/bun": "^1.3.1",
+
"@types/mime-types": "^2.1.4",
+
"@types/node": "^22.10.5",
+
"tsx": "^4.19.2"
}
}
+31 -43
hosting-service/src/index.ts
···
-
import { serve } from 'bun';
import app from './server';
+
import { serve } from '@hono/node-server';
import { FirehoseWorker } from './lib/firehose';
-
import { DNSVerificationWorker } from './lib/dns-verification-worker';
+
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
+
import { backfillCache } from './lib/backfill';
-
const PORT = process.env.PORT || 3001;
-
const CACHE_DIR = './cache/sites';
+
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
+
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)) {
···
console.log('Created cache directory:', CACHE_DIR);
}
-
// Start firehose worker
+
// Start firehose worker with observability logger
const firehose = new FirehoseWorker((msg, data) => {
-
console.log(msg, data);
+
logger.info(msg, data);
});
firehose.start();
-
// Start DNS verification worker (runs every hour)
-
const dnsVerifier = new DNSVerificationWorker(
-
60 * 60 * 1000, // 1 hour
-
(msg, data) => {
-
console.log('[DNS Verifier]', msg, data || '');
-
}
-
);
-
-
dnsVerifier.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', (c) => {
const firehoseHealth = firehose.getHealth();
-
const dnsVerifierHealth = dnsVerifier.getHealth();
return c.json({
status: 'ok',
firehose: firehoseHealth,
-
dnsVerifier: dnsVerifierHealth,
});
});
-
// Add manual DNS verification trigger (for testing/admin)
-
app.post('/admin/verify-dns', async (c) => {
-
try {
-
await dnsVerifier.trigger();
-
return c.json({
-
success: true,
-
message: 'DNS verification triggered',
-
});
-
} catch (error) {
-
return c.json({
-
success: false,
-
error: error instanceof Error ? error.message : String(error),
-
}, 500);
-
}
-
});
-
-
// Start HTTP server
+
// Start HTTP server with Node.js adapter
const server = serve({
-
port: PORT,
fetch: app.fetch,
+
port: PORT,
});
console.log(`
···
Server: http://localhost:${PORT}
Health: http://localhost:${PORT}/health
Cache: ${CACHE_DIR}
-
Firehose: Connected to Jetstream
-
DNS Verifier: Checking every hour
+
Firehose: Connected to Firehose
`);
// Graceful shutdown
-
process.on('SIGINT', () => {
+
process.on('SIGINT', async () => {
console.log('\n๐Ÿ›‘ Shutting down...');
firehose.stop();
-
dnsVerifier.stop();
-
server.stop();
+
server.close();
process.exit(0);
});
-
process.on('SIGTERM', () => {
+
process.on('SIGTERM', async () => {
console.log('\n๐Ÿ›‘ Shutting down...');
firehose.stop();
-
dnsVerifier.stop();
-
server.stop();
+
server.close();
process.exit(0);
});
+44
hosting-service/src/lexicon/index.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type Auth,
+
type Options as XrpcOptions,
+
Server as XrpcServer,
+
type StreamConfigOrHandler,
+
type MethodConfigOrHandler,
+
createServer as createXrpcServer,
+
} from '@atproto/xrpc-server'
+
import { schemas } from './lexicons.js'
+
+
export function createServer(options?: XrpcOptions): Server {
+
return new Server(options)
+
}
+
+
export class Server {
+
xrpc: XrpcServer
+
place: PlaceNS
+
+
constructor(options?: XrpcOptions) {
+
this.xrpc = createXrpcServer(schemas, options)
+
this.place = new PlaceNS(this)
+
}
+
}
+
+
export class PlaceNS {
+
_server: Server
+
wisp: PlaceWispNS
+
+
constructor(server: Server) {
+
this._server = server
+
this.wisp = new PlaceWispNS(server)
+
}
+
}
+
+
export class PlaceWispNS {
+
_server: Server
+
+
constructor(server: Server) {
+
this._server = server
+
}
+
}
+141
hosting-service/src/lexicon/lexicons.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type LexiconDoc,
+
Lexicons,
+
ValidationError,
+
type ValidationResult,
+
} from '@atproto/lexicon'
+
import { type $Typed, is$typed, maybe$typed } from './util.js'
+
+
export const schemaDict = {
+
PlaceWispFs: {
+
lexicon: 1,
+
id: 'place.wisp.fs',
+
defs: {
+
main: {
+
type: 'record',
+
description: 'Virtual filesystem manifest for a Wisp site',
+
record: {
+
type: 'object',
+
required: ['site', 'root', 'createdAt'],
+
properties: {
+
site: {
+
type: 'string',
+
},
+
root: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#directory',
+
},
+
fileCount: {
+
type: 'integer',
+
minimum: 0,
+
maximum: 1000,
+
},
+
createdAt: {
+
type: 'string',
+
format: 'datetime',
+
},
+
},
+
},
+
},
+
file: {
+
type: 'object',
+
required: ['type', 'blob'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'file',
+
},
+
blob: {
+
type: 'blob',
+
accept: ['*/*'],
+
maxSize: 1000000,
+
description: 'Content blob ref',
+
},
+
encoding: {
+
type: 'string',
+
enum: ['gzip'],
+
description: 'Content encoding (e.g., gzip for compressed files)',
+
},
+
mimeType: {
+
type: 'string',
+
description: 'Original MIME type before compression',
+
},
+
base64: {
+
type: 'boolean',
+
description:
+
'True if blob content is base64-encoded (used to bypass PDS content sniffing)',
+
},
+
},
+
},
+
directory: {
+
type: 'object',
+
required: ['type', 'entries'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'directory',
+
},
+
entries: {
+
type: 'array',
+
maxLength: 500,
+
items: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#entry',
+
},
+
},
+
},
+
},
+
entry: {
+
type: 'object',
+
required: ['name', 'node'],
+
properties: {
+
name: {
+
type: 'string',
+
maxLength: 255,
+
},
+
node: {
+
type: 'union',
+
refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'],
+
},
+
},
+
},
+
},
+
},
+
} as const satisfies Record<string, LexiconDoc>
+
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
+
export const lexicons: Lexicons = new Lexicons(schemas)
+
+
export function validate<T extends { $type: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType: true,
+
): ValidationResult<T>
+
export function validate<T extends { $type?: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: false,
+
): ValidationResult<T>
+
export function validate(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: boolean,
+
): ValidationResult {
+
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
+
? lexicons.validate(`${id}#${hash}`, v)
+
: {
+
success: false,
+
error: new ValidationError(
+
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
+
),
+
}
+
}
+
+
export const ids = {
+
PlaceWispFs: 'place.wisp.fs',
+
} as const
+85
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'
+
import { validate as _validate } from '../../../lexicons'
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+
+
const is$typed = _is$typed,
+
validate = _validate
+
const id = 'place.wisp.fs'
+
+
export interface Record {
+
$type: 'place.wisp.fs'
+
site: string
+
root: Directory
+
fileCount?: number
+
createdAt: string
+
[k: string]: unknown
+
}
+
+
const hashRecord = 'main'
+
+
export function isRecord<V>(v: V) {
+
return is$typed(v, id, hashRecord)
+
}
+
+
export function validateRecord<V>(v: V) {
+
return validate<Record & V>(v, id, hashRecord, true)
+
}
+
+
export interface File {
+
$type?: 'place.wisp.fs#file'
+
type: 'file'
+
/** Content blob ref */
+
blob: BlobRef
+
/** Content encoding (e.g., gzip for compressed files) */
+
encoding?: 'gzip'
+
/** Original MIME type before compression */
+
mimeType?: string
+
/** True if blob content is base64-encoded (used to bypass PDS content sniffing) */
+
base64?: boolean
+
}
+
+
const hashFile = 'file'
+
+
export function isFile<V>(v: V) {
+
return is$typed(v, id, hashFile)
+
}
+
+
export function validateFile<V>(v: V) {
+
return validate<File & V>(v, id, hashFile)
+
}
+
+
export interface Directory {
+
$type?: 'place.wisp.fs#directory'
+
type: 'directory'
+
entries: Entry[]
+
}
+
+
const hashDirectory = 'directory'
+
+
export function isDirectory<V>(v: V) {
+
return is$typed(v, id, hashDirectory)
+
}
+
+
export function validateDirectory<V>(v: V) {
+
return validate<Directory & V>(v, id, hashDirectory)
+
}
+
+
export interface Entry {
+
$type?: 'place.wisp.fs#entry'
+
name: string
+
node: $Typed<File> | $Typed<Directory> | { $type: string }
+
}
+
+
const hashEntry = 'entry'
+
+
export function isEntry<V>(v: V) {
+
return is$typed(v, id, hashEntry)
+
}
+
+
export function validateEntry<V>(v: V) {
+
return validate<Entry & V>(v, id, hashEntry)
+
}
+82
hosting-service/src/lexicon/util.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
+
import { type ValidationResult } from '@atproto/lexicon'
+
+
export type OmitKey<T, K extends keyof T> = {
+
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
+
}
+
+
export type $Typed<V, T extends string = string> = V & { $type: T }
+
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
+
+
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
+
? Id
+
: `${Id}#${Hash}`
+
+
function isObject<V>(v: V): v is V & object {
+
return v != null && typeof v === 'object'
+
}
+
+
function is$type<Id extends string, Hash extends string>(
+
$type: unknown,
+
id: Id,
+
hash: Hash,
+
): $type is $Type<Id, Hash> {
+
return hash === 'main'
+
? $type === id
+
: // $type === `${id}#${hash}`
+
typeof $type === 'string' &&
+
$type.length === id.length + 1 + hash.length &&
+
$type.charCodeAt(id.length) === 35 /* '#' */ &&
+
$type.startsWith(id) &&
+
$type.endsWith(hash)
+
}
+
+
export type $TypedObject<
+
V,
+
Id extends string,
+
Hash extends string,
+
> = V extends {
+
$type: $Type<Id, Hash>
+
}
+
? V
+
: V extends { $type?: string }
+
? V extends { $type?: infer T extends $Type<Id, Hash> }
+
? V & { $type: T }
+
: never
+
: V & { $type: $Type<Id, Hash> }
+
+
export function is$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is $TypedObject<V, Id, Hash> {
+
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
+
}
+
+
export function maybe$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is V & object & { $type?: $Type<Id, Hash> } {
+
return (
+
isObject(v) &&
+
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
+
)
+
}
+
+
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
+
export type ValidatorParam<V extends Validator> =
+
V extends Validator<infer R> ? R : never
+
+
/**
+
* Utility function that allows to convert a "validate*" utility function into a
+
* type predicate.
+
*/
+
export function asPredicate<V extends Validator>(validate: V) {
+
return function <T>(v: T): v is T & ValidatorParam<V> {
+
return validate(v).success
+
}
+
}
+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;
+
}
+83 -6
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',
···
id: string;
domain: string;
did: string;
-
rkey: string;
+
rkey: string | null;
verified: boolean;
}
+
+
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
+
const key = domain.toLowerCase();
+
+
// Query database
const result = await sql<DomainLookup[]>`
-
SELECT did, rkey FROM domains WHERE domain = ${domain.toLowerCase()} LIMIT 1
+
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
`;
-
return result[0] || null;
+
const data = result[0] || null;
+
+
return data;
}
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
+
const key = domain.toLowerCase();
+
+
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
-
WHERE domain = ${domain.toLowerCase()} AND verified = true LIMIT 1
+
WHERE domain = ${key} AND verified = true LIMIT 1
`;
-
return result[0] || null;
+
const data = result[0] || null;
+
+
return data;
}
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
+
// Query database
const result = await sql<CustomDomainLookup[]>`
SELECT id, domain, did, rkey, verified FROM custom_domains
WHERE id = ${hash} AND verified = true LIMIT 1
`;
-
return result[0] || null;
+
const data = result[0] || null;
+
+
return data;
}
export async function upsertSite(did: string, rkey: string, displayName?: string) {
···
`;
} catch (err) {
console.error('Failed to upsert site', err);
+
}
+
}
+
+
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 {
+
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;
+
}
+
+
/**
+
* Acquire a distributed lock using PostgreSQL advisory locks
+
* Returns true if lock was acquired, false if already held by another instance
+
* Lock is automatically released when the transaction ends or connection closes
+
*/
+
export async function tryAcquireLock(key: string): Promise<boolean> {
+
const lockId = stringToLockId(key);
+
+
try {
+
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 });
+
return false;
+
}
+
}
+
+
/**
+
* Release a distributed lock
+
*/
+
export async function releaseLock(key: string): Promise<void> {
+
const lockId = stringToLockId(key);
+
+
try {
+
await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
+
} catch (err) {
+
console.error('Failed to release lock', { key, error: err });
}
}
-170
hosting-service/src/lib/dns-verification-worker.ts
···
-
import { verifyCustomDomain } from '../../../src/lib/dns-verify';
-
import { db } from '../../../src/lib/db';
-
-
interface VerificationStats {
-
totalChecked: number;
-
verified: number;
-
failed: number;
-
errors: number;
-
}
-
-
export class DNSVerificationWorker {
-
private interval: Timer | null = null;
-
private isRunning = false;
-
private lastRunTime: number | null = null;
-
private stats: VerificationStats = {
-
totalChecked: 0,
-
verified: 0,
-
failed: 0,
-
errors: 0,
-
};
-
-
constructor(
-
private checkIntervalMs: number = 60 * 60 * 1000, // 1 hour default
-
private onLog?: (message: string, data?: any) => void
-
) {}
-
-
private log(message: string, data?: any) {
-
if (this.onLog) {
-
this.onLog(message, data);
-
}
-
}
-
-
async start() {
-
if (this.isRunning) {
-
this.log('DNS verification worker already running');
-
return;
-
}
-
-
this.isRunning = true;
-
this.log('Starting DNS verification worker', {
-
intervalMinutes: this.checkIntervalMs / 60000,
-
});
-
-
// Run immediately on start
-
await this.verifyAllDomains();
-
-
// Then run on interval
-
this.interval = setInterval(() => {
-
this.verifyAllDomains();
-
}, this.checkIntervalMs);
-
}
-
-
stop() {
-
if (this.interval) {
-
clearInterval(this.interval);
-
this.interval = null;
-
}
-
this.isRunning = false;
-
this.log('DNS verification worker stopped');
-
}
-
-
private async verifyAllDomains() {
-
this.log('Starting DNS verification check');
-
const startTime = Date.now();
-
-
const runStats: VerificationStats = {
-
totalChecked: 0,
-
verified: 0,
-
failed: 0,
-
errors: 0,
-
};
-
-
try {
-
// Get all verified custom domains
-
const domains = await db`
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
-
`;
-
-
if (!domains || domains.length === 0) {
-
this.log('No verified custom domains to check');
-
this.lastRunTime = Date.now();
-
return;
-
}
-
-
this.log(`Checking ${domains.length} verified custom domains`);
-
-
// Verify each domain
-
for (const row of domains) {
-
runStats.totalChecked++;
-
const { id, domain, did } = row;
-
-
try {
-
// Extract hash from id (SHA256 of did:domain)
-
const expectedHash = id.substring(0, 16);
-
-
// Verify DNS records
-
const result = await verifyCustomDomain(domain, did, expectedHash);
-
-
if (result.verified) {
-
// Update last_verified_at timestamp
-
await db`
-
UPDATE custom_domains
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
-
WHERE id = ${id}
-
`;
-
runStats.verified++;
-
this.log(`Domain verified: ${domain}`, { did });
-
} else {
-
// Mark domain as unverified
-
await db`
-
UPDATE custom_domains
-
SET verified = false,
-
last_verified_at = EXTRACT(EPOCH FROM NOW())
-
WHERE id = ${id}
-
`;
-
runStats.failed++;
-
this.log(`Domain verification failed: ${domain}`, {
-
did,
-
error: result.error,
-
found: result.found,
-
});
-
}
-
} catch (error) {
-
runStats.errors++;
-
this.log(`Error verifying domain: ${domain}`, {
-
did,
-
error: error instanceof Error ? error.message : String(error),
-
});
-
}
-
}
-
-
// Update cumulative stats
-
this.stats.totalChecked += runStats.totalChecked;
-
this.stats.verified += runStats.verified;
-
this.stats.failed += runStats.failed;
-
this.stats.errors += runStats.errors;
-
-
const duration = Date.now() - startTime;
-
this.lastRunTime = Date.now();
-
-
this.log('DNS verification check completed', {
-
duration: `${duration}ms`,
-
...runStats,
-
});
-
} catch (error) {
-
this.log('Fatal error in DNS verification worker', {
-
error: error instanceof Error ? error.message : String(error),
-
});
-
}
-
}
-
-
getHealth() {
-
return {
-
isRunning: this.isRunning,
-
lastRunTime: this.lastRunTime,
-
intervalMs: this.checkIntervalMs,
-
stats: this.stats,
-
healthy: this.isRunning && (
-
this.lastRunTime === null ||
-
Date.now() - this.lastRunTime < this.checkIntervalMs * 2
-
),
-
};
-
}
-
-
// Manual trigger for testing
-
async trigger() {
-
this.log('Manual DNS verification triggered');
-
await this.verifyAllDomains();
-
}
-
}
+259 -296
hosting-service/src/lib/firehose.ts
···
-
import { existsSync, rmSync } from 'fs';
-
import type { WispFsRecord } from './types';
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
-
import { upsertSite } from './db';
-
import { safeFetch } from './safe-fetch';
-
-
const CACHE_DIR = './cache/sites';
-
const JETSTREAM_URL = 'wss://jetstream2.us-west.bsky.network/subscribe';
-
const RECONNECT_DELAY = 5000; // 5 seconds
-
const MAX_RECONNECT_DELAY = 60000; // 1 minute
-
-
interface JetstreamCommitEvent {
-
did: string;
-
time_us: number;
-
type: 'com' | 'identity' | 'account';
-
kind: 'commit';
-
commit: {
-
rev: string;
-
operation: 'create' | 'update' | 'delete';
-
collection: string;
-
rkey: string;
-
record?: any;
-
cid?: string;
-
};
-
}
-
-
interface JetstreamIdentityEvent {
-
did: string;
-
time_us: number;
-
type: 'identity';
-
kind: 'update';
-
identity: {
-
did: string;
-
handle: string;
-
seq: number;
-
time: string;
-
};
-
}
+
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'
-
interface JetstreamAccountEvent {
-
did: string;
-
time_us: number;
-
type: 'account';
-
kind: 'update' | 'delete';
-
account: {
-
active: boolean;
-
did: string;
-
seq: number;
-
time: string;
-
};
-
}
-
-
type JetstreamEvent =
-
| JetstreamCommitEvent
-
| JetstreamIdentityEvent
-
| JetstreamAccountEvent;
+
const CACHE_DIR = './cache/sites'
export class FirehoseWorker {
-
private ws: WebSocket | null = null;
-
private reconnectAttempts = 0;
-
private reconnectTimeout: Timer | null = null;
-
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,
-
) {}
+
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.reconnectTimeout) {
-
clearTimeout(this.reconnectTimeout);
-
this.reconnectTimeout = null;
-
}
+
if (this.firehose) {
+
this.firehose.destroy()
+
this.firehose = null
+
}
+
}
-
if (this.ws) {
-
this.ws.close();
-
this.ws = null;
-
}
-
}
+
private connect() {
+
if (this.isShuttingDown) return
-
private connect() {
-
if (this.isShuttingDown) return;
+
this.log('Connecting to AT Protocol firehose')
-
const url = new URL(JETSTREAM_URL);
-
url.searchParams.set('wantedCollections', 'place.wisp.fs');
+
this.firehose = new Firehose({
+
idResolver: this.idResolver,
+
service: 'wss://bsky.network',
+
filterCollections: ['place.wisp.fs'],
+
handleEvent: async (evt: any) => {
+
this.lastEventTime = Date.now()
-
this.log('Connecting to Jetstream', { url: url.toString() });
+
// Watch for write events
+
if (evt.event === 'create' || evt.event === 'update') {
+
const record = evt.record
-
try {
-
this.ws = new WebSocket(url.toString());
+
// 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
+
})
-
this.ws.onopen = () => {
-
this.log('Connected to Jetstream');
-
this.reconnectAttempts = 0;
-
this.lastEventTime = Date.now();
-
};
+
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
+
})
-
this.ws.onmessage = async (event) => {
-
this.lastEventTime = Date.now();
+
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)
+
}
+
})
-
try {
-
const data = JSON.parse(event.data as string) as JetstreamEvent;
-
await this.handleEvent(data);
-
} catch (err) {
-
this.log('Error processing event', {
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
};
+
this.firehose.start()
+
this.log('Firehose started')
+
}
-
this.ws.onerror = (error) => {
-
this.log('WebSocket error', { error: String(error) });
-
};
+
private async handleCreateOrUpdate(
+
did: string,
+
site: string,
+
record: any,
+
eventCid?: string
+
) {
+
this.log('Processing create/update', { did, site })
-
this.ws.onclose = () => {
-
this.log('WebSocket closed');
-
this.ws = null;
+
// Record is already validated in handleEvent
+
const fsRecord = record
-
if (!this.isShuttingDown) {
-
this.scheduleReconnect();
-
}
-
};
-
} catch (err) {
-
this.log('Failed to create WebSocket', {
-
error: err instanceof Error ? err.message : String(err),
-
});
-
this.scheduleReconnect();
-
}
-
}
+
const pdsEndpoint = await getPdsForDid(did)
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did })
+
return
+
}
-
private scheduleReconnect() {
-
if (this.isShuttingDown) return;
+
this.log('Resolved PDS', { did, pdsEndpoint })
-
this.reconnectAttempts++;
-
const delay = Math.min(
-
RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
-
MAX_RECONNECT_DELAY,
-
);
+
// Verify record exists on PDS and fetch its CID
+
let verifiedCid: string
+
try {
+
const result = await fetchSiteRecord(did, site)
-
this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`, {
-
delay: `${delay}ms`,
-
});
+
if (!result) {
+
this.log('Record not found on PDS, skipping cache', {
+
did,
+
site
+
})
+
return
+
}
-
this.reconnectTimeout = setTimeout(() => {
-
this.connect();
-
}, delay);
-
}
+
verifiedCid = result.cid
-
private async handleEvent(event: JetstreamEvent) {
-
if (event.kind !== 'commit') 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
+
}
-
const commitEvent = event as JetstreamCommitEvent;
-
const { commit, did } = commitEvent;
+
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
+
}
-
if (commit.collection !== 'place.wisp.fs') 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
+
)
-
this.log('Received place.wisp.fs event', {
-
did,
-
operation: commit.operation,
-
rkey: commit.rkey,
-
});
+
// Acquire distributed lock only for database write to prevent duplicate writes
+
const lockKey = `db:upsert:${did}:${site}`
+
const lockAcquired = await tryAcquireLock(lockKey)
-
try {
-
if (commit.operation === 'create' || commit.operation === 'update') {
-
// Pass the CID from the event for verification
-
await this.handleCreateOrUpdate(did, commit.rkey, commit.record, commit.cid);
-
} else if (commit.operation === 'delete') {
-
await this.handleDelete(did, commit.rkey);
-
}
-
} catch (err) {
-
this.log('Error handling event', {
-
did,
-
operation: commit.operation,
-
rkey: commit.rkey,
-
error: err instanceof Error ? err.message : String(err),
-
});
-
}
-
}
+
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
+
}
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
-
this.log('Processing create/update', { did, site });
+
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)
+
}
+
}
-
if (!this.validateRecord(record)) {
-
this.log('Invalid record structure, skipping', { did, site });
-
return;
-
}
+
private async handleDelete(did: string, site: string) {
+
this.log('Processing delete', { did, site })
-
const fsRecord = record as WispFsRecord;
+
// 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
+
}
-
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)
-
this.log('Resolved PDS', { did, pdsEndpoint });
+
if (recordRes.ok) {
+
this.log('Record still exists on PDS, not deleting cache', {
+
did,
+
site
+
})
+
return
+
}
-
// Verify record exists on PDS and fetch its CID
-
let verifiedCid: string;
-
try {
-
const result = await fetchSiteRecord(did, site);
+
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)
+
})
+
}
-
if (!result) {
-
this.log('Record not found on PDS, skipping cache', { did, site });
-
return;
-
}
+
// Delete cache
+
this.deleteCache(did, site)
-
verifiedCid = result.cid;
+
this.log('Successfully processed delete', { did, site })
+
}
-
// 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;
-
}
+
private deleteCache(did: string, site: string) {
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
-
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
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
-
-
// Upsert site to database
-
await upsertSite(did, site, fsRecord.site);
-
-
this.log('Successfully processed create/update', { did, site });
-
}
-
-
private async handleDelete(did: string, site: string) {
-
this.log('Processing delete', { did, site });
-
-
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);
-
-
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),
-
});
-
}
-
-
// Delete cache
-
this.deleteCache(did, site);
-
-
this.log('Successfully processed delete', { did, site });
-
}
-
-
private validateRecord(record: any): boolean {
-
if (!record || typeof record !== 'object') return false;
-
if (record.$type !== 'place.wisp.fs') return false;
-
if (!record.root || typeof record.root !== 'object') return false;
-
if (!record.site || typeof record.site !== 'string') return false;
-
return true;
-
}
+
if (!existsSync(cacheDir)) {
+
this.log('Cache directory does not exist, nothing to delete', {
+
did,
+
site
+
})
+
return
+
}
-
private deleteCache(did: string, site: string) {
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
+
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)
+
})
+
}
+
}
-
if (!existsSync(cacheDir)) {
-
this.log('Cache directory does not exist, nothing to delete', {
-
did,
-
site,
-
});
-
return;
-
}
+
getHealth() {
+
const isConnected = this.firehose !== null
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
-
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.ws !== null && this.ws.readyState === WebSocket.OPEN;
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
-
-
return {
-
connected: isConnected,
-
reconnectAttempts: this.reconnectAttempts,
-
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)
+
})
+
})
+180 -87
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 {
-
// Must start with /
-
if (!path.startsWith('/')) return false;
+
// Don't rewrite empty paths
+
if (!path) return false
-
// Don't rewrite protocol-relative URLs
-
if (path.startsWith('//')) 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
+
}
-
// Don't rewrite anchors
-
if (path.startsWith('/#')) return false;
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
+
return true
+
}
-
// Don't rewrite data URIs or other schemes
-
if (path.includes(':')) return false;
+
/**
+
* Normalize a path by resolving . and .. segments
+
*/
+
function normalizePath(path: string): string {
+
const parts = path.split('/')
+
const result: string[] = []
-
return true;
+
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)
+
}
+
+
return result.join('/')
+
}
+
+
/**
+
* 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 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
+
}
-
// Remove leading slash and prepend base path
-
return basePath + path.slice(1);
+
// Normalize the path to resolve .. and .
+
resolvedPath = normalizePath(resolvedPath)
+
+
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'
}
+326
hosting-service/src/lib/observability.ts
···
+
// DIY Observability for Hosting Service
+
import type { Context } from 'hono'
+
+
// Types
+
export interface LogEntry {
+
id: string
+
timestamp: Date
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
traceId?: string
+
eventType?: string
+
}
+
+
export interface ErrorEntry {
+
id: string
+
timestamp: Date
+
message: string
+
stack?: string
+
service: string
+
context?: Record<string, any>
+
count: number
+
lastSeen: Date
+
}
+
+
export interface MetricEntry {
+
timestamp: Date
+
path: string
+
method: string
+
statusCode: number
+
duration: number
+
service: string
+
}
+
+
// In-memory storage with rotation
+
const MAX_LOGS = 5000
+
const MAX_ERRORS = 500
+
const MAX_METRICS = 10000
+
+
const logs: LogEntry[] = []
+
const errors: Map<string, ErrorEntry> = new Map()
+
const metrics: MetricEntry[] = []
+
+
// Helper to generate unique IDs
+
let logCounter = 0
+
let errorCounter = 0
+
+
function generateId(prefix: string, counter: number): string {
+
return `${prefix}-${Date.now()}-${counter}`
+
}
+
+
// Helper to extract event type from message
+
function extractEventType(message: string): string | undefined {
+
const match = message.match(/^\[([^\]]+)\]/)
+
return match ? match[1] : undefined
+
}
+
+
// Log collector
+
export const logCollector = {
+
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
const entry: LogEntry = {
+
id: generateId('log', logCounter++),
+
timestamp: new Date(),
+
level,
+
message,
+
service,
+
context,
+
traceId,
+
eventType: extractEventType(message)
+
}
+
+
logs.unshift(entry)
+
+
// Rotate if needed
+
if (logs.length > MAX_LOGS) {
+
logs.splice(MAX_LOGS)
+
}
+
+
// Also log to console for compatibility
+
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
+
const traceStr = traceId ? ` [trace:${traceId}]` : ''
+
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
+
},
+
+
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('info', message, service, context, traceId)
+
},
+
+
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('warn', message, service, context, traceId)
+
},
+
+
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
+
const ctx = { ...context }
+
if (error instanceof Error) {
+
ctx.error = error.message
+
ctx.stack = error.stack
+
} else if (error) {
+
ctx.error = String(error)
+
}
+
this.log('error', message, service, ctx, traceId)
+
+
// Also track in errors
+
errorTracker.track(message, service, error, context)
+
},
+
+
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
if (process.env.NODE_ENV !== 'production') {
+
this.log('debug', message, service, context, traceId)
+
}
+
},
+
+
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
+
let filtered = [...logs]
+
+
if (filter?.level) {
+
filtered = filtered.filter(log => log.level === filter.level)
+
}
+
+
if (filter?.service) {
+
filtered = filtered.filter(log => log.service === filter.service)
+
}
+
+
if (filter?.eventType) {
+
filtered = filtered.filter(log => log.eventType === filter.eventType)
+
}
+
+
if (filter?.search) {
+
const search = filter.search.toLowerCase()
+
filtered = filtered.filter(log =>
+
log.message.toLowerCase().includes(search) ||
+
JSON.stringify(log.context).toLowerCase().includes(search)
+
)
+
}
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
logs.length = 0
+
}
+
}
+
+
// Error tracker with deduplication
+
export const errorTracker = {
+
track(message: string, service: string, error?: any, context?: Record<string, any>) {
+
const key = `${service}:${message}`
+
+
const existing = errors.get(key)
+
if (existing) {
+
existing.count++
+
existing.lastSeen = new Date()
+
if (context) {
+
existing.context = { ...existing.context, ...context }
+
}
+
} else {
+
const entry: ErrorEntry = {
+
id: generateId('error', errorCounter++),
+
timestamp: new Date(),
+
message,
+
service,
+
context,
+
count: 1,
+
lastSeen: new Date()
+
}
+
+
if (error instanceof Error) {
+
entry.stack = error.stack
+
}
+
+
errors.set(key, entry)
+
+
// Rotate if needed
+
if (errors.size > MAX_ERRORS) {
+
const oldest = Array.from(errors.keys())[0]
+
if (oldest !== undefined) {
+
errors.delete(oldest)
+
}
+
}
+
}
+
},
+
+
getErrors(filter?: { service?: string; limit?: number }) {
+
let filtered = Array.from(errors.values())
+
+
if (filter?.service) {
+
filtered = filtered.filter(err => err.service === filter.service)
+
}
+
+
// Sort by last seen (most recent first)
+
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
errors.clear()
+
}
+
}
+
+
// Metrics collector
+
export const metricsCollector = {
+
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
+
const entry: MetricEntry = {
+
timestamp: new Date(),
+
path,
+
method,
+
statusCode,
+
duration,
+
service
+
}
+
+
metrics.unshift(entry)
+
+
// Rotate if needed
+
if (metrics.length > MAX_METRICS) {
+
metrics.splice(MAX_METRICS)
+
}
+
},
+
+
getMetrics(filter?: { service?: string; timeWindow?: number }) {
+
let filtered = [...metrics]
+
+
if (filter?.service) {
+
filtered = filtered.filter(m => m.service === filter.service)
+
}
+
+
if (filter?.timeWindow) {
+
const cutoff = Date.now() - filter.timeWindow
+
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
+
}
+
+
return filtered
+
},
+
+
getStats(service?: string, timeWindow: number = 3600000) {
+
const filtered = this.getMetrics({ service, timeWindow })
+
+
if (filtered.length === 0) {
+
return {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
}
+
+
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
+
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
+
const errors = filtered.filter(m => m.statusCode >= 400).length
+
+
const p50 = durations[Math.floor(durations.length * 0.5)]
+
const p95 = durations[Math.floor(durations.length * 0.95)]
+
const p99 = durations[Math.floor(durations.length * 0.99)]
+
+
const timeWindowMinutes = timeWindow / 60000
+
+
return {
+
totalRequests: filtered.length,
+
avgDuration: Math.round(totalDuration / filtered.length),
+
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)
+
}
+
},
+
+
clear() {
+
metrics.length = 0
+
}
+
}
+
+
// Hono middleware for request timing
+
export function observabilityMiddleware(service: string) {
+
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(
+
pathname,
+
c.req.method,
+
c.res.status,
+
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 }
+
)
+
+
return c.text('Internal Server Error', 500)
+
}
+
}
+
+
// Export singleton logger for easy access
+
export const logger = {
+
info: (message: string, context?: Record<string, any>) =>
+
logCollector.info(message, 'hosting-service', context),
+
warn: (message: string, context?: Record<string, any>) =>
+
logCollector.warn(message, 'hosting-service', context),
+
error: (message: string, error?: any, context?: Record<string, any>) =>
+
logCollector.error(message, 'hosting-service', error, context),
+
debug: (message: string, context?: Record<string, any>) =>
+
logCollector.debug(message, 'hosting-service', context)
+
}
+10 -4
hosting-service/src/lib/safe-fetch.ts
···
'169.254.169.254',
];
-
const FETCH_TIMEOUT = 5000; // 5 seconds
+
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)
+
})
+
})
+191 -41
hosting-service/src/lib/utils.ts
···
import { AtpAgent } from '@atproto/api';
-
import type { WispFsRecord, Directory, Entry, File } from './types';
-
import { existsSync, mkdirSync, readFileSync } from 'fs';
-
import { writeFile, readFile } from 'fs/promises';
+
import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs';
+
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 { createHash } from 'crypto';
+
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 {
···
rkey: string;
}
-
// Type guards for different blob reference formats
+
/**
+
* 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 {
$link: string;
}
···
let doc;
if (did.startsWith('did:plc:')) {
-
// Resolve did:plc from plc.directory
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
} else if (did.startsWith('did:web:')) {
-
// Resolve did:web from the domain
const didUrl = didWebToHttps(did);
doc = await safeFetchJson(didUrl);
} else {
···
}
function didWebToHttps(did: string): string {
-
// did:web:example.com -> https://example.com/.well-known/did.json
-
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
-
const didParts = did.split(':');
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
throw new Error('Invalid did:web format');
···
const pathParts = didParts.slice(3);
if (pathParts.length === 0) {
-
// No path, use .well-known
return `https://${domain}/.well-known/did.json`;
} else {
-
// Has path
const path = pathParts.join('/');
return `https://${domain}/${path}/did.json`;
}
···
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
const data = await safeFetchJson(url);
-
// Return both the record and its CID for verification
return {
record: data.value as WispFsRecord,
cid: data.cid || ''
···
}
export function extractBlobCid(blobRef: unknown): string | null {
-
// Check if it's a direct IPLD link
if (isIpldLink(blobRef)) {
return blobRef.$link;
}
-
// Check if it's a typed blob ref with a ref property
if (isTypedBlobRef(blobRef)) {
const ref = blobRef.ref;
-
// Check if ref is a CID object
-
if (CID.isCID(ref)) {
-
return ref.toString();
+
const cid = CID.asCID(ref);
+
if (cid) {
+
return cid.toString();
}
-
// Check if ref is an IPLD link object
if (isIpldLink(ref)) {
return ref.$link;
}
}
-
// Check if it's an untyped blob ref with a cid string
if (isUntypedBlobRef(blobRef)) {
return blobRef.cid;
}
···
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
console.log('Caching site', did, rkey);
-
// Validate record structure
if (!record.root) {
console.error('Record missing root directory:', JSON.stringify(record, null, 2));
throw new Error('Invalid record structure: missing root directory');
···
throw new Error('Invalid record structure: root missing entries array');
}
-
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
+
// Use a temporary directory with timestamp to avoid collisions
+
const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+
const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`;
+
const finalDir = `${CACHE_DIR}/${did}/${rkey}`;
-
// Save cache metadata with CID for verification
-
await saveCacheMetadata(did, rkey, recordCid);
+
try {
+
// Download to temporary directory
+
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix);
+
await saveCacheMetadata(did, rkey, recordCid, tempSuffix);
+
+
// Atomically replace old cache with new cache
+
// On POSIX systems (Linux/macOS), rename is atomic
+
if (existsSync(finalDir)) {
+
// Rename old directory to backup
+
const backupDir = `${finalDir}.old-${Date.now()}`;
+
await rename(finalDir, backupDir);
+
+
try {
+
// Rename new directory to final location
+
await rename(tempDir, finalDir);
+
+
// Clean up old backup
+
rmSync(backupDir, { recursive: true, force: true });
+
} catch (err) {
+
// If rename failed, restore backup
+
if (existsSync(backupDir) && !existsSync(finalDir)) {
+
await rename(backupDir, finalDir);
+
}
+
throw err;
+
}
+
} else {
+
// No existing cache, just rename temp to final
+
await rename(tempDir, finalDir);
+
}
+
+
console.log('Successfully cached site atomically', did, rkey);
+
} catch (err) {
+
// Clean up temp directory on failure
+
if (existsSync(tempDir)) {
+
rmSync(tempDir, { recursive: true, force: true });
+
}
+
throw err;
+
}
}
async function cacheFiles(
···
site: string,
entries: Entry[],
pdsEndpoint: string,
-
pathPrefix: string
+
pathPrefix: string,
+
dirSuffix: string = ''
): Promise<void> {
-
for (const entry of entries) {
-
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
-
const node = entry.node;
+
// Collect all file blob download tasks first
+
const downloadTasks: Array<() => Promise<void>> = [];
+
+
function collectFileTasks(
+
entries: Entry[],
+
currentPathPrefix: string
+
) {
+
for (const entry of entries) {
+
const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name;
+
const node = entry.node;
-
if ('type' in node && node.type === 'directory' && 'entries' in node) {
-
await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath);
-
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
-
await cacheFileBlob(did, site, currentPath, node.blob, pdsEndpoint);
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
+
collectFileTasks(node.entries, currentPath);
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
+
const fileNode = node as File;
+
downloadTasks.push(() => cacheFileBlob(
+
did,
+
site,
+
currentPath,
+
fileNode.blob,
+
pdsEndpoint,
+
fileNode.encoding,
+
fileNode.mimeType,
+
fileNode.base64,
+
dirSuffix
+
));
+
}
}
}
+
+
collectFileTasks(entries, pathPrefix);
+
+
// Execute downloads concurrently with a limit of 3 at a time
+
const concurrencyLimit = 3;
+
for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) {
+
const batch = downloadTasks.slice(i, i + concurrencyLimit);
+
await Promise.all(batch.map(task => task()));
+
}
}
async function cacheFileBlob(
···
site: string,
filePath: string,
blobRef: any,
-
pdsEndpoint: string
+
pdsEndpoint: string,
+
encoding?: 'gzip',
+
mimeType?: string,
+
base64?: boolean,
+
dirSuffix: string = ''
): Promise<void> {
const cid = extractBlobCid(blobRef);
if (!cid) {
···
const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
-
// Allow up to 100MB per file blob
-
const content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024 });
+
// Allow up to 100MB per file blob, with 2 minute timeout
+
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 });
+
+
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}/${filePath}`;
+
const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
const fileDir = cacheFile.substring(0, cacheFile.lastIndexOf('/'));
if (fileDir && !existsSync(fileDir)) {
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);
-
console.log('Cached file', filePath, content.length, 'bytes');
+
+
// Store metadata only if file is still compressed
+
if (encoding === 'gzip' && mimeType) {
+
const metaFile = `${cacheFile}.meta`;
+
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+
console.log('Cached file', filePath, content.length, 'bytes (gzipped,', mimeType + ')');
+
} else {
+
console.log('Cached file', filePath, content.length, 'bytes');
+
}
}
/**
···
return existsSync(`${CACHE_DIR}/${did}/${site}`);
}
-
async function saveCacheMetadata(did: string, rkey: string, recordCid: string): Promise<void> {
+
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> {
const metadata: CacheMetadata = {
recordCid,
cachedAt: Date.now(),
···
rkey
};
-
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
+
const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
const metadataDir = metadataPath.substring(0, metadataPath.lastIndexOf('/'));
if (!existsSync(metadataDir)) {
+217 -43
hosting-service/src/server.ts
···
import { Hono } from 'hono';
-
import { serveStatic } from 'hono/bun';
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 } from 'fs';
-
-
const app = new Hono();
+
import { existsSync, readFileSync } from 'fs';
+
import { lookup } from 'mime-types';
+
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
···
const cachedFile = getCachedFilePath(did, rkey, requestPath);
if (existsSync(cachedFile)) {
-
const file = Bun.file(cachedFile);
-
return new Response(file, {
+
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) {
+
// 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,
+
'Content-Encoding': 'gzip',
+
},
+
});
+
}
+
}
+
+
// Serve non-compressed files normally
+
const mimeType = lookup(cachedFile) || 'application/octet-stream';
+
return new Response(content, {
headers: {
-
'Content-Type': file.type || 'application/octet-stream',
+
'Content-Type': mimeType,
},
});
}
···
if (!requestPath.includes('.')) {
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
if (existsSync(indexFile)) {
-
const file = Bun.file(indexFile);
-
return new Response(file, {
+
const content = readFileSync(indexFile);
+
const metaFile = `${indexFile}.meta`;
+
+
// Check if file has compression metadata
+
if (existsSync(metaFile)) {
+
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
+
if (meta.encoding === 'gzip' && meta.mimeType) {
+
return new Response(content, {
+
headers: {
+
'Content-Type': meta.mimeType,
+
'Content-Encoding': 'gzip',
+
},
+
});
+
}
+
}
+
+
return new Response(content, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
···
const cachedFile = getCachedFilePath(did, rkey, requestPath);
if (existsSync(cachedFile)) {
-
const file = Bun.file(cachedFile);
+
const metaFile = `${cachedFile}.meta`;
+
let mimeType = lookup(cachedFile) || 'application/octet-stream';
+
let isGzipped = false;
+
+
// Check if file has compression metadata
+
if (existsSync(metaFile)) {
+
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
+
if (meta.encoding === 'gzip' && meta.mimeType) {
+
mimeType = meta.mimeType;
+
isGzipped = true;
+
}
+
}
// Check if this is HTML content that needs rewriting
-
if (isHtmlContent(requestPath, file.type)) {
-
const content = await file.text();
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
+
// We decompress, rewrite paths, then recompress for efficient delivery
+
if (isHtmlContent(requestPath, mimeType)) {
+
let content: string;
+
if (isGzipped) {
+
const { gunzipSync } = await import('zlib');
+
const compressed = readFileSync(cachedFile);
+
content = gunzipSync(compressed).toString('utf-8');
+
} else {
+
content = readFileSync(cachedFile, 'utf-8');
+
}
+
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 served with proper MIME type
-
return new Response(file, {
+
// 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,
+
'Content-Encoding': 'gzip',
+
},
+
});
+
}
+
return new Response(content, {
headers: {
-
'Content-Type': file.type || 'application/octet-stream',
+
'Content-Type': mimeType,
},
});
}
···
if (!requestPath.includes('.')) {
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
if (existsSync(indexFile)) {
-
const file = Bun.file(indexFile);
-
const content = await file.text();
-
const rewritten = rewriteHtmlPaths(content, basePath);
-
return new Response(rewritten, {
+
const metaFile = `${indexFile}.meta`;
+
let isGzipped = false;
+
+
if (existsSync(metaFile)) {
+
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
+
if (meta.encoding === 'gzip') {
+
isGzipped = true;
+
}
+
}
+
+
// HTML needs path rewriting, decompress, rewrite, then recompress
+
let content: string;
+
if (isGzipped) {
+
const { gunzipSync } = await import('zlib');
+
const compressed = readFileSync(indexFile);
+
content = gunzipSync(compressed).toString('utf-8');
+
} else {
+
content = readFileSync(indexFile, 'utf-8');
+
}
+
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',
},
});
}
···
// Fetch and cache the site
const siteData = await fetchSiteRecord(did, rkey);
if (!siteData) {
-
console.error('Site record not found', did, rkey);
+
logger.error('Site record not found', null, { did, rkey });
return false;
}
const pdsEndpoint = await getPdsForDid(did);
if (!pdsEndpoint) {
-
console.error('PDS not found for DID', did);
+
logger.error('PDS not found for DID', null, { did });
return false;
}
try {
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
+
logger.info('Site cached successfully', { did, rkey });
return true;
} catch (err) {
-
console.error('Failed to cache site', did, rkey, err);
+
logger.error('Failed to cache site', err, { did, rkey });
return false;
}
}
-
// Route 4: Direct file serving (no DB) - sites.wisp.place/:identifier/:site/*
-
// This route is now handled in the catch-all route below
+
const app = new Hono();
-
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
+
// Add observability middleware
+
app.use('*', observabilityMiddleware('hosting-service'));
+
+
// Error handler
+
app.onError(observabilityErrorHandler('hosting-service'));
+
+
// Main site serving route
app.get('/*', async (c) => {
+
const url = new URL(c.req.url);
const hostname = c.req.header('host') || '';
-
const rawPath = c.req.path.replace(/^\//, '');
+
const rawPath = url.pathname.replace(/^\//, '');
const path = sanitizePath(rawPath);
-
-
console.log('[Request]', { hostname, path });
// Check if this is sites.wisp.place subdomain
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
···
const site = pathParts[1];
const filePath = pathParts.slice(2).join('/');
-
console.log('[Sites] Serving', { identifier, site, filePath });
-
// 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);
}
+
// Validate site parameter exists
+
if (!site) {
+
return c.text('Site name required', 400);
+
}
+
// Validate site name (rkey)
if (!isValidRkey(site)) {
return c.text('Invalid site name', 400);
···
const hash = dnsMatch[1];
const baseDomain = dnsMatch[2];
-
console.log('[DNS Hash] Looking up', { hash, baseDomain });
+
if (!hash) {
+
return c.text('Invalid DNS hash', 400);
+
}
if (baseDomain !== BASE_HOST) {
return c.text('Invalid base domain', 400);
···
return c.text('Custom domain not found or not verified', 404);
}
-
const rkey = customDomain.rkey || 'self';
+
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);
}
···
// Route 2: Registered subdomains - /*.wisp.place/*
if (hostname.endsWith(`.${BASE_HOST}`)) {
-
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
-
-
console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname });
-
const domainInfo = await getWispDomain(hostname);
if (!domainInfo) {
return c.text('Subdomain not registered', 404);
}
-
const rkey = domainInfo.rkey || 'self';
+
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);
}
···
}
// Route 1: Custom domains - /*
-
console.log('[Custom Domain] Looking up', { hostname });
-
const customDomain = await getCustomDomain(hostname);
if (!customDomain) {
return c.text('Custom domain not found or not verified', 404);
}
-
const rkey = customDomain.rkey || 'self';
+
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);
}
···
}
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"],
+
}
+
}
+4 -1
lexicons/fs.json
···
"required": ["type", "blob"],
"properties": {
"type": { "type": "string", "const": "file" },
-
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" }
+
"blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" },
+
"encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" },
+
"mimeType": { "type": "string", "description": "Original MIME type before compression" },
+
"base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" }
}
},
"directory": {
+11 -3
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"
},
"dependencies": {
···
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
+
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
···
"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",
-
"typescript": "^5.9.3"
+
"typescript": "^5.9.3",
+
"zlib": "^1.0.5"
},
"devDependencies": {
"@types/react": "^19.2.2",
···
"bun-plugin-tailwind": "^0.1.2",
"bun-types": "latest"
},
-
"module": "src/index.js"
+
"module": "src/index.js",
+
"trustedDependencies": [
+
"core-js",
+
"protobufjs"
+
]
}
+820
public/admin/admin.tsx
···
+
import { StrictMode, useState, useEffect } from 'react'
+
import { createRoot } from 'react-dom/client'
+
import './styles.css'
+
+
// Types
+
interface LogEntry {
+
id: string
+
timestamp: string
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
eventType?: string
+
}
+
+
interface ErrorEntry {
+
id: string
+
timestamp: string
+
message: string
+
stack?: string
+
service: string
+
count: number
+
lastSeen: string
+
}
+
+
interface MetricsStats {
+
totalRequests: number
+
avgDuration: number
+
p50Duration: number
+
p95Duration: number
+
p99Duration: number
+
errorRate: number
+
requestsPerMinute: number
+
}
+
+
// Helper function to format Unix timestamp from database
+
function formatDbDate(timestamp: number | string): Date {
+
const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp
+
return new Date(num * 1000) // Convert seconds to milliseconds
+
}
+
+
// Login Component
+
function Login({ onLogin }: { onLogin: () => void }) {
+
const [username, setUsername] = useState('')
+
const [password, setPassword] = useState('')
+
const [error, setError] = useState('')
+
const [loading, setLoading] = useState(false)
+
+
const handleSubmit = async (e: React.FormEvent) => {
+
e.preventDefault()
+
setError('')
+
setLoading(true)
+
+
try {
+
const res = await fetch('/api/admin/login', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ username, password }),
+
credentials: 'include'
+
})
+
+
if (res.ok) {
+
onLogin()
+
} else {
+
setError('Invalid credentials')
+
}
+
} catch (err) {
+
setError('Failed to login')
+
} finally {
+
setLoading(false)
+
}
+
}
+
+
return (
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
+
<div className="w-full max-w-md">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl">
+
<h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1>
+
<form onSubmit={handleSubmit} className="space-y-4">
+
<div>
+
<label className="block text-sm font-medium text-gray-300 mb-2">
+
Username
+
</label>
+
<input
+
type="text"
+
value={username}
+
onChange={(e) => setUsername(e.target.value)}
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
+
required
+
/>
+
</div>
+
<div>
+
<label className="block text-sm font-medium text-gray-300 mb-2">
+
Password
+
</label>
+
<input
+
type="password"
+
value={password}
+
onChange={(e) => setPassword(e.target.value)}
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
+
required
+
/>
+
</div>
+
{error && (
+
<div className="text-red-400 text-sm">{error}</div>
+
)}
+
<button
+
type="submit"
+
disabled={loading}
+
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium py-2 px-4 rounded transition-colors"
+
>
+
{loading ? 'Logging in...' : 'Login'}
+
</button>
+
</form>
+
</div>
+
</div>
+
</div>
+
)
+
}
+
+
// Dashboard Component
+
function Dashboard() {
+
const [tab, setTab] = useState('overview')
+
const [logs, setLogs] = useState<LogEntry[]>([])
+
const [errors, setErrors] = useState<ErrorEntry[]>([])
+
const [metrics, setMetrics] = useState<any>(null)
+
const [database, setDatabase] = useState<any>(null)
+
const [sites, setSites] = useState<any>(null)
+
const [health, setHealth] = useState<any>(null)
+
const [autoRefresh, setAutoRefresh] = useState(true)
+
+
// Filters
+
const [logLevel, setLogLevel] = useState('')
+
const [logService, setLogService] = useState('')
+
const [logSearch, setLogSearch] = useState('')
+
const [logEventType, setLogEventType] = useState('')
+
+
const fetchLogs = async () => {
+
const params = new URLSearchParams()
+
if (logLevel) params.append('level', logLevel)
+
if (logService) params.append('service', logService)
+
if (logSearch) params.append('search', logSearch)
+
if (logEventType) params.append('eventType', logEventType)
+
params.append('limit', '100')
+
+
const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setLogs(data.logs)
+
}
+
}
+
+
const fetchErrors = async () => {
+
const res = await fetch('/api/admin/errors', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setErrors(data.errors)
+
}
+
}
+
+
const fetchMetrics = async () => {
+
const res = await fetch('/api/admin/metrics', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setMetrics(data)
+
}
+
}
+
+
const fetchDatabase = async () => {
+
const res = await fetch('/api/admin/database', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setDatabase(data)
+
}
+
}
+
+
const fetchSites = async () => {
+
const res = await fetch('/api/admin/sites', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setSites(data)
+
}
+
}
+
+
const fetchHealth = async () => {
+
const res = await fetch('/api/admin/health', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setHealth(data)
+
}
+
}
+
+
const logout = async () => {
+
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
+
window.location.reload()
+
}
+
+
useEffect(() => {
+
fetchMetrics()
+
fetchDatabase()
+
fetchHealth()
+
fetchLogs()
+
fetchErrors()
+
fetchSites()
+
}, [])
+
+
useEffect(() => {
+
fetchLogs()
+
}, [logLevel, logService, logSearch])
+
+
useEffect(() => {
+
if (!autoRefresh) return
+
+
const interval = setInterval(() => {
+
if (tab === 'overview') {
+
fetchMetrics()
+
fetchHealth()
+
} else if (tab === 'logs') {
+
fetchLogs()
+
} else if (tab === 'errors') {
+
fetchErrors()
+
} else if (tab === 'database') {
+
fetchDatabase()
+
} else if (tab === 'sites') {
+
fetchSites()
+
}
+
}, 5000)
+
+
return () => clearInterval(interval)
+
}, [tab, autoRefresh, logLevel, logService, logSearch])
+
+
const formatDuration = (ms: number) => {
+
if (ms < 1000) return `${ms}ms`
+
return `${(ms / 1000).toFixed(2)}s`
+
}
+
+
const formatUptime = (seconds: number) => {
+
const hours = Math.floor(seconds / 3600)
+
const minutes = Math.floor((seconds % 3600) / 60)
+
return `${hours}h ${minutes}m`
+
}
+
+
return (
+
<div className="min-h-screen bg-gray-950 text-white">
+
{/* Header */}
+
<div className="bg-gray-900 border-b border-gray-800 px-6 py-4">
+
<div className="flex items-center justify-between">
+
<h1 className="text-2xl font-bold">Wisp.place Admin</h1>
+
<div className="flex items-center gap-4">
+
<label className="flex items-center gap-2 text-sm text-gray-400">
+
<input
+
type="checkbox"
+
checked={autoRefresh}
+
onChange={(e) => setAutoRefresh(e.target.checked)}
+
className="rounded"
+
/>
+
Auto-refresh
+
</label>
+
<button
+
onClick={logout}
+
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm"
+
>
+
Logout
+
</button>
+
</div>
+
</div>
+
</div>
+
+
{/* Tabs */}
+
<div className="bg-gray-900 border-b border-gray-800 px-6">
+
<div className="flex gap-1">
+
{['overview', 'logs', 'errors', 'database', 'sites'].map((t) => (
+
<button
+
key={t}
+
onClick={() => setTab(t)}
+
className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${
+
tab === t
+
? 'text-white border-b-2 border-blue-500'
+
: 'text-gray-400 hover:text-white'
+
}`}
+
>
+
{t}
+
</button>
+
))}
+
</div>
+
</div>
+
+
{/* Content */}
+
<div className="p-6">
+
{tab === 'overview' && (
+
<div className="space-y-6">
+
{/* Health */}
+
{health && (
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Uptime</div>
+
<div className="text-2xl font-bold">{formatUptime(health.uptime)}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Memory Used</div>
+
<div className="text-2xl font-bold">{health.memory.heapUsed} MB</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">RSS</div>
+
<div className="text-2xl font-bold">{health.memory.rss} MB</div>
+
</div>
+
</div>
+
)}
+
+
{/* Metrics */}
+
{metrics && (
+
<div>
+
<h2 className="text-xl font-bold mb-4">Performance Metrics</h2>
+
<div className="space-y-4">
+
{/* Overall */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Total Requests</div>
+
<div className="text-xl font-bold">{metrics.overall.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg Duration</div>
+
<div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95 Duration</div>
+
<div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Error Rate</div>
+
<div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Main App */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Main App</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Requests</div>
+
<div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg</div>
+
<div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95</div>
+
<div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Req/min</div>
+
<div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Hosting Service */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Hosting Service</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Requests</div>
+
<div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg</div>
+
<div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95</div>
+
<div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Req/min</div>
+
<div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
+
)}
+
+
{tab === 'logs' && (
+
<div className="space-y-4">
+
<div className="flex gap-4">
+
<select
+
value={logLevel}
+
onChange={(e) => setLogLevel(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Levels</option>
+
<option value="info">Info</option>
+
<option value="warn">Warn</option>
+
<option value="error">Error</option>
+
<option value="debug">Debug</option>
+
</select>
+
<select
+
value={logService}
+
onChange={(e) => setLogService(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Services</option>
+
<option value="main-app">Main App</option>
+
<option value="hosting-service">Hosting Service</option>
+
</select>
+
<select
+
value={logEventType}
+
onChange={(e) => setLogEventType(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Event Types</option>
+
<option value="DNS Verifier">DNS Verifier</option>
+
<option value="Auth">Auth</option>
+
<option value="User">User</option>
+
<option value="Domain">Domain</option>
+
<option value="Site">Site</option>
+
<option value="File Upload">File Upload</option>
+
<option value="Sync">Sync</option>
+
<option value="Maintenance">Maintenance</option>
+
<option value="KeyRotation">Key Rotation</option>
+
<option value="Cleanup">Cleanup</option>
+
<option value="Cache">Cache</option>
+
<option value="FirehoseWorker">Firehose Worker</option>
+
</select>
+
<input
+
type="text"
+
value={logSearch}
+
onChange={(e) => setLogSearch(e.target.value)}
+
placeholder="Search logs..."
+
className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
/>
+
</div>
+
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<div className="max-h-[600px] overflow-y-auto">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800 sticky top-0">
+
<tr>
+
<th className="px-4 py-2 text-left">Time</th>
+
<th className="px-4 py-2 text-left">Level</th>
+
<th className="px-4 py-2 text-left">Service</th>
+
<th className="px-4 py-2 text-left">Event Type</th>
+
<th className="px-4 py-2 text-left">Message</th>
+
</tr>
+
</thead>
+
<tbody>
+
{logs.map((log) => (
+
<tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">
+
{new Date(log.timestamp).toLocaleTimeString()}
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs font-medium ${
+
log.level === 'error'
+
? 'bg-red-900 text-red-200'
+
: log.level === 'warn'
+
? 'bg-yellow-900 text-yellow-200'
+
: log.level === 'info'
+
? 'bg-blue-900 text-blue-200'
+
: 'bg-gray-700 text-gray-300'
+
}`}
+
>
+
{log.level}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400">{log.service}</td>
+
<td className="px-4 py-2">
+
{log.eventType && (
+
<span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium">
+
{log.eventType}
+
</span>
+
)}
+
</td>
+
<td className="px-4 py-2">
+
<div>{log.message}</div>
+
{log.context && Object.keys(log.context).length > 0 && (
+
<div className="text-xs text-gray-500 mt-1">
+
{JSON.stringify(log.context)}
+
</div>
+
)}
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
+
{tab === 'errors' && (
+
<div className="space-y-4">
+
<h2 className="text-xl font-bold">Recent Errors</h2>
+
<div className="space-y-3">
+
{errors.map((error) => (
+
<div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4">
+
<div className="flex items-start justify-between mb-2">
+
<div className="flex-1">
+
<div className="font-semibold text-red-400">{error.message}</div>
+
<div className="text-sm text-gray-400 mt-1">
+
Service: {error.service} โ€ข Count: {error.count} โ€ข Last seen:{' '}
+
{new Date(error.lastSeen).toLocaleString()}
+
</div>
+
</div>
+
</div>
+
{error.stack && (
+
<pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto">
+
{error.stack}
+
</pre>
+
)}
+
</div>
+
))}
+
{errors.length === 0 && (
+
<div className="text-center text-gray-500 py-8">No errors found</div>
+
)}
+
</div>
+
</div>
+
)}
+
+
{tab === 'database' && database && (
+
<div className="space-y-6">
+
{/* Stats */}
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Total Sites</div>
+
<div className="text-3xl font-bold">{database.stats.totalSites}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div>
+
<div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Custom Domains</div>
+
<div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div>
+
</div>
+
</div>
+
+
{/* Recent Sites */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Recent Sites</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Site Name</th>
+
<th className="px-4 py-2 text-left">Subdomain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{database.recentSites.map((site: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800">
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
+
<td className="px-4 py-2">
+
{site.subdomain ? (
+
<a
+
href={`https://${site.subdomain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{site.subdomain}
+
</a>
+
) : (
+
<span className="text-gray-500">No domain</span>
+
)}
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{site.did.slice(0, 20)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(site.created_at).toLocaleDateString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
+
{/* Recent Domains */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Domain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">Verified</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{database.recentDomains.map((domain: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800">
+
<td className="px-4 py-2">{domain.domain}</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{domain.did.slice(0, 20)}...
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs ${
+
domain.verified
+
? 'bg-green-900 text-green-200'
+
: 'bg-yellow-900 text-yellow-200'
+
}`}
+
>
+
{domain.verified ? 'Yes' : 'No'}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(domain.created_at).toLocaleDateString()}
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
+
{tab === 'sites' && sites && (
+
<div className="space-y-6">
+
{/* All Sites */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">All Sites</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Site Name</th>
+
<th className="px-4 py-2 text-left">Subdomain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{sites.sites.map((site: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
+
<td className="px-4 py-2">
+
{site.subdomain ? (
+
<a
+
href={`https://${site.subdomain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{site.subdomain}
+
</a>
+
) : (
+
<span className="text-gray-500">No domain</span>
+
)}
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{site.did.slice(0, 30)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(site.created_at).toLocaleString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
+
{/* Custom Domains */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Custom Domains</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Domain</th>
+
<th className="px-4 py-2 text-left">Verified</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
<th className="px-4 py-2 text-left">PDSls</th>
+
</tr>
+
</thead>
+
<tbody>
+
{sites.customDomains.map((domain: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2">
+
{domain.verified ? (
+
<a
+
href={`https://${domain.domain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{domain.domain}
+
</a>
+
) : (
+
<span className="text-gray-400">{domain.domain}</span>
+
)}
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs ${
+
domain.verified
+
? 'bg-green-900 text-green-200'
+
: 'bg-yellow-900 text-yellow-200'
+
}`}
+
>
+
{domain.verified ? 'Yes' : 'Pending'}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{domain.did.slice(0, 30)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(domain.created_at).toLocaleString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
+
</div>
+
)
+
}
+
+
// Main App
+
function App() {
+
const [authenticated, setAuthenticated] = useState(false)
+
const [checking, setChecking] = useState(true)
+
+
useEffect(() => {
+
fetch('/api/admin/status', { credentials: 'include' })
+
.then((res) => res.json())
+
.then((data) => {
+
setAuthenticated(data.authenticated)
+
setChecking(false)
+
})
+
.catch(() => {
+
setChecking(false)
+
})
+
}, [])
+
+
if (checking) {
+
return (
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
+
<div className="text-white">Loading...</div>
+
</div>
+
)
+
}
+
+
if (!authenticated) {
+
return <Login onLogin={() => setAuthenticated(true)} />
+
}
+
+
return <Dashboard />
+
}
+
+
createRoot(document.getElementById('root')!).render(
+
<StrictMode>
+
<App />
+
</StrictMode>
+
)
+13
public/admin/index.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Admin Dashboard - Wisp.place</title>
+
<link rel="stylesheet" href="./styles.css" />
+
</head>
+
<body>
+
<div id="root"></div>
+
<script type="module" src="./admin.tsx"></script>
+
</body>
+
</html>
+1
public/admin/styles.css
···
+
@import "tailwindcss";
+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}
+586 -41
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'
···
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
const [selectedDomain, setSelectedDomain] = useState<string>('')
const [isSavingConfig, setIsSavingConfig] = useState(false)
+
const [isDeletingSite, setIsDeletingSite] = useState(false)
// Upload state
-
const [siteName, setSiteName] = useState('')
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
+
const [newSiteName, setNewSiteName] = useState('')
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState('')
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
+
const [uploadedCount, setUploadedCount] = useState(0)
// Custom domain modal state
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
···
}>({})
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
+
// Wisp domain claim state
+
const [wispHandle, setWispHandle] = useState('')
+
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
+
const [wispAvailability, setWispAvailability] = useState<{
+
available: boolean | null
+
checking: boolean
+
}>({ available: null, checking: false })
+
// Fetch user info on mount
useEffect(() => {
fetchUserInfo()
···
fetchDomains()
}, [])
+
// Auto-switch to 'new' mode if no sites exist
+
useEffect(() => {
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
+
setSiteMode('new')
+
}
+
}, [sites, sitesLoading, siteMode])
+
const fetchUserInfo = async () => {
try {
const response = await fetch('/api/user/info')
···
}
const handleUpload = async () => {
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
+
if (!siteName) {
-
alert('Please enter a site name')
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
return
}
···
const data = await response.json()
if (data.success) {
setUploadProgress('Upload complete!')
-
setSiteName('')
+
setSkippedFiles(data.skippedFiles || [])
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
+
setSelectedSiteRkey('')
+
setNewSiteName('')
setSelectedFiles(null)
// Refresh sites list
await fetchSites()
-
// Reset form
+
// Reset form - give more time if there are skipped files
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
setTimeout(() => {
setUploadProgress('')
+
setSkippedFiles([])
+
setUploadedCount(0)
setIsUploading(false)
-
}, 1500)
+
}, resetDelay)
} else {
throw new Error(data.error || 'Upload failed')
}
···
}
}
+
const handleDeleteSite = async () => {
+
if (!configuringSite) return
+
+
if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) {
+
return
+
}
+
+
setIsDeletingSite(true)
+
try {
+
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
+
method: 'DELETE'
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
// Refresh sites list
+
await fetchSites()
+
// Refresh domains in case this site was mapped
+
await fetchDomains()
+
setConfiguringSite(null)
+
} else {
+
throw new Error(data.error || 'Failed to delete site')
+
}
+
} catch (err) {
+
console.error('Delete site error:', err)
+
alert(
+
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
} finally {
+
setIsDeletingSite(false)
+
}
+
}
+
+
const checkWispAvailability = async (handle: string) => {
+
const trimmedHandle = handle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
setWispAvailability({ available: null, checking: false })
+
return
+
}
+
+
setWispAvailability({ available: null, checking: true })
+
try {
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
+
const data = await response.json()
+
setWispAvailability({ available: data.available, checking: false })
+
} catch (err) {
+
console.error('Check availability error:', err)
+
setWispAvailability({ available: false, checking: false })
+
}
+
}
+
+
const handleClaimWispDomain = async () => {
+
const trimmedHandle = wispHandle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
alert('Please enter a handle')
+
return
+
}
+
+
setIsClaimingWisp(true)
+
try {
+
const response = await fetch('/api/domain/claim', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ handle: trimmedHandle })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setWispHandle('')
+
setWispAvailability({ available: null, checking: false })
+
await fetchDomains()
+
} else {
+
throw new Error(data.error || 'Failed to claim domain')
+
}
+
} catch (err) {
+
console.error('Claim domain error:', err)
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
+
+
// Handle "Already claimed" error more gracefully
+
if (errorMessage.includes('Already claimed')) {
+
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
+
await fetchDomains()
+
} else {
+
alert(`Failed to claim domain: ${errorMessage}`)
+
}
+
} finally {
+
setIsClaimingWisp(false)
+
}
+
}
+
if (loading) {
return (
<div className="w-full min-h-screen bg-background flex items-center justify-center">
···
</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 */}
···
</p>
</>
) : (
-
<div className="text-center py-4 text-muted-foreground">
-
<p>No wisp.place subdomain claimed yet.</p>
-
<p className="text-sm mt-1">
-
You should have claimed one during onboarding!
-
</p>
+
<div className="space-y-4">
+
<div className="p-4 bg-muted/30 rounded-lg">
+
<p className="text-sm text-muted-foreground mb-4">
+
Claim your free wisp.place subdomain
+
</p>
+
<div className="space-y-3">
+
<div className="space-y-2">
+
<Label htmlFor="wisp-handle">Choose your handle</Label>
+
<div className="flex gap-2">
+
<div className="flex-1 relative">
+
<Input
+
id="wisp-handle"
+
placeholder="mysite"
+
value={wispHandle}
+
onChange={(e) => {
+
setWispHandle(e.target.value)
+
if (e.target.value.trim()) {
+
checkWispAvailability(e.target.value)
+
} else {
+
setWispAvailability({ available: null, checking: false })
+
}
+
}}
+
disabled={isClaimingWisp}
+
className="pr-24"
+
/>
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
+
.wisp.place
+
</span>
+
</div>
+
</div>
+
{wispAvailability.checking && (
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
+
<Loader2 className="w-3 h-3 animate-spin" />
+
Checking availability...
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === true && (
+
<p className="text-xs text-green-600 flex items-center gap-1">
+
<CheckCircle2 className="w-3 h-3" />
+
Available
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === false && (
+
<p className="text-xs text-red-600 flex items-center gap-1">
+
<XCircle className="w-3 h-3" />
+
Not available
+
</p>
+
)}
+
</div>
+
<Button
+
onClick={handleClaimWispDomain}
+
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
+
className="w-full"
+
>
+
{isClaimingWisp ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Claiming...
+
</>
+
) : (
+
'Claim Subdomain'
+
)}
+
</Button>
+
</div>
+
</div>
</div>
)}
</CardContent>
···
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
-
<div className="space-y-2">
-
<Label htmlFor="site-name">Site Name</Label>
-
<Input
-
id="site-name"
-
placeholder="my-awesome-site"
-
value={siteName}
-
onChange={(e) => setSiteName(e.target.value)}
-
disabled={isUploading}
-
/>
+
<div className="space-y-4">
+
<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">
+
<Label htmlFor="site-select">Select Site</Label>
+
{sitesLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
+
</div>
+
) : sites.length === 0 ? (
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
+
No sites available. Create a new site instead.
+
</div>
+
) : (
+
<select
+
id="site-select"
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+
value={selectedSiteRkey}
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
+
disabled={isUploading}
+
>
+
<option value="">Select a site...</option>
+
{sites.map((site) => (
+
<option key={site.rkey} value={site.rkey}>
+
{site.display_name || site.rkey}
+
</option>
+
))}
+
</select>
+
)}
+
</div>
+
) : (
+
<div className="space-y-2">
+
<Label htmlFor="new-site-name">New Site Name</Label>
+
<Input
+
id="new-site-name"
+
placeholder="my-awesome-site"
+
value={newSiteName}
+
onChange={(e) => setNewSiteName(e.target.value)}
+
disabled={isUploading}
+
/>
+
</div>
+
)}
+
+
<p className="text-xs text-muted-foreground">
+
File limits: 100MB per file, 300MB total
+
</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
···
</div>
{uploadProgress && (
-
<div className="p-4 bg-muted rounded-lg">
-
<div className="flex items-center gap-2">
-
<Loader2 className="w-4 h-4 animate-spin" />
-
<span className="text-sm">{uploadProgress}</span>
+
<div className="space-y-3">
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">{uploadProgress}</span>
+
</div>
</div>
+
+
{skippedFiles.length > 0 && (
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+
<div className="flex-1">
+
<span className="font-medium">
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
+
</span>
+
{uploadedCount > 0 && (
+
<span className="text-sm ml-2">
+
({uploadedCount} uploaded successfully)
+
</span>
+
)}
+
</div>
+
</div>
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
+
{skippedFiles.slice(0, 5).map((file, idx) => (
+
<div key={idx} className="text-xs">
+
<span className="font-mono">{file.name}</span>
+
<span className="text-muted-foreground"> - {file.reason}</span>
+
</div>
+
))}
+
{skippedFiles.length > 5 && (
+
<div className="text-xs text-muted-foreground">
+
...and {skippedFiles.length - 5} more
+
</div>
+
)}
+
</div>
+
</div>
+
)}
</div>
)}
<Button
onClick={handleUpload}
className="w-full"
-
disabled={!siteName || isUploading}
+
disabled={
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
+
isUploading ||
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
+
}
>
{isUploading ? (
<>
···
</>
) : (
<>
-
{selectedFiles && selectedFiles.length > 0
-
? 'Upload & Deploy'
-
: 'Create Empty Site'}
+
{siteMode === 'existing' ? (
+
'Update Site'
+
) : (
+
selectedFiles && selectedFiles.length > 0
+
? 'Upload & Deploy'
+
: 'Create Empty Site'
+
)}
</>
)}
</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>
</Tabs>
</div>
···
</RadioGroup>
</div>
)}
-
<DialogFooter>
-
<Button
-
variant="outline"
-
onClick={() => setConfiguringSite(null)}
-
disabled={isSavingConfig}
-
>
-
Cancel
-
</Button>
+
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
<Button
-
onClick={handleSaveSiteConfig}
-
disabled={isSavingConfig}
+
variant="destructive"
+
onClick={handleDeleteSite}
+
disabled={isSavingConfig || isDeletingSite}
+
className="sm:mr-auto"
>
-
{isSavingConfig ? (
+
{isDeletingSite ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
Saving...
+
Deleting...
</>
) : (
-
'Save'
+
<>
+
<Trash2 className="w-4 h-4 mr-2" />
+
Delete Site
+
</>
)}
</Button>
+
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
+
<Button
+
variant="outline"
+
onClick={() => setConfiguringSite(null)}
+
disabled={isSavingConfig || isDeletingSite}
+
className="w-full sm:w-auto"
+
>
+
Cancel
+
</Button>
+
<Button
+
onClick={handleSaveSiteConfig}
+
disabled={isSavingConfig || isDeletingSite}
+
className="w-full sm:w-auto"
+
>
+
{isSavingConfig ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Saving...
+
</>
+
) : (
+
'Save'
+
)}
+
</Button>
+
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+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>
+265 -250
public/index.tsx
···
}, [showForm])
return (
-
<div className="min-h-screen">
-
{/* Header */}
-
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
-
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
-
<div className="flex items-center gap-2">
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
-
<Globe className="w-5 h-5 text-primary-foreground" />
+
<>
+
<div className="min-h-screen">
+
{/* Header */}
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
+
<div className="flex items-center gap-2">
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+
<Globe className="w-5 h-5 text-primary-foreground" />
+
</div>
+
<span className="text-xl font-semibold text-foreground">
+
wisp.place
+
</span>
+
</div>
+
<div className="flex items-center gap-3">
+
<Button
+
variant="ghost"
+
size="sm"
+
onClick={() => setShowForm(true)}
+
>
+
Sign In
+
</Button>
+
<Button
+
size="sm"
+
className="bg-accent text-accent-foreground hover:bg-accent/90"
+
>
+
Get Started
+
</Button>
</div>
-
<span className="text-xl font-semibold text-foreground">
-
wisp.place
-
</span>
</div>
-
<div className="flex items-center gap-3">
-
<Button
-
variant="ghost"
-
size="sm"
-
onClick={() => setShowForm(true)}
-
>
-
Sign In
-
</Button>
-
<Button
-
size="sm"
-
className="bg-accent text-accent-foreground hover:bg-accent/90"
-
>
-
Get Started
-
</Button>
-
</div>
-
</div>
-
</header>
+
</header>
-
{/* Hero Section */}
-
<section className="container mx-auto px-4 py-20 md:py-32">
-
<div className="max-w-4xl mx-auto text-center">
-
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
-
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
-
<span className="text-sm text-accent-foreground">
-
Built on AT Protocol
-
</span>
-
</div>
+
{/* Hero Section */}
+
<section className="container mx-auto px-4 py-20 md:py-32">
+
<div className="max-w-4xl mx-auto text-center">
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
+
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
+
<span className="text-sm text-accent-foreground">
+
Built on AT Protocol
+
</span>
+
</div>
-
<h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
-
Host your sites on the{' '}
-
<span className="text-primary">decentralized</span> web
-
</h1>
+
<h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
+
Your Website.Your Control. Lightning Fast.
+
</h1>
-
<p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
-
Deploy static sites to a truly open network. Your
-
content, your control, your identity. No platform
-
lock-in, ever.
-
</p>
+
<p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
+
Host static sites in your AT Protocol account. You
+
keep ownership and control. We just serve them fast
+
through our CDN.
+
</p>
-
<div className="max-w-md mx-auto relative">
-
<div
-
className={`transition-all duration-500 ease-in-out ${
-
showForm
-
? 'opacity-0 -translate-y-5 pointer-events-none'
-
: 'opacity-100 translate-y-0'
-
}`}
-
>
-
<Button
-
size="lg"
-
className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
-
onClick={() => setShowForm(true)}
+
<div className="max-w-md mx-auto relative">
+
<div
+
className={`transition-all duration-500 ease-in-out ${
+
showForm
+
? 'opacity-0 -translate-y-5 pointer-events-none'
+
: 'opacity-100 translate-y-0'
+
}`}
>
-
Log in with AT Proto
-
<ArrowRight className="ml-2 w-5 h-5" />
-
</Button>
-
</div>
+
<Button
+
size="lg"
+
className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
+
onClick={() => setShowForm(true)}
+
>
+
Log in with AT Proto
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</Button>
+
</div>
-
<div
-
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
-
showForm
-
? 'opacity-100 translate-y-0'
-
: 'opacity-0 translate-y-5 pointer-events-none'
-
}`}
-
>
-
<form
-
onSubmit={async (e) => {
-
e.preventDefault()
-
try {
-
const handle = inputRef.current?.value
-
const res = await fetch(
-
'/api/auth/signin',
-
{
-
method: 'POST',
-
headers: {
-
'Content-Type':
-
'application/json'
-
},
-
body: JSON.stringify({ handle })
+
<div
+
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
+
showForm
+
? 'opacity-100 translate-y-0'
+
: 'opacity-0 translate-y-5 pointer-events-none'
+
}`}
+
>
+
<form
+
onSubmit={async (e) => {
+
e.preventDefault()
+
try {
+
const handle =
+
inputRef.current?.value
+
const res = await fetch(
+
'/api/auth/signin',
+
{
+
method: 'POST',
+
headers: {
+
'Content-Type':
+
'application/json'
+
},
+
body: JSON.stringify({
+
handle
+
})
+
}
+
)
+
if (!res.ok)
+
throw new Error(
+
'Request failed'
+
)
+
const data = await res.json()
+
if (data.url) {
+
window.location.href = data.url
+
} else {
+
alert('Unexpected response')
}
-
)
-
if (!res.ok)
-
throw new Error('Request failed')
-
const data = await res.json()
-
if (data.url) {
-
window.location.href = data.url
-
} else {
-
alert('Unexpected response')
+
} catch (error) {
+
console.error(
+
'Login failed:',
+
error
+
)
+
alert('Authentication failed')
}
-
} catch (error) {
-
console.error('Login failed:', error)
-
alert('Authentication failed')
-
}
-
}}
-
className="space-y-3"
-
>
-
<input
-
ref={inputRef}
-
type="text"
-
name="handle"
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
-
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
-
/>
-
<button
-
type="submit"
-
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
+
}}
+
className="space-y-3"
>
-
Continue
-
<ArrowRight className="ml-2 w-5 h-5" />
-
</button>
-
</form>
+
<input
+
ref={inputRef}
+
type="text"
+
name="handle"
+
placeholder="Enter your handle (e.g., alice.bsky.social)"
+
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
+
/>
+
<button
+
type="submit"
+
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
+
>
+
Continue
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</button>
+
</form>
+
</div>
</div>
</div>
-
</div>
-
</section>
+
</section>
-
{/* Stats Section */}
-
<section className="container mx-auto px-4 py-16">
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto">
-
{[
-
{ value: '100%', label: 'Decentralized' },
-
{ value: '0ms', label: 'Cold Start' },
-
{ value: 'โˆž', label: 'Scalability' },
-
{ value: 'You', label: 'Own Your Data' }
-
].map((stat, i) => (
-
<div key={i} className="text-center">
-
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
-
{stat.value}
+
{/* How It Works */}
+
<section className="container mx-auto px-4 py-16 bg-muted/30">
+
<div className="max-w-3xl mx-auto text-center">
+
<h2 className="text-3xl md:text-4xl font-bold mb-8">
+
How it works
+
</h2>
+
<div className="space-y-6 text-left">
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
01
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
Upload your static site
+
</h3>
+
<p className="text-muted-foreground">
+
Your HTML, CSS, and JavaScript files are
+
stored in your AT Protocol account as
+
gzipped blobs and a manifest record.
+
</p>
+
</div>
+
</div>
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
02
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
We serve it globally
+
</h3>
+
<p className="text-muted-foreground">
+
Wisp.place reads your site from your
+
account and delivers it through our CDN
+
for fast loading anywhere.
+
</p>
+
</div>
</div>
-
<div className="text-sm text-muted-foreground">
-
{stat.label}
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
03
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
You stay in control
+
</h3>
+
<p className="text-muted-foreground">
+
Update or remove your site anytime
+
through your AT Protocol account. No
+
lock-in, no middleman ownership.
+
</p>
+
</div>
</div>
</div>
-
))}
-
</div>
-
</section>
+
</div>
+
</section>
-
{/* Features Grid */}
-
<section id="features" className="container mx-auto px-4 py-20">
-
<div className="text-center mb-16">
-
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
-
Built for the open web
-
</h2>
-
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
-
Everything you need to deploy and manage static sites on
-
a decentralized network
-
</p>
-
</div>
+
{/* Features Grid */}
+
<section id="features" className="container mx-auto px-4 py-20">
+
<div className="text-center mb-16">
+
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
+
Why Wisp.place?
+
</h2>
+
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
+
Static site hosting that respects your ownership
+
</p>
+
</div>
-
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
-
{[
-
{
-
icon: Shield,
-
title: 'True Ownership',
-
description:
-
'Your content lives on the AT Protocol network. No single company can take it down or lock you out.'
-
},
-
{
-
icon: Zap,
-
title: 'Lightning Fast',
-
description:
-
'Distributed edge network ensures your sites load instantly from anywhere in the world.'
-
},
-
{
-
icon: Lock,
-
title: 'Cryptographic Security',
-
description:
-
'Content-addressed storage and cryptographic verification ensure integrity and authenticity.'
-
},
-
{
-
icon: Code,
-
title: 'Developer Friendly',
-
description:
-
'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.'
-
},
-
{
-
icon: Server,
-
title: 'Zero Vendor Lock-in',
-
description:
-
'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.'
-
},
-
{
-
icon: Globe,
-
title: 'Global Network',
-
description:
-
'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.'
-
}
-
].map((feature, i) => (
-
<Card
-
key={i}
-
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
-
>
-
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
-
<feature.icon className="w-6 h-6 text-accent" />
-
</div>
-
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
-
{feature.title}
-
</h3>
-
<p className="text-muted-foreground leading-relaxed">
-
{feature.description}
-
</p>
-
</Card>
-
))}
-
</div>
-
</section>
-
-
{/* How It Works */}
-
<section
-
id="how-it-works"
-
className="container mx-auto px-4 py-20 bg-muted/30"
-
>
-
<div className="max-w-4xl mx-auto">
-
<h2 className="text-4xl md:text-5xl font-bold text-center mb-16 text-balance">
-
Deploy in three steps
-
</h2>
-
-
<div className="space-y-12">
+
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{[
{
-
step: '01',
-
title: 'Upload your site',
+
icon: Shield,
+
title: 'You Own Your Content',
description:
-
'Link your Git repository or upload a folder containing your static site directly.'
+
'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.'
},
{
-
step: '02',
-
title: 'Name and set domain',
+
icon: Zap,
+
title: 'CDN Performance',
description:
-
'Name your site and set domain routing to it. You can bring your own domain too.'
+
'We cache and serve your site from edge locations worldwide for fast load times.'
},
{
-
step: '03',
-
title: 'Deploy to AT Protocol',
+
icon: Lock,
+
title: 'No Vendor Lock-in',
description:
-
'Your site is published to the decentralized network with a permanent, verifiable identity.'
+
'Your data stays in your account. Switch providers or self-host whenever you want.'
+
},
+
{
+
icon: Code,
+
title: 'Simple Deployment',
+
description:
+
'Upload your static files and we handle the rest. No complex configuration needed.'
+
},
+
{
+
icon: Server,
+
title: 'AT Protocol Native',
+
description:
+
'Built for the decentralized web. Your site has a verifiable identity on the network.'
+
},
+
{
+
icon: Globe,
+
title: 'Custom Domains',
+
description:
+
'Use your own domain name or a wisp.place subdomain. Your choice, either way.'
}
-
].map((step, i) => (
-
<div key={i} className="flex gap-6 items-start">
-
<div className="text-6xl font-bold text-accent/20 min-w-[80px]">
-
{step.step}
-
</div>
-
<div className="flex-1 pt-2">
-
<h3 className="text-2xl font-semibold mb-3">
-
{step.title}
-
</h3>
-
<p className="text-lg text-muted-foreground leading-relaxed">
-
{step.description}
-
</p>
+
].map((feature, i) => (
+
<Card
+
key={i}
+
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
+
>
+
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
+
<feature.icon className="w-6 h-6 text-accent" />
</div>
-
</div>
+
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
+
{feature.title}
+
</h3>
+
<p className="text-muted-foreground leading-relaxed">
+
{feature.description}
+
</p>
+
</Card>
))}
</div>
-
</div>
-
</section>
+
</section>
-
{/* Footer */}
-
<footer className="border-t border-border/40 bg-muted/20">
-
<div className="container mx-auto px-4 py-8">
-
<div className="text-center text-sm text-muted-foreground">
-
<p>
-
Built by{' '}
-
<a
-
href="https://bsky.app/profile/nekomimi.pet"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-accent hover:text-accent/80 transition-colors font-medium"
-
>
-
@nekomimi.pet
-
</a>
+
{/* CTA Section */}
+
<section className="container mx-auto px-4 py-20">
+
<div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12">
+
<h2 className="text-3xl md:text-4xl font-bold mb-4">
+
Ready to deploy?
+
</h2>
+
<p className="text-xl text-muted-foreground mb-8">
+
Host your static site on your own AT Protocol
+
account today
</p>
+
<Button
+
size="lg"
+
className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6"
+
onClick={() => setShowForm(true)}
+
>
+
Get Started
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</Button>
</div>
-
</div>
-
</footer>
-
</div>
+
</section>
+
+
{/* Footer */}
+
<footer className="border-t border-border/40 bg-muted/20">
+
<div className="container mx-auto px-4 py-8">
+
<div className="text-center text-sm text-muted-foreground">
+
<p>
+
Built by{' '}
+
<a
+
href="https://bsky.app/profile/nekomimi.pet"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
+
>
+
@nekomimi.pet
+
</a>
+
</p>
+
</div>
+
</div>
+
</footer>
+
</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
+68 -13
public/onboarding/onboarding.tsx
···
} from '@public/components/ui/card'
import { Input } from '@public/components/ui/input'
import { Label } from '@public/components/ui/label'
-
import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
+
import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
import Layout from '@public/layouts'
type OnboardingStep = 'domain' | 'upload' | 'complete'
···
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState('')
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
+
const [uploadedCount, setUploadedCount] = useState(0)
// Check domain availability as user types
useEffect(() => {
···
setClaimedDomain(data.domain)
setStep('upload')
} else {
-
alert('Failed to claim domain. Please try again.')
+
throw new Error(data.error || 'Failed to claim domain')
}
} catch (err) {
console.error('Error claiming domain:', err)
-
alert('Failed to claim domain. Please try again.')
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
+
+
// Handle "Already claimed" error - redirect to editor
+
if (errorMessage.includes('Already claimed')) {
+
alert('You have already claimed a wisp.place subdomain. Redirecting to editor...')
+
window.location.href = '/editor'
+
} else {
+
alert(`Failed to claim domain: ${errorMessage}`)
+
}
} finally {
setIsClaimingDomain(false)
}
···
const data = await response.json()
if (data.success) {
setUploadProgress('Upload complete!')
-
// Redirect to the claimed domain
-
setTimeout(() => {
-
window.location.href = `https://${claimedDomain}`
-
}, 1500)
+
setSkippedFiles(data.skippedFiles || [])
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
+
+
// If there are skipped files, show them briefly before redirecting
+
if (data.skippedFiles && data.skippedFiles.length > 0) {
+
setTimeout(() => {
+
window.location.href = `https://${claimedDomain}`
+
}, 3000) // Give more time to see skipped files
+
} else {
+
setTimeout(() => {
+
window.location.href = `https://${claimedDomain}`
+
}, 1500)
+
}
} else {
throw new Error(data.error || 'Upload failed')
}
···
<p className="text-xs text-muted-foreground">
Supported: HTML, CSS, JS, images, fonts, and more
</p>
+
<p className="text-xs text-muted-foreground">
+
Limits: 100MB per file, 300MB total
+
</p>
</div>
{uploadProgress && (
-
<div className="p-4 bg-muted rounded-lg">
-
<div className="flex items-center gap-2">
-
<Loader2 className="w-4 h-4 animate-spin" />
-
<span className="text-sm">
-
{uploadProgress}
-
</span>
+
<div className="space-y-3">
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">
+
{uploadProgress}
+
</span>
+
</div>
</div>
+
+
{skippedFiles.length > 0 && (
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+
<div className="flex-1">
+
<span className="font-medium">
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
+
</span>
+
{uploadedCount > 0 && (
+
<span className="text-sm ml-2">
+
({uploadedCount} uploaded successfully)
+
</span>
+
)}
+
</div>
+
</div>
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
+
{skippedFiles.slice(0, 5).map((file, idx) => (
+
<div key={idx} className="text-xs">
+
<span className="font-mono">{file.name}</span>
+
<span className="text-muted-foreground"> - {file.reason}</span>
+
</div>
+
))}
+
{skippedFiles.length > 5 && (
+
<div className="text-xs text-muted-foreground">
+
...and {skippedFiles.length - 5} more
+
</div>
+
)}
+
</div>
+
</div>
+
)}
</div>
)}
+98 -58
public/styles/global.css
···
@import "tailwindcss";
@import "tw-animate-css";
-
@custom-variant dark (&:is(.dark *));
+
@custom-variant dark (@media (prefers-color-scheme: dark));
:root {
-
/* #F2E7C9 - parchment background */
-
--background: oklch(0.93 0.03 85);
-
/* #413C58 - violet for text */
-
--foreground: oklch(0.32 0.04 285);
+
color-scheme: light;
-
--card: oklch(0.98 0.01 85);
-
--card-foreground: oklch(0.32 0.04 285);
+
/* Warm beige background inspired by Sunset design #E9DDD8 */
+
--background: oklch(0.90 0.012 35);
+
/* Very dark brown text for strong contrast #2A2420 */
+
--foreground: oklch(0.18 0.01 30);
-
--popover: oklch(0.98 0.01 85);
-
--popover-foreground: oklch(0.32 0.04 285);
+
/* Slightly lighter card background */
+
--card: oklch(0.93 0.01 35);
+
--card-foreground: oklch(0.18 0.01 30);
+
+
--popover: oklch(0.93 0.01 35);
+
--popover-foreground: oklch(0.18 0.01 30);
-
/* #413C58 - violet primary */
-
--primary: oklch(0.32 0.04 285);
-
--primary-foreground: oklch(0.98 0.01 85);
+
/* Dark brown primary inspired by #645343 */
+
--primary: oklch(0.35 0.02 35);
+
--primary-foreground: oklch(0.95 0.01 35);
-
/* #FFAAD2 - pink accent */
+
/* Bright pink accent for links #FFAAD2 */
--accent: oklch(0.78 0.15 345);
-
--accent-foreground: oklch(0.32 0.04 285);
+
--accent-foreground: oklch(0.18 0.01 30);
-
/* #348AA7 - blue secondary */
-
--secondary: oklch(0.56 0.08 220);
-
--secondary-foreground: oklch(0.98 0.01 85);
+
/* Medium taupe secondary inspired by #867D76 */
+
--secondary: oklch(0.52 0.015 30);
+
--secondary-foreground: oklch(0.95 0.01 35);
-
/* #CCD7C5 - ash muted */
-
--muted: oklch(0.85 0.02 130);
-
--muted-foreground: oklch(0.45 0.03 285);
+
/* Light warm muted background */
+
--muted: oklch(0.88 0.01 35);
+
--muted-foreground: oklch(0.42 0.015 30);
-
--border: oklch(0.75 0.02 285);
-
--input: oklch(0.75 0.02 285);
-
--ring: oklch(0.78 0.15 345);
+
--border: oklch(0.75 0.015 30);
+
--input: oklch(0.92 0.01 35);
+
--ring: oklch(0.72 0.08 15);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
···
}
.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);
+
+
/* Warm destructive color */
+
--destructive: oklch(0.60 0.22 27);
+
--destructive-foreground: oklch(0.98 0.01 85);
-
--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);
+
/* 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 {
···
@apply bg-background text-foreground;
}
}
+
+
@keyframes arrow-bounce {
+
0%, 100% {
+
transform: translateX(0);
+
}
+
50% {
+
transform: translateX(4px);
+
}
+
}
+
+
.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;
+
}
+46
scripts/change-admin-password.ts
···
+
// Change admin password
+
import { adminAuth } from './src/lib/admin-auth'
+
import { db } from './src/lib/db'
+
import { randomBytes, createHash } from 'crypto'
+
+
// Get username and new password from command line
+
const username = process.argv[2]
+
const newPassword = process.argv[3]
+
+
if (!username || !newPassword) {
+
console.error('Usage: bun run change-admin-password.ts <username> <new-password>')
+
process.exit(1)
+
}
+
+
if (newPassword.length < 8) {
+
console.error('Password must be at least 8 characters')
+
process.exit(1)
+
}
+
+
// Hash password
+
function hashPassword(password: string, salt: string): string {
+
return createHash('sha256').update(password + salt).digest('hex')
+
}
+
+
function generateSalt(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Initialize
+
await adminAuth.init()
+
+
// Check if user exists
+
const result = await db`SELECT username FROM admin_users WHERE username = ${username}`
+
if (result.length === 0) {
+
console.error(`Admin user '${username}' not found`)
+
process.exit(1)
+
}
+
+
// Update password
+
const salt = generateSalt()
+
const passwordHash = hashPassword(newPassword, salt)
+
+
await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}`
+
+
console.log(`โœ“ Password updated for admin user '${username}'`)
+
process.exit(0)
+31
scripts/create-admin.ts
···
+
// Quick script to create admin user with randomly generated password
+
import { adminAuth } from './src/lib/admin-auth'
+
import { randomBytes } from 'crypto'
+
+
// Generate a secure random password
+
function generatePassword(length: number = 20): string {
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
+
const bytes = randomBytes(length)
+
let password = ''
+
for (let i = 0; i < length; i++) {
+
password += chars[bytes[i] % chars.length]
+
}
+
return password
+
}
+
+
const username = 'admin'
+
const password = generatePassword(20)
+
+
await adminAuth.init()
+
await adminAuth.createAdmin(username, password)
+
+
console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—')
+
console.log('โ•‘ ADMIN USER CREATED SUCCESSFULLY โ•‘')
+
console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n')
+
console.log(`Username: ${username}`)
+
console.log(`Password: ${password}`)
+
console.log('\nโš ๏ธ IMPORTANT: Save this password securely!')
+
console.log('This password will not be shown again.\n')
+
console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n')
+
+
process.exit(0)
+75 -18
src/index.ts
···
import { Elysia } from 'elysia'
+
import type { Context } from 'elysia'
import { cors } from '@elysiajs/cors'
import { staticPlugin } from '@elysiajs/static'
-
import { openapi, fromTypes } from '@elysiajs/openapi'
import type { Config } from './lib/types'
import { BASE_HOST } from './lib/constants'
···
import { wispRoutes } from './routes/wisp'
import { domainRoutes } from './routes/domain'
import { userRoutes } from './routes/user'
+
import { siteRoutes } from './routes/site'
import { csrfProtection } from './lib/csrf'
+
import { DNSVerificationWorker } from './lib/dns-verification-worker'
+
import { logger, logCollector, observabilityMiddleware } from './lib/observability'
+
import { promptAdminSetup } from './lib/admin-auth'
+
import { adminRoutes } from './routes/admin'
const config: Config = {
-
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
+
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
clientName: Bun.env.CLIENT_NAME ?? 'PDS-View'
}
+
+
// Initialize admin setup (prompt if no admin exists)
+
await promptAdminSetup()
const client = await getOAuthClient(config)
···
// Schedule maintenance to run every hour
setInterval(runMaintenance, 60 * 60 * 1000)
-
export const app = new Elysia()
-
// Security headers middleware
-
.onAfterHandle(({ set }) => {
+
// Start DNS verification worker (runs every 10 minutes)
+
const dnsVerifier = new DNSVerificationWorker(
+
10 * 60 * 1000, // 10 minutes
+
(msg, data) => {
+
logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined)
+
}
+
)
+
+
dnsVerifier.start()
+
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
+
+
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: Context) => {
+
observabilityMiddleware('main-app').afterHandle(ctx)
+
// Security headers middleware
+
const { set } = ctx
// Prevent clickjacking attacks
set.headers['X-Frame-Options'] = 'DENY'
// Prevent MIME type sniffing
···
set.headers['X-XSS-Protection'] = '1; mode=block'
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
})
-
.use(
-
openapi({
-
references: fromTypes()
-
})
-
)
+
.onError(observabilityMiddleware('main-app').onError)
+
.use(csrfProtection())
+
.use(authRoutes(client))
+
.use(wispRoutes(client))
+
.use(domainRoutes(client))
+
.use(userRoutes(client))
+
.use(siteRoutes(client))
+
.use(adminRoutes())
.use(
await staticPlugin({
prefix: '/'
})
)
-
.use(csrfProtection())
-
.use(authRoutes(client))
-
.use(wispRoutes(client))
-
.use(domainRoutes(client))
-
.use(userRoutes(client))
-
.get('/client-metadata.json', (c) => {
+
.get('/client-metadata.json', () => {
return createClientMetadata(config)
})
-
.get('/jwks.json', (c) => {
-
const keys = getCurrentKeys()
+
.get('/jwks.json', async () => {
+
const keys = await getCurrentKeys()
if (!keys.length) return { keys: [] }
return {
···
return pub
})
}
+
})
+
.get('/api/health', () => {
+
const dnsVerifierHealth = dnsVerifier.getHealth()
+
return {
+
status: 'ok',
+
timestamp: new Date().toISOString(),
+
dnsVerifier: dnsVerifierHealth
+
}
+
})
+
.get('/api/admin/test', () => {
+
return { message: 'Admin routes test works!' }
+
})
+
.post('/api/admin/verify-dns', async () => {
+
try {
+
await dnsVerifier.trigger()
+
return {
+
success: true,
+
message: 'DNS verification triggered'
+
}
+
} catch (error) {
+
return {
+
success: false,
+
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,
-44
src/lexicon/index.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import {
-
type Auth,
-
type Options as XrpcOptions,
-
Server as XrpcServer,
-
type StreamConfigOrHandler,
-
type MethodConfigOrHandler,
-
createServer as createXrpcServer,
-
} from '@atproto/xrpc-server'
-
import { schemas } from './lexicons.js'
-
-
export function createServer(options?: XrpcOptions): Server {
-
return new Server(options)
-
}
-
-
export class Server {
-
xrpc: XrpcServer
-
place: PlaceNS
-
-
constructor(options?: XrpcOptions) {
-
this.xrpc = createXrpcServer(schemas, options)
-
this.place = new PlaceNS(this)
-
}
-
}
-
-
export class PlaceNS {
-
_server: Server
-
wisp: PlaceWispNS
-
-
constructor(server: Server) {
-
this._server = server
-
this.wisp = new PlaceWispNS(server)
-
}
-
}
-
-
export class PlaceWispNS {
-
_server: Server
-
-
constructor(server: Server) {
-
this._server = server
-
}
-
}
-127
src/lexicon/lexicons.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
import {
-
type LexiconDoc,
-
Lexicons,
-
ValidationError,
-
type ValidationResult,
-
} from '@atproto/lexicon'
-
import { type $Typed, is$typed, maybe$typed } from './util.js'
-
-
export const schemaDict = {
-
PlaceWispFs: {
-
lexicon: 1,
-
id: 'place.wisp.fs',
-
defs: {
-
main: {
-
type: 'record',
-
description: 'Virtual filesystem manifest for a Wisp site',
-
record: {
-
type: 'object',
-
required: ['site', 'root', 'createdAt'],
-
properties: {
-
site: {
-
type: 'string',
-
},
-
root: {
-
type: 'ref',
-
ref: 'lex:place.wisp.fs#directory',
-
},
-
fileCount: {
-
type: 'integer',
-
minimum: 0,
-
maximum: 1000,
-
},
-
createdAt: {
-
type: 'string',
-
format: 'datetime',
-
},
-
},
-
},
-
},
-
file: {
-
type: 'object',
-
required: ['type', 'blob'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'file',
-
},
-
blob: {
-
type: 'blob',
-
accept: ['*/*'],
-
maxSize: 1000000,
-
description: 'Content blob ref',
-
},
-
},
-
},
-
directory: {
-
type: 'object',
-
required: ['type', 'entries'],
-
properties: {
-
type: {
-
type: 'string',
-
const: 'directory',
-
},
-
entries: {
-
type: 'array',
-
maxLength: 500,
-
items: {
-
type: 'ref',
-
ref: 'lex:place.wisp.fs#entry',
-
},
-
},
-
},
-
},
-
entry: {
-
type: 'object',
-
required: ['name', 'node'],
-
properties: {
-
name: {
-
type: 'string',
-
maxLength: 255,
-
},
-
node: {
-
type: 'union',
-
refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'],
-
},
-
},
-
},
-
},
-
},
-
} as const satisfies Record<string, LexiconDoc>
-
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
-
export const lexicons: Lexicons = new Lexicons(schemas)
-
-
export function validate<T extends { $type: string }>(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType: true,
-
): ValidationResult<T>
-
export function validate<T extends { $type?: string }>(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType?: false,
-
): ValidationResult<T>
-
export function validate(
-
v: unknown,
-
id: string,
-
hash: string,
-
requiredType?: boolean,
-
): ValidationResult {
-
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
-
? lexicons.validate(`${id}#${hash}`, v)
-
: {
-
success: false,
-
error: new ValidationError(
-
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
-
),
-
}
-
}
-
-
export const ids = {
-
PlaceWispFs: 'place.wisp.fs',
-
} as const
-79
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 { validate as _validate } from '../../../lexicons'
-
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
-
-
const is$typed = _is$typed,
-
validate = _validate
-
const id = 'place.wisp.fs'
-
-
export interface Record {
-
$type: 'place.wisp.fs'
-
site: string
-
root: Directory
-
fileCount?: number
-
createdAt: string
-
[k: string]: unknown
-
}
-
-
const hashRecord = 'main'
-
-
export function isRecord<V>(v: V) {
-
return is$typed(v, id, hashRecord)
-
}
-
-
export function validateRecord<V>(v: V) {
-
return validate<Record & V>(v, id, hashRecord, true)
-
}
-
-
export interface File {
-
$type?: 'place.wisp.fs#file'
-
type: 'file'
-
/** Content blob ref */
-
blob: BlobRef
-
}
-
-
const hashFile = 'file'
-
-
export function isFile<V>(v: V) {
-
return is$typed(v, id, hashFile)
-
}
-
-
export function validateFile<V>(v: V) {
-
return validate<File & V>(v, id, hashFile)
-
}
-
-
export interface Directory {
-
$type?: 'place.wisp.fs#directory'
-
type: 'directory'
-
entries: Entry[]
-
}
-
-
const hashDirectory = 'directory'
-
-
export function isDirectory<V>(v: V) {
-
return is$typed(v, id, hashDirectory)
-
}
-
-
export function validateDirectory<V>(v: V) {
-
return validate<Directory & V>(v, id, hashDirectory)
-
}
-
-
export interface Entry {
-
$type?: 'place.wisp.fs#entry'
-
name: string
-
node: $Typed<File> | $Typed<Directory> | { $type: string }
-
}
-
-
const hashEntry = 'entry'
-
-
export function isEntry<V>(v: V) {
-
return is$typed(v, id, hashEntry)
-
}
-
-
export function validateEntry<V>(v: V) {
-
return validate<Entry & V>(v, id, hashEntry)
-
}
-82
src/lexicon/util.ts
···
-
/**
-
* GENERATED CODE - DO NOT MODIFY
-
*/
-
-
import { type ValidationResult } from '@atproto/lexicon'
-
-
export type OmitKey<T, K extends keyof T> = {
-
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
-
}
-
-
export type $Typed<V, T extends string = string> = V & { $type: T }
-
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
-
-
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
-
? Id
-
: `${Id}#${Hash}`
-
-
function isObject<V>(v: V): v is V & object {
-
return v != null && typeof v === 'object'
-
}
-
-
function is$type<Id extends string, Hash extends string>(
-
$type: unknown,
-
id: Id,
-
hash: Hash,
-
): $type is $Type<Id, Hash> {
-
return hash === 'main'
-
? $type === id
-
: // $type === `${id}#${hash}`
-
typeof $type === 'string' &&
-
$type.length === id.length + 1 + hash.length &&
-
$type.charCodeAt(id.length) === 35 /* '#' */ &&
-
$type.startsWith(id) &&
-
$type.endsWith(hash)
-
}
-
-
export type $TypedObject<
-
V,
-
Id extends string,
-
Hash extends string,
-
> = V extends {
-
$type: $Type<Id, Hash>
-
}
-
? V
-
: V extends { $type?: string }
-
? V extends { $type?: infer T extends $Type<Id, Hash> }
-
? V & { $type: T }
-
: never
-
: V & { $type: $Type<Id, Hash> }
-
-
export function is$typed<V, Id extends string, Hash extends string>(
-
v: V,
-
id: Id,
-
hash: Hash,
-
): v is $TypedObject<V, Id, Hash> {
-
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
-
}
-
-
export function maybe$typed<V, Id extends string, Hash extends string>(
-
v: V,
-
id: Id,
-
hash: Hash,
-
): v is V & object & { $type?: $Type<Id, Hash> } {
-
return (
-
isObject(v) &&
-
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
-
)
-
}
-
-
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
-
export type ValidatorParam<V extends Validator> =
-
V extends Validator<infer R> ? R : never
-
-
/**
-
* Utility function that allows to convert a "validate*" utility function into a
-
* type predicate.
-
*/
-
export function asPredicate<V extends Validator>(validate: V) {
-
return function <T>(v: T): v is T & ValidatorParam<V> {
-
return validate(v).success
-
}
-
}
+44
src/lexicons/index.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type Auth,
+
type Options as XrpcOptions,
+
Server as XrpcServer,
+
type StreamConfigOrHandler,
+
type MethodConfigOrHandler,
+
createServer as createXrpcServer,
+
} from '@atproto/xrpc-server'
+
import { schemas } from './lexicons.js'
+
+
export function createServer(options?: XrpcOptions): Server {
+
return new Server(options)
+
}
+
+
export class Server {
+
xrpc: XrpcServer
+
place: PlaceNS
+
+
constructor(options?: XrpcOptions) {
+
this.xrpc = createXrpcServer(schemas, options)
+
this.place = new PlaceNS(this)
+
}
+
}
+
+
export class PlaceNS {
+
_server: Server
+
wisp: PlaceWispNS
+
+
constructor(server: Server) {
+
this._server = server
+
this.wisp = new PlaceWispNS(server)
+
}
+
}
+
+
export class PlaceWispNS {
+
_server: Server
+
+
constructor(server: Server) {
+
this._server = server
+
}
+
}
+127
src/lexicons/lexicons.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import {
+
type LexiconDoc,
+
Lexicons,
+
ValidationError,
+
type ValidationResult,
+
} from '@atproto/lexicon'
+
import { type $Typed, is$typed, maybe$typed } from './util.js'
+
+
export const schemaDict = {
+
PlaceWispFs: {
+
lexicon: 1,
+
id: 'place.wisp.fs',
+
defs: {
+
main: {
+
type: 'record',
+
description: 'Virtual filesystem manifest for a Wisp site',
+
record: {
+
type: 'object',
+
required: ['site', 'root', 'createdAt'],
+
properties: {
+
site: {
+
type: 'string',
+
},
+
root: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#directory',
+
},
+
fileCount: {
+
type: 'integer',
+
minimum: 0,
+
maximum: 1000,
+
},
+
createdAt: {
+
type: 'string',
+
format: 'datetime',
+
},
+
},
+
},
+
},
+
file: {
+
type: 'object',
+
required: ['type', 'blob'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'file',
+
},
+
blob: {
+
type: 'blob',
+
accept: ['*/*'],
+
maxSize: 1000000,
+
description: 'Content blob ref',
+
},
+
},
+
},
+
directory: {
+
type: 'object',
+
required: ['type', 'entries'],
+
properties: {
+
type: {
+
type: 'string',
+
const: 'directory',
+
},
+
entries: {
+
type: 'array',
+
maxLength: 500,
+
items: {
+
type: 'ref',
+
ref: 'lex:place.wisp.fs#entry',
+
},
+
},
+
},
+
},
+
entry: {
+
type: 'object',
+
required: ['name', 'node'],
+
properties: {
+
name: {
+
type: 'string',
+
maxLength: 255,
+
},
+
node: {
+
type: 'union',
+
refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'],
+
},
+
},
+
},
+
},
+
},
+
} as const satisfies Record<string, LexiconDoc>
+
export const schemas = Object.values(schemaDict) satisfies LexiconDoc[]
+
export const lexicons: Lexicons = new Lexicons(schemas)
+
+
export function validate<T extends { $type: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType: true,
+
): ValidationResult<T>
+
export function validate<T extends { $type?: string }>(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: false,
+
): ValidationResult<T>
+
export function validate(
+
v: unknown,
+
id: string,
+
hash: string,
+
requiredType?: boolean,
+
): ValidationResult {
+
return (requiredType ? is$typed : maybe$typed)(v, id, hash)
+
? lexicons.validate(`${id}#${hash}`, v)
+
: {
+
success: false,
+
error: new ValidationError(
+
`Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`,
+
),
+
}
+
}
+
+
export const ids = {
+
PlaceWispFs: 'place.wisp.fs',
+
} as const
+85
src/lexicons/types/place/wisp/fs.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+
import { CID } from 'multiformats/cid'
+
import { validate as _validate } from '../../../lexicons'
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+
+
const is$typed = _is$typed,
+
validate = _validate
+
const id = 'place.wisp.fs'
+
+
export interface Main {
+
$type: 'place.wisp.fs'
+
site: string
+
root: Directory
+
fileCount?: number
+
createdAt: string
+
[k: string]: unknown
+
}
+
+
const hashMain = 'main'
+
+
export function isMain<V>(v: V) {
+
return is$typed(v, id, hashMain)
+
}
+
+
export function validateMain<V>(v: V) {
+
return validate<Main & V>(v, id, hashMain, true)
+
}
+
+
export {
+
type Main as Record,
+
isMain as isRecord,
+
validateMain as validateRecord,
+
}
+
+
export interface File {
+
$type?: 'place.wisp.fs#file'
+
type: 'file'
+
/** Content blob ref */
+
blob: BlobRef
+
}
+
+
const hashFile = 'file'
+
+
export function isFile<V>(v: V) {
+
return is$typed(v, id, hashFile)
+
}
+
+
export function validateFile<V>(v: V) {
+
return validate<File & V>(v, id, hashFile)
+
}
+
+
export interface Directory {
+
$type?: 'place.wisp.fs#directory'
+
type: 'directory'
+
entries: Entry[]
+
}
+
+
const hashDirectory = 'directory'
+
+
export function isDirectory<V>(v: V) {
+
return is$typed(v, id, hashDirectory)
+
}
+
+
export function validateDirectory<V>(v: V) {
+
return validate<Directory & V>(v, id, hashDirectory)
+
}
+
+
export interface Entry {
+
$type?: 'place.wisp.fs#entry'
+
name: string
+
node: $Typed<File> | $Typed<Directory> | { $type: string }
+
}
+
+
const hashEntry = 'entry'
+
+
export function isEntry<V>(v: V) {
+
return is$typed(v, id, hashEntry)
+
}
+
+
export function validateEntry<V>(v: V) {
+
return validate<Entry & V>(v, id, hashEntry)
+
}
+82
src/lexicons/util.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
+
import { type ValidationResult } from '@atproto/lexicon'
+
+
export type OmitKey<T, K extends keyof T> = {
+
[K2 in keyof T as K2 extends K ? never : K2]: T[K2]
+
}
+
+
export type $Typed<V, T extends string = string> = V & { $type: T }
+
export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
+
+
export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
+
? Id
+
: `${Id}#${Hash}`
+
+
function isObject<V>(v: V): v is V & object {
+
return v != null && typeof v === 'object'
+
}
+
+
function is$type<Id extends string, Hash extends string>(
+
$type: unknown,
+
id: Id,
+
hash: Hash,
+
): $type is $Type<Id, Hash> {
+
return hash === 'main'
+
? $type === id
+
: // $type === `${id}#${hash}`
+
typeof $type === 'string' &&
+
$type.length === id.length + 1 + hash.length &&
+
$type.charCodeAt(id.length) === 35 /* '#' */ &&
+
$type.startsWith(id) &&
+
$type.endsWith(hash)
+
}
+
+
export type $TypedObject<
+
V,
+
Id extends string,
+
Hash extends string,
+
> = V extends {
+
$type: $Type<Id, Hash>
+
}
+
? V
+
: V extends { $type?: string }
+
? V extends { $type?: infer T extends $Type<Id, Hash> }
+
? V & { $type: T }
+
: never
+
: V & { $type: $Type<Id, Hash> }
+
+
export function is$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is $TypedObject<V, Id, Hash> {
+
return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
+
}
+
+
export function maybe$typed<V, Id extends string, Hash extends string>(
+
v: V,
+
id: Id,
+
hash: Hash,
+
): v is V & object & { $type?: $Type<Id, Hash> } {
+
return (
+
isObject(v) &&
+
('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
+
)
+
}
+
+
export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
+
export type ValidatorParam<V extends Validator> =
+
V extends Validator<infer R> ? R : never
+
+
/**
+
* Utility function that allows to convert a "validate*" utility function into a
+
* type predicate.
+
*/
+
export function asPredicate<V extends Validator>(validate: V) {
+
return function <T>(v: T): v is T & ValidatorParam<V> {
+
return validate(v).success
+
}
+
}
+208
src/lib/admin-auth.ts
···
+
// Admin authentication system
+
import { db } from './db'
+
import { randomBytes, createHash } from 'crypto'
+
+
interface AdminUser {
+
id: number
+
username: string
+
password_hash: string
+
created_at: Date
+
}
+
+
interface AdminSession {
+
sessionId: string
+
username: string
+
expiresAt: Date
+
}
+
+
// In-memory session storage
+
const sessions = new Map<string, AdminSession>()
+
const SESSION_DURATION = 24 * 60 * 60 * 1000 // 24 hours
+
+
// Hash password using SHA-256 with salt
+
function hashPassword(password: string, salt: string): string {
+
return createHash('sha256').update(password + salt).digest('hex')
+
}
+
+
// Generate random salt
+
function generateSalt(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Generate session ID
+
function generateSessionId(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Generate a secure random password
+
function generatePassword(length: number = 20): string {
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
+
const bytes = randomBytes(length)
+
let password = ''
+
for (let i = 0; i < length; i++) {
+
password += chars[bytes[i] % chars.length]
+
}
+
return password
+
}
+
+
export const adminAuth = {
+
// Initialize admin table
+
async init() {
+
await db`
+
CREATE TABLE IF NOT EXISTS admin_users (
+
id SERIAL PRIMARY KEY,
+
username TEXT UNIQUE NOT NULL,
+
password_hash TEXT NOT NULL,
+
salt TEXT NOT NULL,
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+
)
+
`
+
},
+
+
// Check if any admin exists
+
async hasAdmin(): Promise<boolean> {
+
const result = await db`SELECT COUNT(*) as count FROM admin_users`
+
return result[0].count > 0
+
},
+
+
// Create admin user
+
async createAdmin(username: string, password: string): Promise<boolean> {
+
try {
+
const salt = generateSalt()
+
const passwordHash = hashPassword(password, salt)
+
+
await db`INSERT INTO admin_users (username, password_hash, salt) VALUES (${username}, ${passwordHash}, ${salt})`
+
+
console.log(`โœ“ Admin user '${username}' created successfully`)
+
return true
+
} catch (error) {
+
console.error('Failed to create admin user:', error)
+
return false
+
}
+
},
+
+
// Verify admin credentials
+
async verify(username: string, password: string): Promise<boolean> {
+
try {
+
const result = await db`SELECT password_hash, salt FROM admin_users WHERE username = ${username}`
+
+
if (result.length === 0) {
+
return false
+
}
+
+
const { password_hash, salt } = result[0]
+
const hash = hashPassword(password, salt as string)
+
return hash === password_hash
+
} catch (error) {
+
console.error('Failed to verify admin:', error)
+
return false
+
}
+
},
+
+
// Create session
+
createSession(username: string): string {
+
const sessionId = generateSessionId()
+
const expiresAt = new Date(Date.now() + SESSION_DURATION)
+
+
sessions.set(sessionId, {
+
sessionId,
+
username,
+
expiresAt
+
})
+
+
// Clean up expired sessions
+
this.cleanupSessions()
+
+
return sessionId
+
},
+
+
// Verify session
+
verifySession(sessionId: string): AdminSession | null {
+
const session = sessions.get(sessionId)
+
+
if (!session) {
+
return null
+
}
+
+
if (session.expiresAt.getTime() < Date.now()) {
+
sessions.delete(sessionId)
+
return null
+
}
+
+
return session
+
},
+
+
// Delete session
+
deleteSession(sessionId: string) {
+
sessions.delete(sessionId)
+
},
+
+
// Cleanup expired sessions
+
cleanupSessions() {
+
const now = Date.now()
+
for (const [sessionId, session] of sessions.entries()) {
+
if (session.expiresAt.getTime() < now) {
+
sessions.delete(sessionId)
+
}
+
}
+
}
+
}
+
+
// Prompt for admin creation on startup
+
export async function promptAdminSetup() {
+
await adminAuth.init()
+
+
const hasAdmin = await adminAuth.hasAdmin()
+
if (hasAdmin) {
+
return
+
}
+
+
// Skip prompt if SKIP_ADMIN_SETUP is set
+
if (process.env.SKIP_ADMIN_SETUP === 'true') {
+
console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—')
+
console.log('โ•‘ ADMIN SETUP REQUIRED โ•‘')
+
console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n')
+
console.log('No admin user found.')
+
console.log('Create one with: bun run create-admin.ts\n')
+
return
+
}
+
+
console.log('\n===========================================')
+
console.log(' ADMIN SETUP REQUIRED')
+
console.log('===========================================\n')
+
console.log('No admin user found. Creating one automatically...\n')
+
+
// Auto-generate admin credentials with random password
+
const username = 'admin'
+
const password = generatePassword(20)
+
+
await adminAuth.createAdmin(username, password)
+
+
console.log('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—')
+
console.log('โ•‘ ADMIN USER CREATED SUCCESSFULLY โ•‘')
+
console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n')
+
console.log(`Username: ${username}`)
+
console.log(`Password: ${password}`)
+
console.log('\nโš ๏ธ IMPORTANT: Save this password securely!')
+
console.log('This password will not be shown again.\n')
+
console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n')
+
}
+
+
// Elysia middleware to protect admin routes
+
export function requireAdmin({ cookie, set }: any) {
+
const sessionId = cookie.admin_session?.value
+
+
if (!sessionId) {
+
set.status = 401
+
return { error: 'Unauthorized' }
+
}
+
+
const session = adminAuth.verifySession(sessionId)
+
if (!session) {
+
set.status = 401
+
return { error: 'Unauthorized' }
+
}
+
+
// Session is valid, continue
+
return
+
}
+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)
+
})
+
})
+80 -33
src/lib/db.ts
···
id TEXT PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
did TEXT NOT NULL,
-
rkey TEXT NOT NULL DEFAULT 'self',
+
rkey TEXT,
verified BOOLEAN DEFAULT false,
last_verified_at BIGINT,
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
)
`;
+
+
// Migrate existing tables to make rkey nullable and remove default
+
try {
+
await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP NOT NULL`;
+
} catch (err) {
+
// Column might already be nullable, ignore
+
}
+
try {
+
await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP DEFAULT`;
+
} catch (err) {
+
// Default might already be removed, ignore
+
}
// Sites table - cache of place.wisp.fs records from PDS
await db`
···
}
};
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
-
client_id: `${config.domain}/client-metadata.json`,
-
client_name: config.clientName,
-
client_uri: config.domain,
-
logo_uri: `${config.domain}/logo.png`,
-
tos_uri: `${config.domain}/tos`,
-
policy_uri: `${config.domain}/policy`,
-
redirect_uris: [`${config.domain}/api/auth/callback`],
-
grant_types: ['authorization_code', 'refresh_token'],
-
response_types: ['code'],
-
application_type: 'web',
-
token_endpoint_auth_method: 'private_key_jwt',
-
token_endpoint_auth_signing_alg: "ES256",
-
scope: "atproto transition:generic",
-
dpop_bound_access_tokens: true,
-
jwks_uri: `${config.domain}/jwks.json`,
-
subject_type: 'public',
-
authorization_signed_response_alg: 'ES256'
-
});
+
export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
+
const isLocalDev = process.env.LOCAL_DEV === 'true';
+
+
if (isLocalDev) {
+
// Loopback client for local development
+
// For loopback, scopes and redirect_uri must be in client_id query string
+
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
+
const scope = 'atproto transition:generic';
+
const params = new URLSearchParams();
+
params.append('redirect_uri', redirectUri);
+
params.append('scope', scope);
+
+
return {
+
client_id: `http://localhost?${params.toString()}`,
+
client_name: config.clientName,
+
client_uri: config.domain,
+
redirect_uris: [redirectUri],
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'none',
+
scope: scope,
+
dpop_bound_access_tokens: false,
+
subject_type: 'public'
+
};
+
}
+
+
// Production client with private_key_jwt
+
return {
+
client_id: `${config.domain}/client-metadata.json`,
+
client_name: config.clientName,
+
client_uri: config.domain,
+
logo_uri: `${config.domain}/logo.png`,
+
tos_uri: `${config.domain}/tos`,
+
policy_uri: `${config.domain}/policy`,
+
redirect_uris: [`${config.domain}/api/auth/callback`],
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'private_key_jwt',
+
token_endpoint_auth_signing_alg: "ES256",
+
scope: "atproto transition:generic",
+
dpop_bound_access_tokens: true,
+
jwks_uri: `${config.domain}/jwks.json`,
+
subject_type: 'public',
+
authorization_signed_response_alg: 'ES256'
+
};
+
};
const persistKey = async (key: JoseKey) => {
const priv = key.privateJwk;
···
return keys;
};
-
let currentKeys: JoseKey[] = [];
-
-
export const getCurrentKeys = () => currentKeys;
+
// Load keys from database every time (stateless - safe for horizontal scaling)
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
+
return await loadPersistedKeys();
+
};
// Key rotation - rotate keys older than 30 days (monthly rotation)
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
console.log(`[KeyRotation] Rotated key ${oldKid}`);
-
// Reload keys into memory
-
currentKeys = await ensureKeys();
-
return true;
} catch (err) {
console.error('[KeyRotation] Failed to rotate keys:', err);
···
}
};
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
-
if (currentKeys.length === 0) {
-
currentKeys = await ensureKeys();
-
}
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
+
const keys = await ensureKeys();
return new NodeOAuthClient({
clientMetadata: createClientMetadata(config),
-
keyset: currentKeys,
+
keyset: keys,
stateStore,
sessionStore
});
···
return rows[0] ?? null;
};
-
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
+
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => {
const domainLower = domain.toLowerCase();
try {
await db`
···
}
};
-
export const updateCustomDomainRkey = async (id: string, rkey: string) => {
+
export const updateCustomDomainRkey = async (id: string, rkey: string | null) => {
const rows = await db`
UPDATE custom_domains
SET rkey = ${rkey}
···
return { success: false, error: err };
}
};
+
+
export const deleteSite = async (did: string, rkey: string) => {
+
try {
+
await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`;
+
return { success: true };
+
} catch (err) {
+
console.error('Failed to delete site', err);
+
return { success: false, error: err };
+
}
+
};
+190
src/lib/dns-verification-worker.ts
···
+
import { verifyCustomDomain } from './dns-verify';
+
import { db } from './db';
+
+
interface VerificationStats {
+
totalChecked: number;
+
verified: number;
+
failed: number;
+
errors: number;
+
}
+
+
export class DNSVerificationWorker {
+
private interval: Timer | null = null;
+
private isRunning = false;
+
private lastRunTime: number | null = null;
+
private stats: VerificationStats = {
+
totalChecked: 0,
+
verified: 0,
+
failed: 0,
+
errors: 0,
+
};
+
+
constructor(
+
private checkIntervalMs: number = 60 * 60 * 1000, // 1 hour default
+
private onLog?: (message: string, data?: any) => void
+
) {}
+
+
private log(message: string, data?: any) {
+
if (this.onLog) {
+
this.onLog(message, data);
+
}
+
}
+
+
async start() {
+
if (this.isRunning) {
+
this.log('DNS verification worker already running');
+
return;
+
}
+
+
this.isRunning = true;
+
this.log('Starting DNS verification worker', {
+
intervalMinutes: this.checkIntervalMs / 60000,
+
});
+
+
// Run immediately on start
+
await this.verifyAllDomains();
+
+
// Then run on interval
+
this.interval = setInterval(() => {
+
this.verifyAllDomains();
+
}, this.checkIntervalMs);
+
}
+
+
stop() {
+
if (this.interval) {
+
clearInterval(this.interval);
+
this.interval = null;
+
}
+
this.isRunning = false;
+
this.log('DNS verification worker stopped');
+
}
+
+
private async verifyAllDomains() {
+
this.log('Starting DNS verification check');
+
const startTime = Date.now();
+
+
const runStats: VerificationStats = {
+
totalChecked: 0,
+
verified: 0,
+
failed: 0,
+
errors: 0,
+
};
+
+
try {
+
// 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 custom domains to check');
+
this.lastRunTime = Date.now();
+
return;
+
}
+
+
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, verified: wasVerified } = row;
+
+
try {
+
// Extract hash from id (SHA256 of did:domain)
+
const expectedHash = id.substring(0, 16);
+
+
// Verify DNS records
+
const result = await verifyCustomDomain(domain, did, expectedHash);
+
+
if (result.verified) {
+
// Update verified status and last_verified_at timestamp
+
await db`
+
UPDATE custom_domains
+
SET verified = true,
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
+
WHERE id = ${id}
+
`;
+
runStats.verified++;
+
if (!wasVerified) {
+
this.log(`Domain newly verified: ${domain}`, { did });
+
} else {
+
this.log(`Domain re-verified: ${domain}`, { did });
+
}
+
} else {
+
// Mark domain as unverified or keep it pending
+
await db`
+
UPDATE custom_domains
+
SET verified = false,
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
+
WHERE id = ${id}
+
`;
+
runStats.failed++;
+
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++;
+
this.log(`Error verifying domain: ${domain}`, {
+
did,
+
error: error instanceof Error ? error.message : String(error),
+
});
+
}
+
}
+
+
// Update cumulative stats
+
this.stats.totalChecked += runStats.totalChecked;
+
this.stats.verified += runStats.verified;
+
this.stats.failed += runStats.failed;
+
this.stats.errors += runStats.errors;
+
+
const duration = Date.now() - startTime;
+
this.lastRunTime = Date.now();
+
+
this.log('DNS verification check completed', {
+
duration: `${duration}ms`,
+
...runStats,
+
});
+
} catch (error) {
+
this.log('Fatal error in DNS verification worker', {
+
error: error instanceof Error ? error.message : String(error),
+
});
+
}
+
}
+
+
getHealth() {
+
return {
+
isRunning: this.isRunning,
+
lastRunTime: this.lastRunTime,
+
intervalMs: this.checkIntervalMs,
+
stats: this.stats,
+
healthy: this.isRunning && (
+
this.lastRunTime === null ||
+
Date.now() - this.lastRunTime < this.checkIntervalMs * 2
+
),
+
};
+
}
+
+
// Manual trigger for testing
+
async trigger() {
+
this.log('Manual DNS verification triggered');
+
await this.verifyAllDomains();
+
}
+
}
+36 -14
src/lib/oauth-client.ts
···
}
};
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
-
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
+
export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
+
const isLocalDev = Bun.env.LOCAL_DEV === 'true';
+
+
if (isLocalDev) {
+
// Loopback client for local development
+
// For loopback, scopes and redirect_uri must be in client_id query string
+
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
+
const scope = 'atproto transition:generic';
+
const params = new URLSearchParams();
+
params.append('redirect_uri', redirectUri);
+
params.append('scope', scope);
+
+
return {
+
client_id: `http://localhost?${params.toString()}`,
+
client_name: config.clientName,
+
client_uri: `https://wisp.place`,
+
redirect_uris: [redirectUri],
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
application_type: 'web',
+
token_endpoint_auth_method: 'none',
+
scope: scope,
+
dpop_bound_access_tokens: false,
+
subject_type: 'public'
+
};
+
}
+
+
// Production client with private_key_jwt
return {
client_id: `${config.domain}/client-metadata.json`,
client_name: config.clientName,
-
client_uri: `https://wisp.place`,
+
client_uri: `https://wisp.place`,
logo_uri: `${config.domain}/logo.png`,
tos_uri: `${config.domain}/tos`,
policy_uri: `${config.domain}/policy`,
···
return keys;
};
-
let currentKeys: JoseKey[] = [];
-
-
export const getCurrentKeys = () => currentKeys;
+
// Load keys from database every time (stateless - safe for horizontal scaling)
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
+
return await loadPersistedKeys();
+
};
// Key rotation - rotate keys older than 30 days (monthly rotation)
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
logger.info(`[KeyRotation] Rotated key ${oldKid}`);
-
// Reload keys into memory
-
currentKeys = await ensureKeys();
-
return true;
} catch (err) {
logger.error('[KeyRotation] Failed to rotate keys', err);
···
}
};
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
-
if (currentKeys.length === 0) {
-
currentKeys = await ensureKeys();
-
}
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
+
const keys = await ensureKeys();
return new NodeOAuthClient({
clientMetadata: createClientMetadata(config),
-
keyset: currentKeys,
+
keyset: keys,
stateStore,
sessionStore
});
+339
src/lib/observability.ts
···
+
// DIY Observability - Logs, Metrics, and Error Tracking
+
// Types
+
export interface LogEntry {
+
id: string
+
timestamp: Date
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
traceId?: string
+
eventType?: string
+
}
+
+
export interface ErrorEntry {
+
id: string
+
timestamp: Date
+
message: string
+
stack?: string
+
service: string
+
context?: Record<string, any>
+
count: number // How many times this error occurred
+
lastSeen: Date
+
}
+
+
export interface MetricEntry {
+
timestamp: Date
+
path: string
+
method: string
+
statusCode: number
+
duration: number // in milliseconds
+
service: string
+
}
+
+
export interface DatabaseStats {
+
totalSites: number
+
totalDomains: number
+
totalCustomDomains: number
+
recentSites: any[]
+
recentDomains: any[]
+
}
+
+
// In-memory storage with rotation
+
const MAX_LOGS = 5000
+
const MAX_ERRORS = 500
+
const MAX_METRICS = 10000
+
+
const logs: LogEntry[] = []
+
const errors: Map<string, ErrorEntry> = new Map()
+
const metrics: MetricEntry[] = []
+
+
// Helper to generate unique IDs
+
let logCounter = 0
+
let errorCounter = 0
+
+
function generateId(prefix: string, counter: number): string {
+
return `${prefix}-${Date.now()}-${counter}`
+
}
+
+
// Helper to extract event type from message
+
function extractEventType(message: string): string | undefined {
+
const match = message.match(/^\[([^\]]+)\]/)
+
return match ? match[1] : undefined
+
}
+
+
// Log collector
+
export const logCollector = {
+
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
const entry: LogEntry = {
+
id: generateId('log', logCounter++),
+
timestamp: new Date(),
+
level,
+
message,
+
service,
+
context,
+
traceId,
+
eventType: extractEventType(message)
+
}
+
+
logs.unshift(entry)
+
+
// Rotate if needed
+
if (logs.length > MAX_LOGS) {
+
logs.splice(MAX_LOGS)
+
}
+
+
// Also log to console for compatibility
+
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
+
const traceStr = traceId ? ` [trace:${traceId}]` : ''
+
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
+
},
+
+
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('info', message, service, context, traceId)
+
},
+
+
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('warn', message, service, context, traceId)
+
},
+
+
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
+
const ctx = { ...context }
+
if (error instanceof Error) {
+
ctx.error = error.message
+
ctx.stack = error.stack
+
} else if (error) {
+
ctx.error = String(error)
+
}
+
this.log('error', message, service, ctx, traceId)
+
+
// Also track in errors
+
errorTracker.track(message, service, error, context)
+
},
+
+
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
if (process.env.NODE_ENV !== 'production') {
+
this.log('debug', message, service, context, traceId)
+
}
+
},
+
+
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
+
let filtered = [...logs]
+
+
if (filter?.level) {
+
filtered = filtered.filter(log => log.level === filter.level)
+
}
+
+
if (filter?.service) {
+
filtered = filtered.filter(log => log.service === filter.service)
+
}
+
+
if (filter?.eventType) {
+
filtered = filtered.filter(log => log.eventType === filter.eventType)
+
}
+
+
if (filter?.search) {
+
const search = filter.search.toLowerCase()
+
filtered = filtered.filter(log =>
+
log.message.toLowerCase().includes(search) ||
+
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
+
)
+
}
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
logs.length = 0
+
}
+
}
+
+
// Error tracker with deduplication
+
export const errorTracker = {
+
track(message: string, service: string, error?: any, context?: Record<string, any>) {
+
const key = `${service}:${message}`
+
+
const existing = errors.get(key)
+
if (existing) {
+
existing.count++
+
existing.lastSeen = new Date()
+
if (context) {
+
existing.context = { ...existing.context, ...context }
+
}
+
} else {
+
const entry: ErrorEntry = {
+
id: generateId('error', errorCounter++),
+
timestamp: new Date(),
+
message,
+
service,
+
context,
+
count: 1,
+
lastSeen: new Date()
+
}
+
+
if (error instanceof Error) {
+
entry.stack = error.stack
+
}
+
+
errors.set(key, entry)
+
+
// Rotate if needed
+
if (errors.size > MAX_ERRORS) {
+
const oldest = Array.from(errors.keys())[0]
+
errors.delete(oldest)
+
}
+
}
+
},
+
+
getErrors(filter?: { service?: string; limit?: number }) {
+
let filtered = Array.from(errors.values())
+
+
if (filter?.service) {
+
filtered = filtered.filter(err => err.service === filter.service)
+
}
+
+
// Sort by last seen (most recent first)
+
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
errors.clear()
+
}
+
}
+
+
// Metrics collector
+
export const metricsCollector = {
+
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
+
const entry: MetricEntry = {
+
timestamp: new Date(),
+
path,
+
method,
+
statusCode,
+
duration,
+
service
+
}
+
+
metrics.unshift(entry)
+
+
// Rotate if needed
+
if (metrics.length > MAX_METRICS) {
+
metrics.splice(MAX_METRICS)
+
}
+
},
+
+
getMetrics(filter?: { service?: string; timeWindow?: number }) {
+
let filtered = [...metrics]
+
+
if (filter?.service) {
+
filtered = filtered.filter(m => m.service === filter.service)
+
}
+
+
if (filter?.timeWindow) {
+
const cutoff = Date.now() - filter.timeWindow
+
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
+
}
+
+
return filtered
+
},
+
+
getStats(service?: string, timeWindow: number = 3600000) {
+
const filtered = this.getMetrics({ service, timeWindow })
+
+
if (filtered.length === 0) {
+
return {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
}
+
+
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
+
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
+
const errors = filtered.filter(m => m.statusCode >= 400).length
+
+
const p50 = durations[Math.floor(durations.length * 0.5)]
+
const p95 = durations[Math.floor(durations.length * 0.95)]
+
const p99 = durations[Math.floor(durations.length * 0.99)]
+
+
const timeWindowMinutes = timeWindow / 60000
+
+
return {
+
totalRequests: filtered.length,
+
avgDuration: Math.round(totalDuration / filtered.length),
+
p50Duration: Math.round(p50),
+
p95Duration: Math.round(p95),
+
p99Duration: Math.round(p99),
+
errorRate: (errors / filtered.length) * 100,
+
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
+
}
+
},
+
+
clear() {
+
metrics.length = 0
+
}
+
}
+
+
// Elysia middleware for request timing
+
export function observabilityMiddleware(service: string) {
+
return {
+
beforeHandle: ({ request }: any) => {
+
// Store start time on request object
+
(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)
+
+
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(
+
url.pathname,
+
request.method,
+
set.status || 500,
+
duration,
+
service
+
)
+
+
// Don't log 404 errors
+
const statusCode = set.status || 500
+
if (statusCode !== 404) {
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode }
+
)
+
}
+
}
+
}
+
}
+
+
// Export singleton logger for easy access
+
export const logger = {
+
info: (message: string, context?: Record<string, any>) =>
+
logCollector.info(message, 'main-app', context),
+
warn: (message: string, context?: Record<string, any>) =>
+
logCollector.warn(message, 'main-app', context),
+
error: (message: string, error?: any, context?: Record<string, any>) =>
+
logCollector.error(message, 'main-app', error, context),
+
debug: (message: string, context?: Record<string, any>) =>
+
logCollector.debug(message, 'main-app', context)
+
}
+2 -4
src/lib/types.ts
···
-
import type { BlobRef } from "@atproto/api";
-
/**
* Configuration for the Wisp client
* @typeParam Config
*/
export type Config = {
-
/** The base domain URL with HTTPS protocol */
-
domain: `https://${string}`,
+
/** The base domain URL with HTTP or HTTPS protocol */
+
domain: `http://${string}` | `https://${string}`,
/** Name of the client application */
clientName: string
};
+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')
+
}
+
}
+
})
+
})
+52 -4
src/lib/wisp-utils.ts
···
import type { BlobRef } from "@atproto/api";
-
import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs";
+
import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
+
import { validateRecord } from "../lexicons/types/place/wisp/fs";
+
import { gzipSync } from 'zlib';
export interface UploadedFile {
name: string;
content: Buffer;
mimeType: string;
size: number;
+
compressed?: boolean;
+
originalMimeType?: string;
}
export interface FileUploadResult {
hash: string;
blobRef: BlobRef;
+
encoding?: 'gzip';
+
mimeType?: string;
+
base64?: boolean;
}
export interface ProcessedDirectory {
directory: Directory;
fileCount: number;
+
}
+
+
/**
+
* Determine if a file should be gzip compressed based on its MIME type
+
*/
+
export function shouldCompressFile(mimeType: string): boolean {
+
// Compress text-based files
+
const compressibleTypes = [
+
'text/html',
+
'text/css',
+
'text/javascript',
+
'application/javascript',
+
'application/json',
+
'image/svg+xml',
+
'text/xml',
+
'application/xml',
+
'text/plain',
+
'application/x-javascript'
+
];
+
+
// Check if mime type starts with any compressible type
+
return compressibleTypes.some(type => mimeType.startsWith(type));
+
}
+
+
/**
+
* Compress a file using gzip
+
*/
+
export function compressFile(content: Buffer): Buffer {
+
return gzipSync(content, { level: 9 });
}
/**
···
root: Directory,
fileCount: number
): Record {
-
return {
+
const manifest = {
$type: 'place.wisp.fs' as const,
site: siteName,
root,
fileCount,
createdAt: new Date().toISOString()
};
+
+
// Validate the manifest before returning
+
const validationResult = validateRecord(manifest);
+
if (!validationResult.success) {
+
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
+
}
+
+
return manifest;
}
/**
···
});
if (fileIndex !== -1 && uploadResults[fileIndex]) {
-
const blobRef = uploadResults[fileIndex].blobRef;
+
const result = uploadResults[fileIndex];
+
const blobRef = result.blobRef;
return {
...entry,
node: {
$type: 'place.wisp.fs#file' as const,
type: 'file' as const,
-
blob: blobRef
+
blob: blobRef,
+
...(result.encoding && { encoding: result.encoding }),
+
...(result.mimeType && { mimeType: result.mimeType }),
+
...(result.base64 && { base64: result.base64 })
}
};
} else {
+305
src/routes/admin.ts
···
+
// Admin API routes
+
import { Elysia, t } from 'elysia'
+
import { adminAuth, requireAdmin } from '../lib/admin-auth'
+
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
+
import { db } from '../lib/db'
+
+
export const adminRoutes = () =>
+
new Elysia({ prefix: '/api/admin' })
+
// Login
+
.post(
+
'/login',
+
async ({ body, cookie, set }) => {
+
const { username, password } = body
+
+
const valid = await adminAuth.verify(username, password)
+
if (!valid) {
+
set.status = 401
+
return { error: 'Invalid credentials' }
+
}
+
+
const sessionId = adminAuth.createSession(username)
+
+
// Set cookie
+
cookie.admin_session.set({
+
value: sessionId,
+
httpOnly: true,
+
secure: process.env.NODE_ENV === 'production',
+
sameSite: 'lax',
+
maxAge: 24 * 60 * 60 // 24 hours
+
})
+
+
return { success: true }
+
},
+
{
+
body: t.Object({
+
username: t.String(),
+
password: t.String()
+
})
+
}
+
)
+
+
// Logout
+
.post('/logout', ({ cookie }) => {
+
const sessionId = cookie.admin_session?.value
+
if (sessionId && typeof sessionId === 'string') {
+
adminAuth.deleteSession(sessionId)
+
}
+
cookie.admin_session.remove()
+
return { success: true }
+
})
+
+
// Check auth status
+
.get('/status', ({ cookie }) => {
+
const sessionId = cookie.admin_session?.value
+
if (!sessionId || typeof sessionId !== 'string') {
+
return { authenticated: false }
+
}
+
+
const session = adminAuth.verifySession(sessionId)
+
if (!session) {
+
return { authenticated: false }
+
}
+
+
return {
+
authenticated: true,
+
username: session.username
+
}
+
})
+
+
// Get logs (protected)
+
.get('/logs', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
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)
+
+
// Get logs from main app
+
const mainLogs = logCollector.getLogs(filter)
+
+
// Get logs from hosting service
+
let hostingLogs: any[] = []
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const params = new URLSearchParams()
+
if (query.level) params.append('level', query.level as string)
+
if (query.service) params.append('service', query.service as string)
+
if (query.search) params.append('search', query.search as string)
+
if (query.eventType) params.append('eventType', query.eventType as string)
+
params.append('limit', String(filter.limit || 100))
+
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingLogs = data.logs
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
// Merge and sort by timestamp
+
const allLogs = [...mainLogs, ...hostingLogs].sort((a, b) =>
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
+
)
+
+
return { logs: allLogs.slice(0, filter.limit || 100) }
+
})
+
+
// Get errors (protected)
+
.get('/errors', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const filter: any = {}
+
+
if (query.service) filter.service = query.service
+
if (query.limit) filter.limit = parseInt(query.limit as string)
+
+
// Get errors from main app
+
const mainErrors = errorTracker.getErrors(filter)
+
+
// Get errors from hosting service
+
let hostingErrors: any[] = []
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const params = new URLSearchParams()
+
if (query.service) params.append('service', query.service as string)
+
params.append('limit', String(filter.limit || 100))
+
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingErrors = data.errors
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
// Merge and sort by last seen
+
const allErrors = [...mainErrors, ...hostingErrors].sort((a, b) =>
+
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
+
)
+
+
return { errors: allErrors.slice(0, filter.limit || 100) }
+
})
+
+
// Get metrics (protected)
+
.get('/metrics', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const timeWindow = query.timeWindow
+
? parseInt(query.timeWindow as string)
+
: 3600000 // 1 hour default
+
+
const mainAppStats = metricsCollector.getStats('main-app', timeWindow)
+
const overallStats = metricsCollector.getStats(undefined, timeWindow)
+
+
// Get hosting service stats from its own endpoint
+
let hostingServiceStats = {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingServiceStats = data.stats
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
return {
+
overall: overallStats,
+
mainApp: mainAppStats,
+
hostingService: hostingServiceStats,
+
timeWindow
+
}
+
})
+
+
// Get database stats (protected)
+
.get('/database', async ({ cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
try {
+
// Get total counts
+
const allSitesResult = await db`SELECT COUNT(*) as count FROM sites`
+
const wispSubdomainsResult = await db`SELECT COUNT(*) as count FROM domains WHERE domain LIKE '%.wisp.place'`
+
const customDomainsResult = await db`SELECT COUNT(*) as count FROM custom_domains WHERE verified = true`
+
+
// Get recent sites (including those without domains)
+
const recentSites = await db`
+
SELECT
+
s.did,
+
s.rkey,
+
s.display_name,
+
s.created_at,
+
d.domain as subdomain
+
FROM sites s
+
LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place'
+
ORDER BY s.created_at DESC
+
LIMIT 10
+
`
+
+
// Get recent domains
+
const recentDomains = await db`SELECT domain, did, rkey, verified, created_at FROM custom_domains ORDER BY created_at DESC LIMIT 10`
+
+
return {
+
stats: {
+
totalSites: allSitesResult[0].count,
+
totalWispSubdomains: wispSubdomainsResult[0].count,
+
totalCustomDomains: customDomainsResult[0].count
+
},
+
recentSites: recentSites,
+
recentDomains: recentDomains
+
}
+
} catch (error) {
+
set.status = 500
+
return {
+
error: 'Failed to fetch database stats',
+
message: error instanceof Error ? error.message : String(error)
+
}
+
}
+
})
+
+
// Get sites listing (protected)
+
.get('/sites', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const limit = query.limit ? parseInt(query.limit as string) : 50
+
const offset = query.offset ? parseInt(query.offset as string) : 0
+
+
try {
+
const sites = await db`
+
SELECT
+
s.did,
+
s.rkey,
+
s.display_name,
+
s.created_at,
+
d.domain as subdomain
+
FROM sites s
+
LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place'
+
ORDER BY s.created_at DESC
+
LIMIT ${limit} OFFSET ${offset}
+
`
+
+
const customDomains = await db`
+
SELECT
+
domain,
+
did,
+
rkey,
+
verified,
+
created_at
+
FROM custom_domains
+
ORDER BY created_at DESC
+
LIMIT ${limit} OFFSET ${offset}
+
`
+
+
return {
+
sites: sites,
+
customDomains: customDomains
+
}
+
} catch (error) {
+
set.status = 500
+
return {
+
error: 'Failed to fetch sites',
+
message: error instanceof Error ? error.message : String(error)
+
}
+
}
+
})
+
+
// Get system health (protected)
+
.get('/health', ({ cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const uptime = process.uptime()
+
const memory = process.memoryUsage()
+
+
return {
+
uptime: Math.floor(uptime),
+
memory: {
+
heapUsed: Math.round(memory.heapUsed / 1024 / 1024), // MB
+
heapTotal: Math.round(memory.heapTotal / 1024 / 1024), // MB
+
rss: Math.round(memory.rss / 1024 / 1024) // MB
+
},
+
timestamp: new Date().toISOString()
+
}
+
})
+
+10 -5
src/routes/auth.ts
···
import { getSitesByDid, getDomainByDid } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { authenticateRequest } from '../lib/wisp-auth'
-
import { logger } from '../lib/logger'
+
import { logger } from '../lib/observability'
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
.post('/api/auth/signin', async (c) => {
+
let handle = 'unknown'
try {
-
const { handle } = await c.request.json()
+
const body = c.body as { handle: string }
+
handle = body.handle
+
logger.info('Sign-in attempt', { handle })
const state = crypto.randomUUID()
const url = await client.authorize(handle, { state })
+
logger.info('Authorization URL generated', { handle })
return { url: url.toString() }
} catch (err) {
-
logger.error('[Auth] Signin error', err)
-
return { error: 'Authentication failed' }
+
logger.error('Signin error', err, { handle })
+
console.error('[Auth] Full error:', err)
+
return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) }
}
})
.get('/api/auth/callback', async (c) => {
···
logger.error('[Auth] Status check error', err)
return { authenticated: false }
}
-
})
+
})
+1 -1
src/routes/domain.ts
···
}
// Update custom domain to point to this site
-
await updateCustomDomainRkey(id, siteRkey || 'self');
+
await updateCustomDomainRkey(id, siteRkey);
return { success: true };
} catch (err) {
+60
src/routes/site.ts
···
+
import { Elysia } from 'elysia'
+
import { requireAuth } from '../lib/wisp-auth'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Agent } from '@atproto/api'
+
import { deleteSite } from '../lib/db'
+
import { logger } from '../lib/logger'
+
+
export const siteRoutes = (client: NodeOAuthClient) =>
+
new Elysia({ prefix: '/api/site' })
+
.derive(async ({ cookie }) => {
+
const auth = await requireAuth(client, cookie)
+
return { auth }
+
})
+
.delete('/:rkey', async ({ params, auth }) => {
+
const { rkey } = params
+
+
if (!rkey) {
+
return {
+
success: false,
+
error: 'Site rkey is required'
+
}
+
}
+
+
try {
+
// Create agent with OAuth session
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
+
// Delete the record from AT Protocol
+
try {
+
await agent.com.atproto.repo.deleteRecord({
+
repo: auth.did,
+
collection: 'place.wisp.fs',
+
rkey: rkey
+
})
+
logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`)
+
} catch (err) {
+
logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err)
+
throw new Error('Failed to delete site from AT Protocol')
+
}
+
+
// Delete from database
+
const result = await deleteSite(auth.did, rkey)
+
if (!result.success) {
+
throw new Error('Failed to delete site from database')
+
}
+
+
logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`)
+
+
return {
+
success: true,
+
message: 'Site deleted successfully'
+
}
+
} catch (err) {
+
logger.error('[Site] Delete error', err)
+
return {
+
success: false,
+
error: err instanceof Error ? err.message : 'Failed to delete site'
+
}
+
}
+
})
+73 -55
src/routes/wisp.ts
···
type FileUploadResult,
processUploadedFiles,
createManifest,
-
updateFileBlobs
+
updateFileBlobs,
+
shouldCompressFile,
+
compressFile
} from '../lib/wisp-utils'
import { upsertSite } from '../lib/db'
-
import { logger } from '../lib/logger'
+
import { logger } from '../lib/observability'
+
import { validateRecord } from '../lexicons/types/place/wisp/fs'
+
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants'
function isValidSiteName(siteName: string): boolean {
if (!siteName || typeof siteName !== 'string') return false;
···
createdAt: new Date().toISOString()
};
+
// Validate the manifest
+
const validationResult = validateRecord(emptyManifest);
+
if (!validationResult.success) {
+
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
+
}
+
// Use site name as rkey
const rkey = siteName;
···
// Elysia gives us File objects directly, handle both single file and array
const fileArray = Array.isArray(files) ? files : [files];
const uploadedFiles: UploadedFile[] = [];
+
const skippedFiles: Array<{ name: string; reason: string }> = [];
-
// Define allowed file extensions for static site hosting
-
const allowedExtensions = new Set([
-
// HTML
-
'.html', '.htm',
-
// CSS
-
'.css',
-
// JavaScript
-
'.js', '.mjs', '.jsx', '.ts', '.tsx',
-
// Images
-
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
-
// Fonts
-
'.woff', '.woff2', '.ttf', '.otf', '.eot',
-
// Documents
-
'.pdf', '.txt',
-
// JSON (for config files, but not .map files)
-
'.json',
-
// Audio/Video
-
'.mp3', '.mp4', '.webm', '.ogg', '.wav',
-
// Other web assets
-
'.xml', '.rss', '.atom'
-
]);
-
// Files to explicitly exclude
-
const excludedFiles = new Set([
-
'.map', '.DS_Store', 'Thumbs.db'
-
]);
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
-
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
-
-
// Skip excluded files
-
if (excludedFiles.has(fileExtension)) {
-
continue;
-
}
-
-
// Skip files that aren't in allowed extensions
-
if (!allowedExtensions.has(fileExtension)) {
-
continue;
-
}
// Skip files that are too large (limit to 100MB per file)
-
const maxSize = 100 * 1024 * 1024; // 100MB
+
const maxSize = MAX_FILE_SIZE; // 100MB
if (file.size > maxSize) {
+
skippedFiles.push({
+
name: file.name,
+
reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)`
+
});
continue;
}
const arrayBuffer = await file.arrayBuffer();
+
const originalContent = Buffer.from(arrayBuffer);
+
const originalMimeType = file.type || 'application/octet-stream';
+
+
// Compress and base64 encode ALL files
+
const compressedContent = compressFile(originalContent);
+
// Base64 encode the gzipped content to prevent PDS content sniffing
+
const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
+
const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
+
logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
+
uploadedFiles.push({
name: file.name,
-
content: Buffer.from(arrayBuffer),
-
mimeType: 'application/octet-stream',
-
size: file.size
+
content: base64Content,
+
mimeType: originalMimeType,
+
size: base64Content.length,
+
compressed: true,
+
originalMimeType
});
}
// Check total size limit (300MB)
const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
-
const maxTotalSize = 300 * 1024 * 1024; // 300MB
+
const maxTotalSize = MAX_SITE_SIZE; // 300MB
if (totalSize > maxTotalSize) {
throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
}
+
// Check file count limit (2000 files)
+
if (uploadedFiles.length > MAX_FILE_COUNT) {
+
throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`);
+
}
+
if (uploadedFiles.length === 0) {
// Create empty manifest
···
fileCount: 0,
createdAt: new Date().toISOString()
};
+
+
// Validate the manifest
+
const validationResult = validateRecord(emptyManifest);
+
if (!validationResult.success) {
+
throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
+
}
// Use site name as rkey
const rkey = siteName;
···
cid: record.data.cid,
fileCount: 0,
siteName,
+
skippedFiles,
message: 'Site created but no valid web files were found to upload'
};
}
···
// Process files into directory structure
const { directory, fileCount } = processUploadedFiles(uploadedFiles);
-
// Upload files as blobs in parallel (always as octet-stream)
+
// Upload files as blobs in parallel
+
// For compressed files, we upload as octet-stream and store the original MIME type in metadata
+
// For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues
const uploadPromises = uploadedFiles.map(async (file, i) => {
try {
+
// If compressed, always upload as octet-stream
+
// Otherwise, workaround: PDS incorrectly processes text/html through image pipeline
+
const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html')
+
? 'application/octet-stream'
+
: file.mimeType;
+
+
const compressionInfo = file.compressed ? ' (gzipped)' : '';
+
logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
+
const uploadResult = await agent.com.atproto.repo.uploadBlob(
file.content,
{
-
encoding: 'application/octet-stream'
+
encoding: uploadMimeType
}
);
-
const sentMimeType = file.mimeType;
const returnedBlobRef = uploadResult.data.blob;
// Use the blob ref exactly as returned from PDS
return {
result: {
hash: returnedBlobRef.ref.toString(),
-
blobRef: returnedBlobRef
+
blobRef: returnedBlobRef,
+
...(file.compressed && {
+
encoding: 'gzip' as const,
+
mimeType: file.originalMimeType || file.mimeType,
+
base64: true
+
})
},
filePath: file.name,
-
sentMimeType,
+
sentMimeType: file.mimeType,
returnedMimeType: returnedBlobRef.mimeType
};
} catch (uploadError) {
-
logger.error('[Wisp] Upload failed for file', uploadError);
+
logger.error('Upload failed for file', uploadError);
throw uploadError;
}
});
···
record: manifest
});
} catch (putRecordError: any) {
-
logger.error('[Wisp] Failed to create record on PDS');
-
logger.error('[Wisp] Record creation error', putRecordError);
+
logger.error('Failed to create record on PDS', putRecordError);
throw putRecordError;
}
···
uri: record.data.uri,
cid: record.data.cid,
fileCount,
-
siteName
+
siteName,
+
skippedFiles,
+
uploadedCount: uploadedFiles.length
};
return result;
} catch (error) {
-
logger.error('[Wisp] Upload error', error);
-
logger.errorWithContext('[Wisp] Upload error details', {
+
logger.error('Upload error', error, {
message: error instanceof Error ? error.message : 'Unknown error',
name: error instanceof Error ? error.name : undefined
-
}, error);
+
});
throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
+40
testDeploy/index.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
<title>Wisp.place Test Site</title>
+
<style>
+
body {
+
font-family: system-ui, -apple-system, sans-serif;
+
max-width: 800px;
+
margin: 4rem auto;
+
padding: 0 2rem;
+
line-height: 1.6;
+
}
+
h1 {
+
color: #333;
+
}
+
.info {
+
background: #f0f0f0;
+
padding: 1rem;
+
border-radius: 8px;
+
margin: 2rem 0;
+
}
+
</style>
+
</head>
+
<body>
+
<h1>Hello from Wisp.place!</h1>
+
<p>This is a test deployment using the wisp-cli and Tangled Spindles CI/CD.</p>
+
+
<div class="info">
+
<h2>About this deployment</h2>
+
<p>This site was deployed to the AT Protocol using:</p>
+
<ul>
+
<li>Wisp.place CLI (Rust)</li>
+
<li>Tangled Spindles CI/CD</li>
+
<li>AT Protocol for decentralized hosting</li>
+
</ul>
+
</div>
+
</body>
+
</html>