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

Compare changes

Choose any two refs to compare.

+8
.dockerignore
··· 9 9 *.log 10 10 .vscode 11 11 .idea 12 + server 13 + .prettierrc 14 + testDeploy 15 + .tangled 16 + .crush 17 + .claude 18 + server 19 + hosting-service
+4 -1
.gitignore
··· 1 1 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 - 2 + .env 3 3 # dependencies 4 4 /node_modules 5 5 /.pnp 6 6 .pnp.js 7 7 8 + cli/target/ 9 + target/ 8 10 # testing 9 11 /coverage 10 12 ··· 14 16 15 17 # production 16 18 /build 19 + /result 17 20 18 21 # misc 19 22 .DS_Store
+3
.gitmodules
··· 1 + [submodule "cli/jacquard"] 2 + path = cli/jacquard 3 + url = https://tangled.org/@nonbinary.computer/jacquard
+50
.tangled/workflows/deploy-wisp.yml
··· 1 + # Deploy to Wisp.place 2 + # This workflow builds your site and deploys it to Wisp.place using the wisp-cli 3 + when: 4 + - event: ['push'] 5 + branch: ['main'] 6 + - event: ['manual'] 7 + engine: 'nixery' 8 + clone: 9 + skip: false 10 + depth: 1 11 + submodules: true 12 + dependencies: 13 + nixpkgs: 14 + - git 15 + - gcc 16 + github:NixOS/nixpkgs/nixpkgs-unstable: 17 + - rustc 18 + - cargo 19 + environment: 20 + # Customize these for your project 21 + SITE_PATH: 'testDeploy' 22 + SITE_NAME: 'wispPlaceDocs' 23 + steps: 24 + - name: 'Initialize submodules' 25 + command: | 26 + git submodule update --init --recursive 27 + 28 + - name: 'Build wisp-cli' 29 + command: | 30 + cd cli 31 + export PATH="$HOME/.nix-profile/bin:$PATH" 32 + nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs 33 + nix-channel --update 34 + nix-shell -p pkg-config openssl --run ' 35 + export PKG_CONFIG_PATH="$(pkg-config --variable pc_path pkg-config)" 36 + export OPENSSL_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.dev)" 37 + export OPENSSL_NO_VENDOR=1 38 + export OPENSSL_LIB_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.out)/lib" 39 + cargo build --release 40 + ' 41 + cd .. 42 + 43 + - name: 'Deploy to Wisp.place' 44 + command: | 45 + echo 46 + ./cli/target/release/wisp-cli \ 47 + "$WISP_HANDLE" \ 48 + --path "$SITE_PATH" \ 49 + --site "$SITE_NAME" \ 50 + --password "$WISP_APP_PASSWORD"
+22
.tangled/workflows/test.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - git 10 + github:NixOS/nixpkgs/nixpkgs-unstable: 11 + - bun 12 + 13 + steps: 14 + - name: install dependencies 15 + command: | 16 + export PATH="$HOME/.nix-profile/bin:$PATH" 17 + bun install 18 + 19 + - name: run all tests 20 + command: | 21 + export PATH="$HOME/.nix-profile/bin:$PATH" 22 + bun test
+10 -6
Dockerfile
··· 15 15 COPY public ./public 16 16 17 17 # Build the application (if needed) 18 - # RUN bun run build 18 + RUN bun build \ 19 + --compile \ 20 + --minify \ 21 + --outfile server \ 22 + src/index.ts 23 + 24 + FROM scratch AS runtime 25 + WORKDIR /app 26 + COPY --from=base /app/server /app/server 19 27 20 28 # Set environment variables (can be overridden at runtime) 21 29 ENV PORT=3000 ··· 24 32 # Expose the application port 25 33 EXPOSE 3000 26 34 27 - # Health check 28 - HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 29 - CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" 30 - 31 35 # Start the application 32 - CMD ["bun", "src/index.ts"] 36 + CMD ["./server"]
+7 -12
README.md
··· 1 - # Elysia with Bun runtime 1 + # Wisp.place 2 + A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place) 2 3 3 - ## Getting Started 4 - To get started with this template, simply paste this command into your terminal: 5 - ```bash 6 - bun create elysia ./elysia-example 7 - ``` 4 + /src is the main backend 5 + 6 + /hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses 8 7 9 - ## Development 10 - To start the development server run: 11 - ```bash 12 - bun run dev 13 - ``` 8 + /cli is the wisp-cli, a way to upload sites directly to the pds 14 9 15 - Open http://localhost:3000/ with your browser to see the result. 10 + full readme soon
-41
api.md
··· 1 - /** 2 - * AUTHENTICATION ROUTES 3 - * 4 - * Handles OAuth authentication flow for Bluesky/ATProto accounts 5 - * All routes are on the editor.wisp.place subdomain 6 - * 7 - * Routes: 8 - * POST /api/auth/signin - Initiate OAuth sign-in flow 9 - * GET /api/auth/callback - OAuth callback handler (redirect from PDS) 10 - * GET /api/auth/status - Check current authentication status 11 - * POST /api/auth/logout - Sign out and clear session 12 - */ 13 - 14 - /** 15 - * CUSTOM DOMAIN ROUTES 16 - * 17 - * Handles custom domain (BYOD - Bring Your Own Domain) management 18 - * Users can claim custom domains with DNS verification (TXT + CNAME) 19 - * and map them to their sites 20 - * 21 - * Routes: 22 - * GET /api/check-domain - Fast verification check for routing (public) 23 - * GET /api/custom-domains - List user's custom domains 24 - * POST /api/custom-domains/check - Check domain availability and DNS config 25 - * POST /api/custom-domains/claim - Claim a custom domain 26 - * PUT /api/custom-domains/:id/site - Update site mapping 27 - * DELETE /api/custom-domains/:id - Remove a custom domain 28 - * POST /api/custom-domains/:id/verify - Manually trigger verification 29 - */ 30 - 31 - /** 32 - * WISP SITE MANAGEMENT ROUTES 33 - * 34 - * API endpoints for managing user's Wisp sites stored in ATProto repos 35 - * Handles reading site metadata, fetching content, updating sites, and uploads 36 - * All routes are on the editor.wisp.place subdomain 37 - * 38 - * Routes: 39 - * GET /wisp/sites - List all sites for authenticated user 40 - * POST /wisp/upload-files - Upload and deploy files as a site 41 - */
+334
bun.lock
··· 11 11 "@elysiajs/cors": "^1.4.0", 12 12 "@elysiajs/eden": "^1.4.3", 13 13 "@elysiajs/openapi": "^1.4.11", 14 + "@elysiajs/opentelemetry": "^1.4.6", 14 15 "@elysiajs/static": "^1.4.2", 15 16 "@radix-ui/react-dialog": "^1.1.15", 16 17 "@radix-ui/react-label": "^2.1.7", ··· 25 26 "lucide-react": "^0.546.0", 26 27 "react": "^19.2.0", 27 28 "react-dom": "^19.2.0", 29 + "react-shiki": "^0.9.0", 28 30 "tailwind-merge": "^3.3.1", 29 31 "tailwindcss": "4", 30 32 "tw-animate-css": "^1.4.0", 33 + "typescript": "^5.9.3", 34 + "zlib": "^1.0.5", 31 35 }, 32 36 "devDependencies": { 33 37 "@types/react": "^19.2.2", ··· 37 41 }, 38 42 }, 39 43 }, 44 + "trustedDependencies": [ 45 + "core-js", 46 + "protobufjs", 47 + ], 40 48 "packages": { 41 49 "@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=="], 42 50 ··· 108 116 109 117 "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], 110 118 119 + "@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=="], 120 + 111 121 "@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="], 112 122 123 + "@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=="], 124 + 125 + "@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=="], 126 + 113 127 "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="], 114 128 129 + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], 130 + 115 131 "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], 116 132 117 133 "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 118 134 135 + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], 136 + 137 + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], 138 + 139 + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="], 140 + 141 + "@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=="], 142 + 143 + "@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=="], 144 + 145 + "@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=="], 146 + 147 + "@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=="], 148 + 149 + "@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=="], 150 + 151 + "@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=="], 152 + 153 + "@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=="], 154 + 155 + "@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=="], 156 + 157 + "@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=="], 158 + 159 + "@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=="], 160 + 161 + "@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=="], 162 + 163 + "@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=="], 164 + 165 + "@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=="], 166 + 167 + "@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=="], 168 + 169 + "@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=="], 170 + 171 + "@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=="], 172 + 173 + "@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=="], 174 + 175 + "@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=="], 176 + 177 + "@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=="], 178 + 179 + "@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=="], 180 + 181 + "@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=="], 182 + 183 + "@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=="], 184 + 185 + "@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=="], 186 + 187 + "@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=="], 188 + 189 + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], 190 + 119 191 "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], 120 192 121 193 "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], ··· 138 210 139 211 "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], 140 212 213 + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 214 + 215 + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], 216 + 217 + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], 218 + 219 + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], 220 + 221 + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], 222 + 223 + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], 224 + 225 + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], 226 + 227 + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], 228 + 229 + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], 230 + 231 + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 232 + 141 233 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], 142 234 143 235 "@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=="], ··· 188 280 189 281 "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], 190 282 283 + "@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], 284 + 285 + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="], 286 + 287 + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="], 288 + 289 + "@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="], 290 + 291 + "@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="], 292 + 293 + "@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], 294 + 295 + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], 296 + 191 297 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 192 298 193 299 "@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="], ··· 200 306 201 307 "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="], 202 308 309 + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], 310 + 311 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 312 + 313 + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], 314 + 315 + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], 316 + 317 + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], 318 + 319 + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 320 + 203 321 "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], 204 322 205 323 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 206 324 207 325 "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], 208 326 327 + "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 328 + 329 + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 330 + 331 + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], 332 + 209 333 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 210 334 211 335 "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], 212 336 337 + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 338 + 339 + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 340 + 341 + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 342 + 213 343 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 214 344 215 345 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], ··· 248 378 249 379 "cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="], 250 380 381 + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], 382 + 251 383 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 252 384 385 + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], 386 + 387 + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], 388 + 389 + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], 390 + 391 + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], 392 + 393 + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 394 + 253 395 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], 254 396 397 + "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=="], 398 + 255 399 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 256 400 257 401 "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], ··· 259 403 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 260 404 261 405 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 406 + 407 + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], 262 408 263 409 "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], 264 410 ··· 276 422 277 423 "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 278 424 425 + "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], 426 + 279 427 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], 428 + 429 + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 280 430 281 431 "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], 282 432 283 433 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 284 434 285 435 "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], 436 + 437 + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], 286 438 287 439 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 288 440 ··· 290 442 291 443 "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=="], 292 444 445 + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 446 + 293 447 "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], 294 448 295 449 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], ··· 298 452 299 453 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 300 454 455 + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 456 + 301 457 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 302 458 459 + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], 460 + 303 461 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 304 462 305 463 "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], ··· 328 486 329 487 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 330 488 489 + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 490 + 331 491 "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=="], 332 492 333 493 "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], ··· 344 504 345 505 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 346 506 507 + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], 508 + 509 + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], 510 + 511 + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], 512 + 513 + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 514 + 347 515 "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], 348 516 349 517 "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], 350 518 351 519 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 520 + 521 + "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=="], 352 522 353 523 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 354 524 525 + "inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="], 526 + 355 527 "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], 356 528 357 529 "iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="], 358 530 359 531 "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], 360 532 533 + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 534 + 535 + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 536 + 537 + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 538 + 539 + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 540 + 541 + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 542 + 543 + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 544 + 361 545 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 362 546 363 547 "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 364 548 549 + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], 550 + 551 + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], 552 + 553 + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], 554 + 365 555 "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 366 556 367 557 "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=="], 368 558 369 559 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 370 560 561 + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], 562 + 563 + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], 564 + 565 + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], 566 + 567 + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], 568 + 569 + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], 570 + 571 + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], 572 + 573 + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], 574 + 575 + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], 576 + 371 577 "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], 372 578 373 579 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], 374 580 375 581 "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], 376 582 583 + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], 584 + 585 + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], 586 + 587 + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], 588 + 589 + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], 590 + 591 + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], 592 + 593 + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], 594 + 595 + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], 596 + 597 + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], 598 + 599 + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], 600 + 601 + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], 602 + 603 + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], 604 + 605 + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], 606 + 607 + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], 608 + 609 + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], 610 + 611 + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], 612 + 613 + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], 614 + 615 + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], 616 + 617 + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], 618 + 619 + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], 620 + 621 + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], 622 + 623 + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], 624 + 377 625 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 378 626 379 627 "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], ··· 381 629 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 382 630 383 631 "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 632 + 633 + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], 384 634 385 635 "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 386 636 ··· 396 646 397 647 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], 398 648 649 + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], 650 + 651 + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], 652 + 399 653 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], 400 654 655 + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], 656 + 401 657 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 402 658 403 659 "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], 660 + 661 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 404 662 405 663 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], 406 664 ··· 417 675 "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], 418 676 419 677 "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], 678 + 679 + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 680 + 681 + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], 420 682 421 683 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], 422 684 ··· 438 700 439 701 "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], 440 702 703 + "react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="], 704 + 441 705 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 442 706 443 707 "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], 444 708 445 709 "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 446 710 711 + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], 712 + 713 + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], 714 + 715 + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], 716 + 717 + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 718 + 719 + "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], 720 + 721 + "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=="], 722 + 447 723 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 448 724 449 725 "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], ··· 458 734 459 735 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 460 736 737 + "shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], 738 + 739 + "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], 740 + 461 741 "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], 462 742 463 743 "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], ··· 468 748 469 749 "sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="], 470 750 751 + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], 752 + 471 753 "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 472 754 473 755 "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], 474 756 757 + "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=="], 758 + 475 759 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 760 + 761 + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], 762 + 763 + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 476 764 477 765 "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], 478 766 767 + "style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="], 768 + 769 + "style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="], 770 + 479 771 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 480 772 773 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 774 + 481 775 "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], 482 776 483 777 "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], ··· 491 785 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], 492 786 493 787 "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], 788 + 789 + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], 494 790 495 791 "ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="], 496 792 ··· 500 796 501 797 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], 502 798 799 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 800 + 503 801 "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], 504 802 505 803 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], ··· 510 808 511 809 "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 512 810 811 + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], 812 + 813 + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], 814 + 815 + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], 816 + 817 + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], 818 + 819 + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], 820 + 513 821 "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], 514 822 515 823 "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], ··· 519 827 "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], 520 828 521 829 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], 830 + 831 + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], 832 + 833 + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], 834 + 835 + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 522 836 523 837 "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], 524 838 839 + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 840 + 841 + "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=="], 842 + 843 + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 844 + 525 845 "yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="], 846 + 847 + "zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="], 526 848 527 849 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 850 + 851 + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 528 852 529 853 "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 530 854 ··· 532 856 533 857 "iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 534 858 859 + "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 860 + 861 + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 862 + 535 863 "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 536 864 865 + "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 866 + 537 867 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 538 868 539 869 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 540 870 541 871 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 872 + 873 + "micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 874 + 875 + "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 542 876 } 543 877 }
+428
claude.md
··· 1 + # Wisp.place - Codebase Overview 2 + 3 + **Project URL**: https://wisp.place 4 + 5 + A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution. 6 + 7 + --- 8 + 9 + ## ๐Ÿ—๏ธ Architecture Overview 10 + 11 + ### Multi-Part System 12 + 1. **Main Backend** (`/src`) - OAuth, site management, custom domains 13 + 2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites 14 + 3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS 15 + 4. **Frontend** (`/public`) - React UI for onboarding, editor, admin 16 + 17 + ### Tech Stack 18 + - **Backend**: Elysia (Bun) + TypeScript + PostgreSQL 19 + - **Frontend**: React 19 + Tailwind CSS 4 + Radix UI 20 + - **CLI**: Rust with Jacquard (AT Protocol library) 21 + - **Database**: PostgreSQL for session/domain/site caching 22 + - **AT Protocol**: OAuth 2.0 + custom lexicons for storage 23 + 24 + --- 25 + 26 + ## ๐Ÿ“‚ Directory Structure 27 + 28 + ### `/src` - Main Backend Server 29 + **Purpose**: Core server handling OAuth, site management, custom domains, admin features 30 + 31 + **Key Routes**: 32 + - `/api/auth/*` - OAuth signin/callback/logout/status 33 + - `/api/domain/*` - Custom domain management (BYOD) 34 + - `/wisp/*` - Site upload and management 35 + - `/api/user/*` - User info and site listing 36 + - `/api/admin/*` - Admin console (logs, metrics, DNS verification) 37 + 38 + **Key Files**: 39 + - `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers) 40 + - `lib/oauth-client.ts` - OAuth client setup with session/state persistence 41 + - `lib/db.ts` - PostgreSQL schema and queries for all tables 42 + - `lib/wisp-auth.ts` - Cookie-based authentication middleware 43 + - `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling 44 + - `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache 45 + - `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME) 46 + - `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes 47 + - `lib/admin-auth.ts` - Simple username/password admin authentication 48 + - `lib/observability.ts` - Logging, error tracking, metrics collection 49 + - `routes/auth.ts` - OAuth flow handlers 50 + - `routes/wisp.ts` - File upload and site creation (/wisp/upload-files) 51 + - `routes/domain.ts` - Domain claiming/verification API 52 + - `routes/user.ts` - User status/info/sites listing 53 + - `routes/site.ts` - Site metadata and file retrieval 54 + - `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger) 55 + 56 + ### `/lexicons` & `src/lexicons/` 57 + **Purpose**: AT Protocol Lexicon definitions for custom data types 58 + 59 + **Key File**: `fs.json` - Defines `place.wisp.fs` record format 60 + - **structure**: Virtual filesystem manifest with tree structure 61 + - **site**: string identifier 62 + - **root**: directory object containing entries 63 + - **file**: blob reference + metadata (encoding, mimeType, base64 flag) 64 + - **directory**: array of entries (recursive) 65 + - **entry**: name + node (file or directory) 66 + 67 + **Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing 68 + 69 + ### `/hosting-service` 70 + **Purpose**: Lightweight microservice that serves cached sites from disk 71 + 72 + **Architecture**: 73 + - Routes by domain lookup in PostgreSQL 74 + - Caches site content locally on first access or firehose event 75 + - Listens to AT Protocol firehose for new site records 76 + - Automatically downloads and caches files from PDS 77 + - SSRF-protected fetch (timeout, size limits, private IP blocking) 78 + 79 + **Routes**: 80 + 1. Custom domains (`/*`) โ†’ lookup custom_domains table 81 + 2. Wisp subdomains (`/*.wisp.place/*`) โ†’ lookup domains table 82 + 3. DNS hash routing (`/hash.dns.wisp.place/*`) โ†’ lookup custom_domains by hash 83 + 4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ†’ fetch from PDS if not cached 84 + 85 + **HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`) 86 + 87 + ### `/cli` 88 + **Purpose**: Rust CLI tool for direct site uploads using app password or OAuth 89 + 90 + **Flow**: 91 + 1. Authenticate with handle + app password or OAuth 92 + 2. Walk directory tree, compress files 93 + 3. Upload blobs to PDS via agent 94 + 4. Create place.wisp.fs record with manifest 95 + 5. Store site in database cache 96 + 97 + **Auth Methods**: 98 + - `--password` flag for app password auth 99 + - OAuth loopback server for browser-based auth 100 + - Supports both (password preferred if provided) 101 + 102 + --- 103 + 104 + ## ๐Ÿ” Key Concepts 105 + 106 + ### Custom Domains (BYOD - Bring Your Own Domain) 107 + **Process**: 108 + 1. User claims custom domain via API 109 + 2. System generates hash (SHA256(domain + secret)) 110 + 3. User adds DNS records: 111 + - TXT at `_wisp.example.com` = their DID 112 + - CNAME at `example.com` = `{hash}.dns.wisp.place` 113 + 4. Background worker checks verification every 10 minutes 114 + 5. Once verified, custom domain routes to their hosted sites 115 + 116 + **Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at) 117 + 118 + ### Wisp Subdomains 119 + **Process**: 120 + 1. Handle claimed on first signup (e.g., alice โ†’ alice.wisp.place) 121 + 2. Stored in `domains` table mapping domain โ†’ DID 122 + 3. Served by hosting service 123 + 124 + ### Site Storage 125 + **Locations**: 126 + - **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record 127 + - **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at) 128 + - **File Cache**: Hosting service caches downloaded files on disk 129 + 130 + **Limits**: 131 + - MAX_SITE_SIZE: 300MB total 132 + - MAX_FILE_SIZE: 100MB per file 133 + - MAX_FILE_COUNT: 2000 files 134 + 135 + ### File Compression Strategy 136 + **Why**: Bypass PDS content sniffing issues (was treating HTML as images) 137 + 138 + **Process**: 139 + 1. All files gzip-compressed (level 9) 140 + 2. Compressed content base64-encoded 141 + 3. Uploaded as `application/octet-stream` MIME type 142 + 4. Blob metadata stores original MIME type + encoding flag 143 + 5. Hosting service decompresses on serve 144 + 145 + --- 146 + 147 + ## ๐Ÿ”„ Data Flow 148 + 149 + ### User Registration โ†’ Site Upload 150 + ``` 151 + 1. OAuth signin โ†’ state/session stored in DB 152 + 2. Cookie set with DID 153 + 3. Sync sites from PDS to cache DB 154 + 4. If no sites/domain โ†’ redirect to onboarding 155 + 5. User creates site โ†’ POST /wisp/upload-files 156 + 6. Files compressed, uploaded as blobs 157 + 7. place.wisp.fs record created 158 + 8. Site cached in DB 159 + 9. Hosting service notified via firehose 160 + ``` 161 + 162 + ### Custom Domain Setup 163 + ``` 164 + 1. User claims domain (DB check + allocation) 165 + 2. System generates hash 166 + 3. User adds DNS records (_wisp.domain TXT + CNAME) 167 + 4. Background worker verifies every 10 min 168 + 5. Hosting service routes based on verification status 169 + ``` 170 + 171 + ### Site Access 172 + ``` 173 + Hosting Service: 174 + 1. Request arrives at custom domain or *.wisp.place 175 + 2. Domain lookup in PostgreSQL 176 + 3. Check cache for site files 177 + 4. If not cached: 178 + - Fetch from PDS using DID + rkey 179 + - Decompress files 180 + - Save to disk cache 181 + 5. Serve files (with HTML path rewriting) 182 + ``` 183 + 184 + --- 185 + 186 + ## ๐Ÿ› ๏ธ Important Implementation Details 187 + 188 + ### OAuth Implementation 189 + - **State & Session Storage**: PostgreSQL (with expiration) 190 + - **Key Rotation**: Periodic rotation + expiration cleanup (hourly) 191 + - **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback 192 + - **Session Timeout**: 30 days 193 + - **State Timeout**: 1 hour 194 + 195 + ### Security Headers 196 + - X-Frame-Options: DENY 197 + - X-Content-Type-Options: nosniff 198 + - Strict-Transport-Security: max-age=31536000 199 + - Content-Security-Policy (configured for Elysia + React) 200 + - X-XSS-Protection: 1; mode=block 201 + - Referrer-Policy: strict-origin-when-cross-origin 202 + 203 + ### Admin Authentication 204 + - Simple username/password (hashed with bcrypt) 205 + - Session-based cookie auth (24hr expiration) 206 + - Separate `admin_session` cookie 207 + - Initial setup prompted on startup 208 + 209 + ### Observability 210 + - **Logging**: Structured logging with service tags + event types 211 + - **Error Tracking**: Captures error context (message, stack, etc.) 212 + - **Metrics**: Request counts, latencies, error rates 213 + - **Log Levels**: debug, info, warn, error 214 + - **Collection**: Centralized log collector with in-memory buffer 215 + 216 + --- 217 + 218 + ## ๐Ÿ“ Database Schema 219 + 220 + ### oauth_states 221 + - key (primary key) 222 + - data (JSON) 223 + - created_at, expires_at (timestamps) 224 + 225 + ### oauth_sessions 226 + - sub (primary key - subject/DID) 227 + - data (JSON with OAuth session) 228 + - updated_at, expires_at 229 + 230 + ### oauth_keys 231 + - kid (primary key - key ID) 232 + - jwk (JSON Web Key) 233 + - created_at 234 + 235 + ### domains 236 + - domain (primary key - e.g., alice.wisp.place) 237 + - did (unique - user's DID) 238 + - rkey (optional - record key) 239 + - created_at 240 + 241 + ### custom_domains 242 + - id (primary key - UUID) 243 + - domain (unique - e.g., example.com) 244 + - did (user's DID) 245 + - rkey (optional) 246 + - verified (boolean) 247 + - last_verified_at (timestamp) 248 + - created_at 249 + 250 + ### sites 251 + - id, did, rkey, site_name 252 + - created_at, updated_at 253 + - Indexes on (did), (did, rkey), (rkey) 254 + 255 + ### admin_users 256 + - username (primary key) 257 + - password_hash (bcrypt) 258 + - created_at 259 + 260 + --- 261 + 262 + ## ๐Ÿš€ Key Workflows 263 + 264 + ### Sign In Flow 265 + 1. POST /api/auth/signin with handle 266 + 2. System generates state token 267 + 3. Redirects to PDS OAuth endpoint 268 + 4. PDS redirects back to /api/auth/callback?code=X&state=Y 269 + 5. Validate state (CSRF protection) 270 + 6. Exchange code for session 271 + 7. Store session in DB, set DID cookie 272 + 8. Sync sites from PDS 273 + 9. Redirect to /editor or /onboarding 274 + 275 + ### File Upload Flow 276 + 1. POST /wisp/upload-files with siteName + files 277 + 2. Validate site name (rkey format rules) 278 + 3. For each file: 279 + - Check size limits 280 + - Read as ArrayBuffer 281 + - Gzip compress 282 + - Base64 encode 283 + 4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob() 284 + 5. Create manifest with all blob refs 285 + 6. putRecord() for place.wisp.fs with manifest 286 + 7. Upsert to sites table 287 + 8. Return URI + CID 288 + 289 + ### Domain Verification Flow 290 + 1. POST /api/custom-domains/claim 291 + 2. Generate hash = SHA256(domain + secret) 292 + 3. Store in custom_domains with verified=false 293 + 4. Return hash for user to configure DNS 294 + 5. Background worker periodically: 295 + - Query custom_domains where verified=false 296 + - Verify TXT record at _wisp.domain 297 + - Verify CNAME points to hash.dns.wisp.place 298 + - Update verified flag + last_verified_at 299 + 6. Hosting service routes when verified=true 300 + 301 + --- 302 + 303 + ## ๐ŸŽจ Frontend Structure 304 + 305 + ### `/public` 306 + - **index.tsx** - Landing page with sign-in form 307 + - **editor/editor.tsx** - Site editor/management UI 308 + - **admin/admin.tsx** - Admin dashboard 309 + - **components/ui/** - Reusable components (Button, Card, Dialog, etc.) 310 + - **styles/global.css** - Tailwind + custom styles 311 + 312 + ### Page Flow 313 + 1. `/` - Landing page (sign in / get started) 314 + 2. `/editor` - Main app (requires auth) 315 + 3. `/admin` - Admin console (requires admin auth) 316 + 4. `/onboarding` - First-time user setup 317 + 318 + --- 319 + 320 + ## ๐Ÿ” Notable Implementation Patterns 321 + 322 + ### File Handling 323 + - Files stored as base64-encoded gzip in PDS blobs 324 + - Metadata preserves original MIME type 325 + - Hosting service decompresses on serve 326 + - Workaround for PDS image pipeline issues with HTML 327 + 328 + ### Error Handling 329 + - Comprehensive logging with context 330 + - Graceful degradation (e.g., site sync failure doesn't break auth) 331 + - Structured error responses with details 332 + 333 + ### Performance 334 + - Site sync: Batch fetch up to 100 records per request 335 + - Blob upload: Parallel promises for all files 336 + - DNS verification: Batched background worker (10 min intervals) 337 + - Caching: Two-tier (DB + disk in hosting service) 338 + 339 + ### Validation 340 + - Lexicon validation on manifest creation 341 + - Record type checking 342 + - Domain format validation 343 + - Site name format validation (AT Protocol rkey rules) 344 + - File size limits enforced before upload 345 + 346 + --- 347 + 348 + ## ๐Ÿ› Known Quirks & Workarounds 349 + 350 + 1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content 351 + 352 + 2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains 353 + 354 + 3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories 355 + 356 + 4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed 357 + 358 + 5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently 359 + 360 + --- 361 + 362 + ## ๐Ÿ“‹ Environment Variables 363 + 364 + - `DOMAIN` - Base domain with protocol (default: `https://wisp.place`) 365 + - `CLIENT_NAME` - OAuth client name (default: `PDS-View`) 366 + - `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`) 367 + - `NODE_ENV` - production/development 368 + - `HOSTING_PORT` - Hosting service port (default: 3001) 369 + - `BASE_DOMAIN` - Domain for URLs (default: wisp.place) 370 + 371 + --- 372 + 373 + ## ๐Ÿง‘โ€๐Ÿ’ป Development Notes 374 + 375 + ### Adding New Features 376 + 1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts 377 + 2. **DB changes**: Add migration in db.ts 378 + 3. **New lexicons**: Update `/lexicons/*.json`, regenerate types 379 + 4. **Admin features**: Add to /api/admin endpoints 380 + 381 + ### Testing 382 + - Run with `bun test` 383 + - CSRF tests in lib/csrf.test.ts 384 + - Utility tests in lib/wisp-utils.test.ts 385 + 386 + ### Debugging 387 + - Check logs via `/api/admin/logs` (requires admin auth) 388 + - DNS verification manual trigger: POST /api/admin/verify-dns 389 + - Health check: GET /api/health (includes DNS verifier status) 390 + 391 + --- 392 + 393 + ## ๐Ÿš€ Deployment Considerations 394 + 395 + 1. **Secrets**: Admin password, OAuth keys, database credentials 396 + 2. **HTTPS**: Required (HSTS header enforces it) 397 + 3. **CDN**: Custom domains require DNS configuration 398 + 4. **Scaling**: 399 + - Main server: Horizontal scaling with session DB 400 + - Hosting service: Independent scaling, disk cache per instance 401 + 5. **Backups**: PostgreSQL database critical; firehose provides recovery 402 + 403 + --- 404 + 405 + ## ๐Ÿ“š Related Technologies 406 + 407 + - **AT Protocol**: Decentralized identity, OAuth 2.0 408 + - **Jacquard**: Rust library for AT Protocol interactions 409 + - **Elysia**: Bun web framework (similar to Express/Hono) 410 + - **Lexicon**: AT Protocol's schema definition language 411 + - **Firehose**: Real-time event stream of repo changes 412 + - **PDS**: Personal Data Server (where users' data stored) 413 + 414 + --- 415 + 416 + ## ๐ŸŽฏ Project Goals 417 + 418 + โœ… Decentralized site hosting (data owned by users) 419 + โœ… Custom domain support with DNS verification 420 + โœ… Fast CDN distribution via hosting service 421 + โœ… Developer tools (CLI + API) 422 + โœ… Admin dashboard for monitoring 423 + โœ… Zero user data retention (sites in PDS, sessions in DB only) 424 + 425 + --- 426 + 427 + **Last Updated**: November 2025 428 + **Status**: Active development
+24
cli/.gitignore
··· 1 + .DS_STORE 2 + jacquard/ 3 + binaries/ 4 + # Generated by Cargo 5 + # will have compiled files and executables 6 + debug 7 + target 8 + 9 + # These are backup files generated by rustfmt 10 + **/*.rs.bk 11 + 12 + # MSVC Windows builds of rustc generate these, which store debugging information 13 + *.pdb 14 + 15 + # Generated by cargo mutants 16 + # Contains mutation testing data 17 + **/mutants.out*/ 18 + 19 + # RustRover 20 + # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 21 + # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 22 + # and can be added to the global gitignore or merged into this file. For a more nuclear 23 + # option (not recommended) you can uncomment the following to ignore the entire idea folder. 24 + #.idea/
+4530
cli/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "abnf" 7 + version = "0.13.0" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "087113bd50d9adce24850eed5d0476c7d199d532fce8fab5173650331e09033a" 10 + dependencies = [ 11 + "abnf-core", 12 + "nom", 13 + ] 14 + 15 + [[package]] 16 + name = "abnf-core" 17 + version = "0.5.0" 18 + source = "registry+https://github.com/rust-lang/crates.io-index" 19 + checksum = "c44e09c43ae1c368fb91a03a566472d0087c26cf7e1b9e8e289c14ede681dd7d" 20 + dependencies = [ 21 + "nom", 22 + ] 23 + 24 + [[package]] 25 + name = "addr2line" 26 + version = "0.25.1" 27 + source = "registry+https://github.com/rust-lang/crates.io-index" 28 + checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" 29 + dependencies = [ 30 + "gimli", 31 + ] 32 + 33 + [[package]] 34 + name = "adler2" 35 + version = "2.0.1" 36 + source = "registry+https://github.com/rust-lang/crates.io-index" 37 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 38 + 39 + [[package]] 40 + name = "adler32" 41 + version = "1.2.0" 42 + source = "registry+https://github.com/rust-lang/crates.io-index" 43 + checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 44 + 45 + [[package]] 46 + name = "aho-corasick" 47 + version = "1.1.4" 48 + source = "registry+https://github.com/rust-lang/crates.io-index" 49 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 50 + dependencies = [ 51 + "memchr", 52 + ] 53 + 54 + [[package]] 55 + name = "aliasable" 56 + version = "0.1.3" 57 + source = "registry+https://github.com/rust-lang/crates.io-index" 58 + checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 59 + 60 + [[package]] 61 + name = "alloc-no-stdlib" 62 + version = "2.0.4" 63 + source = "registry+https://github.com/rust-lang/crates.io-index" 64 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 65 + 66 + [[package]] 67 + name = "alloc-stdlib" 68 + version = "0.2.2" 69 + source = "registry+https://github.com/rust-lang/crates.io-index" 70 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 71 + dependencies = [ 72 + "alloc-no-stdlib", 73 + ] 74 + 75 + [[package]] 76 + name = "android_system_properties" 77 + version = "0.1.5" 78 + source = "registry+https://github.com/rust-lang/crates.io-index" 79 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 80 + dependencies = [ 81 + "libc", 82 + ] 83 + 84 + [[package]] 85 + name = "anstream" 86 + version = "0.6.21" 87 + source = "registry+https://github.com/rust-lang/crates.io-index" 88 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 89 + dependencies = [ 90 + "anstyle", 91 + "anstyle-parse", 92 + "anstyle-query", 93 + "anstyle-wincon", 94 + "colorchoice", 95 + "is_terminal_polyfill", 96 + "utf8parse", 97 + ] 98 + 99 + [[package]] 100 + name = "anstyle" 101 + version = "1.0.13" 102 + source = "registry+https://github.com/rust-lang/crates.io-index" 103 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 104 + 105 + [[package]] 106 + name = "anstyle-parse" 107 + version = "0.2.7" 108 + source = "registry+https://github.com/rust-lang/crates.io-index" 109 + checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 110 + dependencies = [ 111 + "utf8parse", 112 + ] 113 + 114 + [[package]] 115 + name = "anstyle-query" 116 + version = "1.1.4" 117 + source = "registry+https://github.com/rust-lang/crates.io-index" 118 + checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 119 + dependencies = [ 120 + "windows-sys 0.60.2", 121 + ] 122 + 123 + [[package]] 124 + name = "anstyle-wincon" 125 + version = "3.0.10" 126 + source = "registry+https://github.com/rust-lang/crates.io-index" 127 + checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 128 + dependencies = [ 129 + "anstyle", 130 + "once_cell_polyfill", 131 + "windows-sys 0.60.2", 132 + ] 133 + 134 + [[package]] 135 + name = "ascii" 136 + version = "1.1.0" 137 + source = "registry+https://github.com/rust-lang/crates.io-index" 138 + checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 139 + 140 + [[package]] 141 + name = "async-compression" 142 + version = "0.4.32" 143 + source = "registry+https://github.com/rust-lang/crates.io-index" 144 + checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" 145 + dependencies = [ 146 + "compression-codecs", 147 + "compression-core", 148 + "futures-core", 149 + "pin-project-lite", 150 + "tokio", 151 + ] 152 + 153 + [[package]] 154 + name = "async-trait" 155 + version = "0.1.89" 156 + source = "registry+https://github.com/rust-lang/crates.io-index" 157 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 158 + dependencies = [ 159 + "proc-macro2", 160 + "quote", 161 + "syn 2.0.108", 162 + ] 163 + 164 + [[package]] 165 + name = "atomic-waker" 166 + version = "1.1.2" 167 + source = "registry+https://github.com/rust-lang/crates.io-index" 168 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 169 + 170 + [[package]] 171 + name = "autocfg" 172 + version = "1.5.0" 173 + source = "registry+https://github.com/rust-lang/crates.io-index" 174 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 175 + 176 + [[package]] 177 + name = "backtrace" 178 + version = "0.3.76" 179 + source = "registry+https://github.com/rust-lang/crates.io-index" 180 + checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" 181 + dependencies = [ 182 + "addr2line", 183 + "cfg-if", 184 + "libc", 185 + "miniz_oxide", 186 + "object", 187 + "rustc-demangle", 188 + "windows-link 0.2.1", 189 + ] 190 + 191 + [[package]] 192 + name = "backtrace-ext" 193 + version = "0.2.1" 194 + source = "registry+https://github.com/rust-lang/crates.io-index" 195 + checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" 196 + dependencies = [ 197 + "backtrace", 198 + ] 199 + 200 + [[package]] 201 + name = "base-x" 202 + version = "0.2.11" 203 + source = "registry+https://github.com/rust-lang/crates.io-index" 204 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 205 + 206 + [[package]] 207 + name = "base16ct" 208 + version = "0.2.0" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 211 + 212 + [[package]] 213 + name = "base256emoji" 214 + version = "1.0.2" 215 + source = "registry+https://github.com/rust-lang/crates.io-index" 216 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 217 + dependencies = [ 218 + "const-str", 219 + "match-lookup", 220 + ] 221 + 222 + [[package]] 223 + name = "base64" 224 + version = "0.13.1" 225 + source = "registry+https://github.com/rust-lang/crates.io-index" 226 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 227 + 228 + [[package]] 229 + name = "base64" 230 + version = "0.22.1" 231 + source = "registry+https://github.com/rust-lang/crates.io-index" 232 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 233 + 234 + [[package]] 235 + name = "base64ct" 236 + version = "1.8.0" 237 + source = "registry+https://github.com/rust-lang/crates.io-index" 238 + checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 239 + 240 + [[package]] 241 + name = "bitflags" 242 + version = "2.10.0" 243 + source = "registry+https://github.com/rust-lang/crates.io-index" 244 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 245 + 246 + [[package]] 247 + name = "block-buffer" 248 + version = "0.10.4" 249 + source = "registry+https://github.com/rust-lang/crates.io-index" 250 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 251 + dependencies = [ 252 + "generic-array", 253 + ] 254 + 255 + [[package]] 256 + name = "bon" 257 + version = "3.8.1" 258 + source = "registry+https://github.com/rust-lang/crates.io-index" 259 + checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" 260 + dependencies = [ 261 + "bon-macros", 262 + "rustversion", 263 + ] 264 + 265 + [[package]] 266 + name = "bon-macros" 267 + version = "3.8.1" 268 + source = "registry+https://github.com/rust-lang/crates.io-index" 269 + checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 270 + dependencies = [ 271 + "darling", 272 + "ident_case", 273 + "prettyplease", 274 + "proc-macro2", 275 + "quote", 276 + "rustversion", 277 + "syn 2.0.108", 278 + ] 279 + 280 + [[package]] 281 + name = "borsh" 282 + version = "1.5.7" 283 + source = "registry+https://github.com/rust-lang/crates.io-index" 284 + checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" 285 + dependencies = [ 286 + "cfg_aliases", 287 + ] 288 + 289 + [[package]] 290 + name = "brotli" 291 + version = "3.5.0" 292 + source = "registry+https://github.com/rust-lang/crates.io-index" 293 + checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" 294 + dependencies = [ 295 + "alloc-no-stdlib", 296 + "alloc-stdlib", 297 + "brotli-decompressor", 298 + ] 299 + 300 + [[package]] 301 + name = "brotli-decompressor" 302 + version = "2.5.1" 303 + source = "registry+https://github.com/rust-lang/crates.io-index" 304 + checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" 305 + dependencies = [ 306 + "alloc-no-stdlib", 307 + "alloc-stdlib", 308 + ] 309 + 310 + [[package]] 311 + name = "btree-range-map" 312 + version = "0.7.2" 313 + source = "registry+https://github.com/rust-lang/crates.io-index" 314 + checksum = "1be5c9672446d3800bcbcaabaeba121fe22f1fb25700c4562b22faf76d377c33" 315 + dependencies = [ 316 + "btree-slab", 317 + "cc-traits", 318 + "range-traits", 319 + "serde", 320 + "slab", 321 + ] 322 + 323 + [[package]] 324 + name = "btree-slab" 325 + version = "0.6.1" 326 + source = "registry+https://github.com/rust-lang/crates.io-index" 327 + checksum = "7a2b56d3029f075c4fa892428a098425b86cef5c89ae54073137ece416aef13c" 328 + dependencies = [ 329 + "cc-traits", 330 + "slab", 331 + "smallvec", 332 + ] 333 + 334 + [[package]] 335 + name = "buf_redux" 336 + version = "0.8.4" 337 + source = "registry+https://github.com/rust-lang/crates.io-index" 338 + checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" 339 + dependencies = [ 340 + "memchr", 341 + "safemem", 342 + ] 343 + 344 + [[package]] 345 + name = "bumpalo" 346 + version = "3.19.0" 347 + source = "registry+https://github.com/rust-lang/crates.io-index" 348 + checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 349 + 350 + [[package]] 351 + name = "bytes" 352 + version = "1.10.1" 353 + source = "registry+https://github.com/rust-lang/crates.io-index" 354 + checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 355 + dependencies = [ 356 + "serde", 357 + ] 358 + 359 + [[package]] 360 + name = "cbor4ii" 361 + version = "0.2.14" 362 + source = "registry+https://github.com/rust-lang/crates.io-index" 363 + checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" 364 + dependencies = [ 365 + "serde", 366 + ] 367 + 368 + [[package]] 369 + name = "cc" 370 + version = "1.2.44" 371 + source = "registry+https://github.com/rust-lang/crates.io-index" 372 + checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" 373 + dependencies = [ 374 + "find-msvc-tools", 375 + "shlex", 376 + ] 377 + 378 + [[package]] 379 + name = "cc-traits" 380 + version = "2.0.0" 381 + source = "registry+https://github.com/rust-lang/crates.io-index" 382 + checksum = "060303ef31ef4a522737e1b1ab68c67916f2a787bb2f4f54f383279adba962b5" 383 + dependencies = [ 384 + "slab", 385 + ] 386 + 387 + [[package]] 388 + name = "cesu8" 389 + version = "1.1.0" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 392 + 393 + [[package]] 394 + name = "cfg-if" 395 + version = "1.0.4" 396 + source = "registry+https://github.com/rust-lang/crates.io-index" 397 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 398 + 399 + [[package]] 400 + name = "cfg_aliases" 401 + version = "0.2.1" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 404 + 405 + [[package]] 406 + name = "chrono" 407 + version = "0.4.42" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 410 + dependencies = [ 411 + "iana-time-zone", 412 + "js-sys", 413 + "num-traits", 414 + "serde", 415 + "wasm-bindgen", 416 + "windows-link 0.2.1", 417 + ] 418 + 419 + [[package]] 420 + name = "chunked_transfer" 421 + version = "1.5.0" 422 + source = "registry+https://github.com/rust-lang/crates.io-index" 423 + checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" 424 + 425 + [[package]] 426 + name = "ciborium" 427 + version = "0.2.2" 428 + source = "registry+https://github.com/rust-lang/crates.io-index" 429 + checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 430 + dependencies = [ 431 + "ciborium-io", 432 + "ciborium-ll", 433 + "serde", 434 + ] 435 + 436 + [[package]] 437 + name = "ciborium-io" 438 + version = "0.2.2" 439 + source = "registry+https://github.com/rust-lang/crates.io-index" 440 + checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 441 + 442 + [[package]] 443 + name = "ciborium-ll" 444 + version = "0.2.2" 445 + source = "registry+https://github.com/rust-lang/crates.io-index" 446 + checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 447 + dependencies = [ 448 + "ciborium-io", 449 + "half", 450 + ] 451 + 452 + [[package]] 453 + name = "cid" 454 + version = "0.11.1" 455 + source = "registry+https://github.com/rust-lang/crates.io-index" 456 + checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 457 + dependencies = [ 458 + "core2", 459 + "multibase", 460 + "multihash", 461 + "serde", 462 + "serde_bytes", 463 + "unsigned-varint", 464 + ] 465 + 466 + [[package]] 467 + name = "clap" 468 + version = "4.5.51" 469 + source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 471 + dependencies = [ 472 + "clap_builder", 473 + "clap_derive", 474 + ] 475 + 476 + [[package]] 477 + name = "clap_builder" 478 + version = "4.5.51" 479 + source = "registry+https://github.com/rust-lang/crates.io-index" 480 + checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 481 + dependencies = [ 482 + "anstream", 483 + "anstyle", 484 + "clap_lex", 485 + "strsim", 486 + ] 487 + 488 + [[package]] 489 + name = "clap_derive" 490 + version = "4.5.49" 491 + source = "registry+https://github.com/rust-lang/crates.io-index" 492 + checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 493 + dependencies = [ 494 + "heck 0.5.0", 495 + "proc-macro2", 496 + "quote", 497 + "syn 2.0.108", 498 + ] 499 + 500 + [[package]] 501 + name = "clap_lex" 502 + version = "0.7.6" 503 + source = "registry+https://github.com/rust-lang/crates.io-index" 504 + checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 505 + 506 + [[package]] 507 + name = "colorchoice" 508 + version = "1.0.4" 509 + source = "registry+https://github.com/rust-lang/crates.io-index" 510 + checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 511 + 512 + [[package]] 513 + name = "combine" 514 + version = "4.6.7" 515 + source = "registry+https://github.com/rust-lang/crates.io-index" 516 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 517 + dependencies = [ 518 + "bytes", 519 + "memchr", 520 + ] 521 + 522 + [[package]] 523 + name = "compression-codecs" 524 + version = "0.4.31" 525 + source = "registry+https://github.com/rust-lang/crates.io-index" 526 + checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" 527 + dependencies = [ 528 + "compression-core", 529 + "flate2", 530 + "memchr", 531 + ] 532 + 533 + [[package]] 534 + name = "compression-core" 535 + version = "0.4.29" 536 + source = "registry+https://github.com/rust-lang/crates.io-index" 537 + checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" 538 + 539 + [[package]] 540 + name = "const-oid" 541 + version = "0.9.6" 542 + source = "registry+https://github.com/rust-lang/crates.io-index" 543 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 544 + 545 + [[package]] 546 + name = "const-str" 547 + version = "0.4.3" 548 + source = "registry+https://github.com/rust-lang/crates.io-index" 549 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 550 + 551 + [[package]] 552 + name = "core-foundation" 553 + version = "0.9.4" 554 + source = "registry+https://github.com/rust-lang/crates.io-index" 555 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 556 + dependencies = [ 557 + "core-foundation-sys", 558 + "libc", 559 + ] 560 + 561 + [[package]] 562 + name = "core-foundation" 563 + version = "0.10.1" 564 + source = "registry+https://github.com/rust-lang/crates.io-index" 565 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 566 + dependencies = [ 567 + "core-foundation-sys", 568 + "libc", 569 + ] 570 + 571 + [[package]] 572 + name = "core-foundation-sys" 573 + version = "0.8.7" 574 + source = "registry+https://github.com/rust-lang/crates.io-index" 575 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 576 + 577 + [[package]] 578 + name = "core2" 579 + version = "0.4.0" 580 + source = "registry+https://github.com/rust-lang/crates.io-index" 581 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 582 + dependencies = [ 583 + "memchr", 584 + ] 585 + 586 + [[package]] 587 + name = "cpufeatures" 588 + version = "0.2.17" 589 + source = "registry+https://github.com/rust-lang/crates.io-index" 590 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 591 + dependencies = [ 592 + "libc", 593 + ] 594 + 595 + [[package]] 596 + name = "crc32fast" 597 + version = "1.5.0" 598 + source = "registry+https://github.com/rust-lang/crates.io-index" 599 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 600 + dependencies = [ 601 + "cfg-if", 602 + ] 603 + 604 + [[package]] 605 + name = "crossbeam-channel" 606 + version = "0.5.15" 607 + source = "registry+https://github.com/rust-lang/crates.io-index" 608 + checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 609 + dependencies = [ 610 + "crossbeam-utils", 611 + ] 612 + 613 + [[package]] 614 + name = "crossbeam-utils" 615 + version = "0.8.21" 616 + source = "registry+https://github.com/rust-lang/crates.io-index" 617 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 618 + 619 + [[package]] 620 + name = "crunchy" 621 + version = "0.2.4" 622 + source = "registry+https://github.com/rust-lang/crates.io-index" 623 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 624 + 625 + [[package]] 626 + name = "crypto-bigint" 627 + version = "0.5.5" 628 + source = "registry+https://github.com/rust-lang/crates.io-index" 629 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 630 + dependencies = [ 631 + "generic-array", 632 + "rand_core 0.6.4", 633 + "subtle", 634 + "zeroize", 635 + ] 636 + 637 + [[package]] 638 + name = "crypto-common" 639 + version = "0.1.6" 640 + source = "registry+https://github.com/rust-lang/crates.io-index" 641 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 642 + dependencies = [ 643 + "generic-array", 644 + "typenum", 645 + ] 646 + 647 + [[package]] 648 + name = "darling" 649 + version = "0.21.3" 650 + source = "registry+https://github.com/rust-lang/crates.io-index" 651 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 652 + dependencies = [ 653 + "darling_core", 654 + "darling_macro", 655 + ] 656 + 657 + [[package]] 658 + name = "darling_core" 659 + version = "0.21.3" 660 + source = "registry+https://github.com/rust-lang/crates.io-index" 661 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 662 + dependencies = [ 663 + "fnv", 664 + "ident_case", 665 + "proc-macro2", 666 + "quote", 667 + "strsim", 668 + "syn 2.0.108", 669 + ] 670 + 671 + [[package]] 672 + name = "darling_macro" 673 + version = "0.21.3" 674 + source = "registry+https://github.com/rust-lang/crates.io-index" 675 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 676 + dependencies = [ 677 + "darling_core", 678 + "quote", 679 + "syn 2.0.108", 680 + ] 681 + 682 + [[package]] 683 + name = "dashmap" 684 + version = "6.1.0" 685 + source = "registry+https://github.com/rust-lang/crates.io-index" 686 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 687 + dependencies = [ 688 + "cfg-if", 689 + "crossbeam-utils", 690 + "hashbrown 0.14.5", 691 + "lock_api", 692 + "once_cell", 693 + "parking_lot_core", 694 + ] 695 + 696 + [[package]] 697 + name = "data-encoding" 698 + version = "2.9.0" 699 + source = "registry+https://github.com/rust-lang/crates.io-index" 700 + checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 701 + 702 + [[package]] 703 + name = "data-encoding-macro" 704 + version = "0.1.18" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" 707 + dependencies = [ 708 + "data-encoding", 709 + "data-encoding-macro-internal", 710 + ] 711 + 712 + [[package]] 713 + name = "data-encoding-macro-internal" 714 + version = "0.1.16" 715 + source = "registry+https://github.com/rust-lang/crates.io-index" 716 + checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 717 + dependencies = [ 718 + "data-encoding", 719 + "syn 2.0.108", 720 + ] 721 + 722 + [[package]] 723 + name = "deflate" 724 + version = "1.0.0" 725 + source = "registry+https://github.com/rust-lang/crates.io-index" 726 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 727 + dependencies = [ 728 + "adler32", 729 + "gzip-header", 730 + ] 731 + 732 + [[package]] 733 + name = "der" 734 + version = "0.7.10" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 737 + dependencies = [ 738 + "const-oid", 739 + "pem-rfc7468", 740 + "zeroize", 741 + ] 742 + 743 + [[package]] 744 + name = "deranged" 745 + version = "0.5.5" 746 + source = "registry+https://github.com/rust-lang/crates.io-index" 747 + checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" 748 + dependencies = [ 749 + "powerfmt", 750 + "serde_core", 751 + ] 752 + 753 + [[package]] 754 + name = "digest" 755 + version = "0.10.7" 756 + source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 758 + dependencies = [ 759 + "block-buffer", 760 + "const-oid", 761 + "crypto-common", 762 + "subtle", 763 + ] 764 + 765 + [[package]] 766 + name = "dirs" 767 + version = "6.0.0" 768 + source = "registry+https://github.com/rust-lang/crates.io-index" 769 + checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 770 + dependencies = [ 771 + "dirs-sys", 772 + ] 773 + 774 + [[package]] 775 + name = "dirs-sys" 776 + version = "0.5.0" 777 + source = "registry+https://github.com/rust-lang/crates.io-index" 778 + checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 779 + dependencies = [ 780 + "libc", 781 + "option-ext", 782 + "redox_users", 783 + "windows-sys 0.61.2", 784 + ] 785 + 786 + [[package]] 787 + name = "displaydoc" 788 + version = "0.2.5" 789 + source = "registry+https://github.com/rust-lang/crates.io-index" 790 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 791 + dependencies = [ 792 + "proc-macro2", 793 + "quote", 794 + "syn 2.0.108", 795 + ] 796 + 797 + [[package]] 798 + name = "dyn-clone" 799 + version = "1.0.20" 800 + source = "registry+https://github.com/rust-lang/crates.io-index" 801 + checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 802 + 803 + [[package]] 804 + name = "ecdsa" 805 + version = "0.16.9" 806 + source = "registry+https://github.com/rust-lang/crates.io-index" 807 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 808 + dependencies = [ 809 + "der", 810 + "digest", 811 + "elliptic-curve", 812 + "rfc6979", 813 + "signature", 814 + "spki", 815 + ] 816 + 817 + [[package]] 818 + name = "elliptic-curve" 819 + version = "0.13.8" 820 + source = "registry+https://github.com/rust-lang/crates.io-index" 821 + checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 822 + dependencies = [ 823 + "base16ct", 824 + "crypto-bigint", 825 + "digest", 826 + "ff", 827 + "generic-array", 828 + "group", 829 + "pem-rfc7468", 830 + "pkcs8", 831 + "rand_core 0.6.4", 832 + "sec1", 833 + "subtle", 834 + "zeroize", 835 + ] 836 + 837 + [[package]] 838 + name = "encoding_rs" 839 + version = "0.8.35" 840 + source = "registry+https://github.com/rust-lang/crates.io-index" 841 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 842 + dependencies = [ 843 + "cfg-if", 844 + ] 845 + 846 + [[package]] 847 + name = "enum-as-inner" 848 + version = "0.6.1" 849 + source = "registry+https://github.com/rust-lang/crates.io-index" 850 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 851 + dependencies = [ 852 + "heck 0.5.0", 853 + "proc-macro2", 854 + "quote", 855 + "syn 2.0.108", 856 + ] 857 + 858 + [[package]] 859 + name = "equivalent" 860 + version = "1.0.2" 861 + source = "registry+https://github.com/rust-lang/crates.io-index" 862 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 863 + 864 + [[package]] 865 + name = "errno" 866 + version = "0.3.14" 867 + source = "registry+https://github.com/rust-lang/crates.io-index" 868 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 869 + dependencies = [ 870 + "libc", 871 + "windows-sys 0.61.2", 872 + ] 873 + 874 + [[package]] 875 + name = "fastrand" 876 + version = "2.3.0" 877 + source = "registry+https://github.com/rust-lang/crates.io-index" 878 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 879 + 880 + [[package]] 881 + name = "ff" 882 + version = "0.13.1" 883 + source = "registry+https://github.com/rust-lang/crates.io-index" 884 + checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 885 + dependencies = [ 886 + "rand_core 0.6.4", 887 + "subtle", 888 + ] 889 + 890 + [[package]] 891 + name = "filetime" 892 + version = "0.2.26" 893 + source = "registry+https://github.com/rust-lang/crates.io-index" 894 + checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" 895 + dependencies = [ 896 + "cfg-if", 897 + "libc", 898 + "libredox", 899 + "windows-sys 0.60.2", 900 + ] 901 + 902 + [[package]] 903 + name = "find-msvc-tools" 904 + version = "0.1.4" 905 + source = "registry+https://github.com/rust-lang/crates.io-index" 906 + checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 907 + 908 + [[package]] 909 + name = "flate2" 910 + version = "1.1.5" 911 + source = "registry+https://github.com/rust-lang/crates.io-index" 912 + checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 913 + dependencies = [ 914 + "crc32fast", 915 + "miniz_oxide", 916 + ] 917 + 918 + [[package]] 919 + name = "fnv" 920 + version = "1.0.7" 921 + source = "registry+https://github.com/rust-lang/crates.io-index" 922 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 923 + 924 + [[package]] 925 + name = "form_urlencoded" 926 + version = "1.2.2" 927 + source = "registry+https://github.com/rust-lang/crates.io-index" 928 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 929 + dependencies = [ 930 + "percent-encoding", 931 + ] 932 + 933 + [[package]] 934 + name = "futf" 935 + version = "0.1.5" 936 + source = "registry+https://github.com/rust-lang/crates.io-index" 937 + checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" 938 + dependencies = [ 939 + "mac", 940 + "new_debug_unreachable", 941 + ] 942 + 943 + [[package]] 944 + name = "futures" 945 + version = "0.3.31" 946 + source = "registry+https://github.com/rust-lang/crates.io-index" 947 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 948 + dependencies = [ 949 + "futures-channel", 950 + "futures-core", 951 + "futures-executor", 952 + "futures-io", 953 + "futures-sink", 954 + "futures-task", 955 + "futures-util", 956 + ] 957 + 958 + [[package]] 959 + name = "futures-channel" 960 + version = "0.3.31" 961 + source = "registry+https://github.com/rust-lang/crates.io-index" 962 + checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 963 + dependencies = [ 964 + "futures-core", 965 + "futures-sink", 966 + ] 967 + 968 + [[package]] 969 + name = "futures-core" 970 + version = "0.3.31" 971 + source = "registry+https://github.com/rust-lang/crates.io-index" 972 + checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 973 + 974 + [[package]] 975 + name = "futures-executor" 976 + version = "0.3.31" 977 + source = "registry+https://github.com/rust-lang/crates.io-index" 978 + checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" 979 + dependencies = [ 980 + "futures-core", 981 + "futures-task", 982 + "futures-util", 983 + ] 984 + 985 + [[package]] 986 + name = "futures-io" 987 + version = "0.3.31" 988 + source = "registry+https://github.com/rust-lang/crates.io-index" 989 + checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 990 + 991 + [[package]] 992 + name = "futures-macro" 993 + version = "0.3.31" 994 + source = "registry+https://github.com/rust-lang/crates.io-index" 995 + checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 996 + dependencies = [ 997 + "proc-macro2", 998 + "quote", 999 + "syn 2.0.108", 1000 + ] 1001 + 1002 + [[package]] 1003 + name = "futures-sink" 1004 + version = "0.3.31" 1005 + source = "registry+https://github.com/rust-lang/crates.io-index" 1006 + checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 1007 + 1008 + [[package]] 1009 + name = "futures-task" 1010 + version = "0.3.31" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 1013 + 1014 + [[package]] 1015 + name = "futures-util" 1016 + version = "0.3.31" 1017 + source = "registry+https://github.com/rust-lang/crates.io-index" 1018 + checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 1019 + dependencies = [ 1020 + "futures-channel", 1021 + "futures-core", 1022 + "futures-io", 1023 + "futures-macro", 1024 + "futures-sink", 1025 + "futures-task", 1026 + "memchr", 1027 + "pin-project-lite", 1028 + "pin-utils", 1029 + "slab", 1030 + ] 1031 + 1032 + [[package]] 1033 + name = "generic-array" 1034 + version = "0.14.9" 1035 + source = "registry+https://github.com/rust-lang/crates.io-index" 1036 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 1037 + dependencies = [ 1038 + "typenum", 1039 + "version_check", 1040 + "zeroize", 1041 + ] 1042 + 1043 + [[package]] 1044 + name = "getrandom" 1045 + version = "0.2.16" 1046 + source = "registry+https://github.com/rust-lang/crates.io-index" 1047 + checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1048 + dependencies = [ 1049 + "cfg-if", 1050 + "js-sys", 1051 + "libc", 1052 + "wasi", 1053 + "wasm-bindgen", 1054 + ] 1055 + 1056 + [[package]] 1057 + name = "getrandom" 1058 + version = "0.3.4" 1059 + source = "registry+https://github.com/rust-lang/crates.io-index" 1060 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1061 + dependencies = [ 1062 + "cfg-if", 1063 + "js-sys", 1064 + "libc", 1065 + "r-efi", 1066 + "wasip2", 1067 + "wasm-bindgen", 1068 + ] 1069 + 1070 + [[package]] 1071 + name = "gimli" 1072 + version = "0.32.3" 1073 + source = "registry+https://github.com/rust-lang/crates.io-index" 1074 + checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" 1075 + 1076 + [[package]] 1077 + name = "group" 1078 + version = "0.13.0" 1079 + source = "registry+https://github.com/rust-lang/crates.io-index" 1080 + checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1081 + dependencies = [ 1082 + "ff", 1083 + "rand_core 0.6.4", 1084 + "subtle", 1085 + ] 1086 + 1087 + [[package]] 1088 + name = "gzip-header" 1089 + version = "1.0.0" 1090 + source = "registry+https://github.com/rust-lang/crates.io-index" 1091 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1092 + dependencies = [ 1093 + "crc32fast", 1094 + ] 1095 + 1096 + [[package]] 1097 + name = "h2" 1098 + version = "0.4.12" 1099 + source = "registry+https://github.com/rust-lang/crates.io-index" 1100 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1101 + dependencies = [ 1102 + "atomic-waker", 1103 + "bytes", 1104 + "fnv", 1105 + "futures-core", 1106 + "futures-sink", 1107 + "http", 1108 + "indexmap 2.12.0", 1109 + "slab", 1110 + "tokio", 1111 + "tokio-util", 1112 + "tracing", 1113 + ] 1114 + 1115 + [[package]] 1116 + name = "half" 1117 + version = "2.7.1" 1118 + source = "registry+https://github.com/rust-lang/crates.io-index" 1119 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 1120 + dependencies = [ 1121 + "cfg-if", 1122 + "crunchy", 1123 + "zerocopy", 1124 + ] 1125 + 1126 + [[package]] 1127 + name = "hashbrown" 1128 + version = "0.12.3" 1129 + source = "registry+https://github.com/rust-lang/crates.io-index" 1130 + checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 1131 + 1132 + [[package]] 1133 + name = "hashbrown" 1134 + version = "0.14.5" 1135 + source = "registry+https://github.com/rust-lang/crates.io-index" 1136 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1137 + 1138 + [[package]] 1139 + name = "hashbrown" 1140 + version = "0.16.0" 1141 + source = "registry+https://github.com/rust-lang/crates.io-index" 1142 + checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1143 + 1144 + [[package]] 1145 + name = "heck" 1146 + version = "0.4.1" 1147 + source = "registry+https://github.com/rust-lang/crates.io-index" 1148 + checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 1149 + 1150 + [[package]] 1151 + name = "heck" 1152 + version = "0.5.0" 1153 + source = "registry+https://github.com/rust-lang/crates.io-index" 1154 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1155 + 1156 + [[package]] 1157 + name = "hermit-abi" 1158 + version = "0.5.2" 1159 + source = "registry+https://github.com/rust-lang/crates.io-index" 1160 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1161 + 1162 + [[package]] 1163 + name = "hex" 1164 + version = "0.4.3" 1165 + source = "registry+https://github.com/rust-lang/crates.io-index" 1166 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1167 + 1168 + [[package]] 1169 + name = "hex_fmt" 1170 + version = "0.3.0" 1171 + source = "registry+https://github.com/rust-lang/crates.io-index" 1172 + checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" 1173 + 1174 + [[package]] 1175 + name = "hickory-proto" 1176 + version = "0.24.4" 1177 + source = "registry+https://github.com/rust-lang/crates.io-index" 1178 + checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1179 + dependencies = [ 1180 + "async-trait", 1181 + "cfg-if", 1182 + "data-encoding", 1183 + "enum-as-inner", 1184 + "futures-channel", 1185 + "futures-io", 1186 + "futures-util", 1187 + "idna", 1188 + "ipnet", 1189 + "once_cell", 1190 + "rand 0.8.5", 1191 + "thiserror 1.0.69", 1192 + "tinyvec", 1193 + "tokio", 1194 + "tracing", 1195 + "url", 1196 + ] 1197 + 1198 + [[package]] 1199 + name = "hickory-resolver" 1200 + version = "0.24.4" 1201 + source = "registry+https://github.com/rust-lang/crates.io-index" 1202 + checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1203 + dependencies = [ 1204 + "cfg-if", 1205 + "futures-util", 1206 + "hickory-proto", 1207 + "ipconfig", 1208 + "lru-cache", 1209 + "once_cell", 1210 + "parking_lot", 1211 + "rand 0.8.5", 1212 + "resolv-conf", 1213 + "smallvec", 1214 + "thiserror 1.0.69", 1215 + "tokio", 1216 + "tracing", 1217 + ] 1218 + 1219 + [[package]] 1220 + name = "hmac" 1221 + version = "0.12.1" 1222 + source = "registry+https://github.com/rust-lang/crates.io-index" 1223 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1224 + dependencies = [ 1225 + "digest", 1226 + ] 1227 + 1228 + [[package]] 1229 + name = "html5ever" 1230 + version = "0.27.0" 1231 + source = "registry+https://github.com/rust-lang/crates.io-index" 1232 + checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" 1233 + dependencies = [ 1234 + "log", 1235 + "mac", 1236 + "markup5ever", 1237 + "proc-macro2", 1238 + "quote", 1239 + "syn 2.0.108", 1240 + ] 1241 + 1242 + [[package]] 1243 + name = "http" 1244 + version = "1.3.1" 1245 + source = "registry+https://github.com/rust-lang/crates.io-index" 1246 + checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 1247 + dependencies = [ 1248 + "bytes", 1249 + "fnv", 1250 + "itoa", 1251 + ] 1252 + 1253 + [[package]] 1254 + name = "http-body" 1255 + version = "1.0.1" 1256 + source = "registry+https://github.com/rust-lang/crates.io-index" 1257 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 1258 + dependencies = [ 1259 + "bytes", 1260 + "http", 1261 + ] 1262 + 1263 + [[package]] 1264 + name = "http-body-util" 1265 + version = "0.1.3" 1266 + source = "registry+https://github.com/rust-lang/crates.io-index" 1267 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 1268 + dependencies = [ 1269 + "bytes", 1270 + "futures-core", 1271 + "http", 1272 + "http-body", 1273 + "pin-project-lite", 1274 + ] 1275 + 1276 + [[package]] 1277 + name = "httparse" 1278 + version = "1.10.1" 1279 + source = "registry+https://github.com/rust-lang/crates.io-index" 1280 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1281 + 1282 + [[package]] 1283 + name = "httpdate" 1284 + version = "1.0.3" 1285 + source = "registry+https://github.com/rust-lang/crates.io-index" 1286 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1287 + 1288 + [[package]] 1289 + name = "hyper" 1290 + version = "1.7.0" 1291 + source = "registry+https://github.com/rust-lang/crates.io-index" 1292 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1293 + dependencies = [ 1294 + "atomic-waker", 1295 + "bytes", 1296 + "futures-channel", 1297 + "futures-core", 1298 + "h2", 1299 + "http", 1300 + "http-body", 1301 + "httparse", 1302 + "itoa", 1303 + "pin-project-lite", 1304 + "pin-utils", 1305 + "smallvec", 1306 + "tokio", 1307 + "want", 1308 + ] 1309 + 1310 + [[package]] 1311 + name = "hyper-rustls" 1312 + version = "0.27.7" 1313 + source = "registry+https://github.com/rust-lang/crates.io-index" 1314 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1315 + dependencies = [ 1316 + "http", 1317 + "hyper", 1318 + "hyper-util", 1319 + "rustls", 1320 + "rustls-pki-types", 1321 + "tokio", 1322 + "tokio-rustls", 1323 + "tower-service", 1324 + "webpki-roots", 1325 + ] 1326 + 1327 + [[package]] 1328 + name = "hyper-util" 1329 + version = "0.1.17" 1330 + source = "registry+https://github.com/rust-lang/crates.io-index" 1331 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1332 + dependencies = [ 1333 + "base64 0.22.1", 1334 + "bytes", 1335 + "futures-channel", 1336 + "futures-core", 1337 + "futures-util", 1338 + "http", 1339 + "http-body", 1340 + "hyper", 1341 + "ipnet", 1342 + "libc", 1343 + "percent-encoding", 1344 + "pin-project-lite", 1345 + "socket2 0.6.1", 1346 + "system-configuration", 1347 + "tokio", 1348 + "tower-service", 1349 + "tracing", 1350 + "windows-registry", 1351 + ] 1352 + 1353 + [[package]] 1354 + name = "iana-time-zone" 1355 + version = "0.1.64" 1356 + source = "registry+https://github.com/rust-lang/crates.io-index" 1357 + checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 1358 + dependencies = [ 1359 + "android_system_properties", 1360 + "core-foundation-sys", 1361 + "iana-time-zone-haiku", 1362 + "js-sys", 1363 + "log", 1364 + "wasm-bindgen", 1365 + "windows-core", 1366 + ] 1367 + 1368 + [[package]] 1369 + name = "iana-time-zone-haiku" 1370 + version = "0.1.2" 1371 + source = "registry+https://github.com/rust-lang/crates.io-index" 1372 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1373 + dependencies = [ 1374 + "cc", 1375 + ] 1376 + 1377 + [[package]] 1378 + name = "icu_collections" 1379 + version = "2.1.1" 1380 + source = "registry+https://github.com/rust-lang/crates.io-index" 1381 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 1382 + dependencies = [ 1383 + "displaydoc", 1384 + "potential_utf", 1385 + "yoke", 1386 + "zerofrom", 1387 + "zerovec", 1388 + ] 1389 + 1390 + [[package]] 1391 + name = "icu_locale_core" 1392 + version = "2.1.1" 1393 + source = "registry+https://github.com/rust-lang/crates.io-index" 1394 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 1395 + dependencies = [ 1396 + "displaydoc", 1397 + "litemap", 1398 + "tinystr", 1399 + "writeable", 1400 + "zerovec", 1401 + ] 1402 + 1403 + [[package]] 1404 + name = "icu_normalizer" 1405 + version = "2.1.1" 1406 + source = "registry+https://github.com/rust-lang/crates.io-index" 1407 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 1408 + dependencies = [ 1409 + "icu_collections", 1410 + "icu_normalizer_data", 1411 + "icu_properties", 1412 + "icu_provider", 1413 + "smallvec", 1414 + "zerovec", 1415 + ] 1416 + 1417 + [[package]] 1418 + name = "icu_normalizer_data" 1419 + version = "2.1.1" 1420 + source = "registry+https://github.com/rust-lang/crates.io-index" 1421 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 1422 + 1423 + [[package]] 1424 + name = "icu_properties" 1425 + version = "2.1.1" 1426 + source = "registry+https://github.com/rust-lang/crates.io-index" 1427 + checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" 1428 + dependencies = [ 1429 + "icu_collections", 1430 + "icu_locale_core", 1431 + "icu_properties_data", 1432 + "icu_provider", 1433 + "zerotrie", 1434 + "zerovec", 1435 + ] 1436 + 1437 + [[package]] 1438 + name = "icu_properties_data" 1439 + version = "2.1.1" 1440 + source = "registry+https://github.com/rust-lang/crates.io-index" 1441 + checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" 1442 + 1443 + [[package]] 1444 + name = "icu_provider" 1445 + version = "2.1.1" 1446 + source = "registry+https://github.com/rust-lang/crates.io-index" 1447 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 1448 + dependencies = [ 1449 + "displaydoc", 1450 + "icu_locale_core", 1451 + "writeable", 1452 + "yoke", 1453 + "zerofrom", 1454 + "zerotrie", 1455 + "zerovec", 1456 + ] 1457 + 1458 + [[package]] 1459 + name = "ident_case" 1460 + version = "1.0.1" 1461 + source = "registry+https://github.com/rust-lang/crates.io-index" 1462 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1463 + 1464 + [[package]] 1465 + name = "idna" 1466 + version = "1.1.0" 1467 + source = "registry+https://github.com/rust-lang/crates.io-index" 1468 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1469 + dependencies = [ 1470 + "idna_adapter", 1471 + "smallvec", 1472 + "utf8_iter", 1473 + ] 1474 + 1475 + [[package]] 1476 + name = "idna_adapter" 1477 + version = "1.2.1" 1478 + source = "registry+https://github.com/rust-lang/crates.io-index" 1479 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 1480 + dependencies = [ 1481 + "icu_normalizer", 1482 + "icu_properties", 1483 + ] 1484 + 1485 + [[package]] 1486 + name = "indexmap" 1487 + version = "1.9.3" 1488 + source = "registry+https://github.com/rust-lang/crates.io-index" 1489 + checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 1490 + dependencies = [ 1491 + "autocfg", 1492 + "hashbrown 0.12.3", 1493 + "serde", 1494 + ] 1495 + 1496 + [[package]] 1497 + name = "indexmap" 1498 + version = "2.12.0" 1499 + source = "registry+https://github.com/rust-lang/crates.io-index" 1500 + checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" 1501 + dependencies = [ 1502 + "equivalent", 1503 + "hashbrown 0.16.0", 1504 + "serde", 1505 + "serde_core", 1506 + ] 1507 + 1508 + [[package]] 1509 + name = "indoc" 1510 + version = "2.0.7" 1511 + source = "registry+https://github.com/rust-lang/crates.io-index" 1512 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" 1513 + dependencies = [ 1514 + "rustversion", 1515 + ] 1516 + 1517 + [[package]] 1518 + name = "inventory" 1519 + version = "0.3.21" 1520 + source = "registry+https://github.com/rust-lang/crates.io-index" 1521 + checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" 1522 + dependencies = [ 1523 + "rustversion", 1524 + ] 1525 + 1526 + [[package]] 1527 + name = "ipconfig" 1528 + version = "0.3.2" 1529 + source = "registry+https://github.com/rust-lang/crates.io-index" 1530 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1531 + dependencies = [ 1532 + "socket2 0.5.10", 1533 + "widestring", 1534 + "windows-sys 0.48.0", 1535 + "winreg", 1536 + ] 1537 + 1538 + [[package]] 1539 + name = "ipld-core" 1540 + version = "0.4.2" 1541 + source = "registry+https://github.com/rust-lang/crates.io-index" 1542 + checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db" 1543 + dependencies = [ 1544 + "cid", 1545 + "serde", 1546 + "serde_bytes", 1547 + ] 1548 + 1549 + [[package]] 1550 + name = "ipnet" 1551 + version = "2.11.0" 1552 + source = "registry+https://github.com/rust-lang/crates.io-index" 1553 + checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 1554 + 1555 + [[package]] 1556 + name = "iri-string" 1557 + version = "0.7.8" 1558 + source = "registry+https://github.com/rust-lang/crates.io-index" 1559 + checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1560 + dependencies = [ 1561 + "memchr", 1562 + "serde", 1563 + ] 1564 + 1565 + [[package]] 1566 + name = "is_ci" 1567 + version = "1.2.0" 1568 + source = "registry+https://github.com/rust-lang/crates.io-index" 1569 + checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" 1570 + 1571 + [[package]] 1572 + name = "is_terminal_polyfill" 1573 + version = "1.70.2" 1574 + source = "registry+https://github.com/rust-lang/crates.io-index" 1575 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1576 + 1577 + [[package]] 1578 + name = "itoa" 1579 + version = "1.0.15" 1580 + source = "registry+https://github.com/rust-lang/crates.io-index" 1581 + checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1582 + 1583 + [[package]] 1584 + name = "jacquard" 1585 + version = "0.9.0" 1586 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1587 + dependencies = [ 1588 + "bytes", 1589 + "getrandom 0.2.16", 1590 + "http", 1591 + "jacquard-api", 1592 + "jacquard-common", 1593 + "jacquard-derive", 1594 + "jacquard-identity", 1595 + "jacquard-oauth", 1596 + "jose-jwk", 1597 + "miette", 1598 + "regex", 1599 + "reqwest", 1600 + "serde", 1601 + "serde_html_form", 1602 + "serde_json", 1603 + "smol_str", 1604 + "thiserror 2.0.17", 1605 + "tokio", 1606 + "trait-variant", 1607 + "url", 1608 + "webpage", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "jacquard-api" 1613 + version = "0.9.0" 1614 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1615 + dependencies = [ 1616 + "bon", 1617 + "bytes", 1618 + "jacquard-common", 1619 + "jacquard-derive", 1620 + "jacquard-lexicon", 1621 + "miette", 1622 + "rustversion", 1623 + "serde", 1624 + "serde_ipld_dagcbor", 1625 + "thiserror 2.0.17", 1626 + "unicode-segmentation", 1627 + ] 1628 + 1629 + [[package]] 1630 + name = "jacquard-common" 1631 + version = "0.9.0" 1632 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1633 + dependencies = [ 1634 + "base64 0.22.1", 1635 + "bon", 1636 + "bytes", 1637 + "chrono", 1638 + "cid", 1639 + "getrandom 0.2.16", 1640 + "getrandom 0.3.4", 1641 + "http", 1642 + "ipld-core", 1643 + "k256", 1644 + "langtag", 1645 + "miette", 1646 + "multibase", 1647 + "multihash", 1648 + "ouroboros", 1649 + "p256", 1650 + "rand 0.9.2", 1651 + "regex", 1652 + "reqwest", 1653 + "serde", 1654 + "serde_html_form", 1655 + "serde_ipld_dagcbor", 1656 + "serde_json", 1657 + "signature", 1658 + "smol_str", 1659 + "thiserror 2.0.17", 1660 + "tokio", 1661 + "tokio-util", 1662 + "trait-variant", 1663 + "url", 1664 + ] 1665 + 1666 + [[package]] 1667 + name = "jacquard-derive" 1668 + version = "0.9.0" 1669 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1670 + dependencies = [ 1671 + "heck 0.5.0", 1672 + "jacquard-lexicon", 1673 + "proc-macro2", 1674 + "quote", 1675 + "syn 2.0.108", 1676 + ] 1677 + 1678 + [[package]] 1679 + name = "jacquard-identity" 1680 + version = "0.9.1" 1681 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1682 + dependencies = [ 1683 + "bon", 1684 + "bytes", 1685 + "hickory-resolver", 1686 + "http", 1687 + "jacquard-api", 1688 + "jacquard-common", 1689 + "jacquard-lexicon", 1690 + "miette", 1691 + "mini-moka", 1692 + "percent-encoding", 1693 + "reqwest", 1694 + "serde", 1695 + "serde_html_form", 1696 + "serde_json", 1697 + "thiserror 2.0.17", 1698 + "tokio", 1699 + "trait-variant", 1700 + "url", 1701 + "urlencoding", 1702 + ] 1703 + 1704 + [[package]] 1705 + name = "jacquard-lexicon" 1706 + version = "0.9.1" 1707 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1708 + dependencies = [ 1709 + "cid", 1710 + "dashmap", 1711 + "heck 0.5.0", 1712 + "inventory", 1713 + "jacquard-common", 1714 + "miette", 1715 + "multihash", 1716 + "prettyplease", 1717 + "proc-macro2", 1718 + "quote", 1719 + "serde", 1720 + "serde_ipld_dagcbor", 1721 + "serde_json", 1722 + "serde_repr", 1723 + "serde_with", 1724 + "sha2", 1725 + "syn 2.0.108", 1726 + "thiserror 2.0.17", 1727 + "unicode-segmentation", 1728 + ] 1729 + 1730 + [[package]] 1731 + name = "jacquard-oauth" 1732 + version = "0.9.0" 1733 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a" 1734 + dependencies = [ 1735 + "base64 0.22.1", 1736 + "bytes", 1737 + "chrono", 1738 + "dashmap", 1739 + "elliptic-curve", 1740 + "http", 1741 + "jacquard-common", 1742 + "jacquard-identity", 1743 + "jose-jwa", 1744 + "jose-jwk", 1745 + "miette", 1746 + "p256", 1747 + "rand 0.8.5", 1748 + "rouille", 1749 + "serde", 1750 + "serde_html_form", 1751 + "serde_json", 1752 + "sha2", 1753 + "signature", 1754 + "smol_str", 1755 + "thiserror 2.0.17", 1756 + "tokio", 1757 + "trait-variant", 1758 + "url", 1759 + "webbrowser", 1760 + ] 1761 + 1762 + [[package]] 1763 + name = "jni" 1764 + version = "0.21.1" 1765 + source = "registry+https://github.com/rust-lang/crates.io-index" 1766 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 1767 + dependencies = [ 1768 + "cesu8", 1769 + "cfg-if", 1770 + "combine", 1771 + "jni-sys", 1772 + "log", 1773 + "thiserror 1.0.69", 1774 + "walkdir", 1775 + "windows-sys 0.45.0", 1776 + ] 1777 + 1778 + [[package]] 1779 + name = "jni-sys" 1780 + version = "0.3.0" 1781 + source = "registry+https://github.com/rust-lang/crates.io-index" 1782 + checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 1783 + 1784 + [[package]] 1785 + name = "jose-b64" 1786 + version = "0.1.2" 1787 + source = "registry+https://github.com/rust-lang/crates.io-index" 1788 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 1789 + dependencies = [ 1790 + "base64ct", 1791 + "serde", 1792 + "subtle", 1793 + "zeroize", 1794 + ] 1795 + 1796 + [[package]] 1797 + name = "jose-jwa" 1798 + version = "0.1.2" 1799 + source = "registry+https://github.com/rust-lang/crates.io-index" 1800 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 1801 + dependencies = [ 1802 + "serde", 1803 + ] 1804 + 1805 + [[package]] 1806 + name = "jose-jwk" 1807 + version = "0.1.2" 1808 + source = "registry+https://github.com/rust-lang/crates.io-index" 1809 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 1810 + dependencies = [ 1811 + "jose-b64", 1812 + "jose-jwa", 1813 + "p256", 1814 + "p384", 1815 + "rsa", 1816 + "serde", 1817 + "zeroize", 1818 + ] 1819 + 1820 + [[package]] 1821 + name = "js-sys" 1822 + version = "0.3.82" 1823 + source = "registry+https://github.com/rust-lang/crates.io-index" 1824 + checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 1825 + dependencies = [ 1826 + "once_cell", 1827 + "wasm-bindgen", 1828 + ] 1829 + 1830 + [[package]] 1831 + name = "k256" 1832 + version = "0.13.4" 1833 + source = "registry+https://github.com/rust-lang/crates.io-index" 1834 + checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" 1835 + dependencies = [ 1836 + "cfg-if", 1837 + "ecdsa", 1838 + "elliptic-curve", 1839 + "sha2", 1840 + ] 1841 + 1842 + [[package]] 1843 + name = "langtag" 1844 + version = "0.4.0" 1845 + source = "registry+https://github.com/rust-lang/crates.io-index" 1846 + checksum = "9ecb4c689a30e48ebeaa14237f34037e300dd072e6ad21a9ec72e810ff3c6600" 1847 + dependencies = [ 1848 + "serde", 1849 + "static-regular-grammar", 1850 + "thiserror 1.0.69", 1851 + ] 1852 + 1853 + [[package]] 1854 + name = "lazy_static" 1855 + version = "1.5.0" 1856 + source = "registry+https://github.com/rust-lang/crates.io-index" 1857 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1858 + dependencies = [ 1859 + "spin", 1860 + ] 1861 + 1862 + [[package]] 1863 + name = "libc" 1864 + version = "0.2.177" 1865 + source = "registry+https://github.com/rust-lang/crates.io-index" 1866 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 1867 + 1868 + [[package]] 1869 + name = "libm" 1870 + version = "0.2.15" 1871 + source = "registry+https://github.com/rust-lang/crates.io-index" 1872 + checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" 1873 + 1874 + [[package]] 1875 + name = "libredox" 1876 + version = "0.1.10" 1877 + source = "registry+https://github.com/rust-lang/crates.io-index" 1878 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 1879 + dependencies = [ 1880 + "bitflags", 1881 + "libc", 1882 + "redox_syscall", 1883 + ] 1884 + 1885 + [[package]] 1886 + name = "linked-hash-map" 1887 + version = "0.5.6" 1888 + source = "registry+https://github.com/rust-lang/crates.io-index" 1889 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 1890 + 1891 + [[package]] 1892 + name = "linux-raw-sys" 1893 + version = "0.11.0" 1894 + source = "registry+https://github.com/rust-lang/crates.io-index" 1895 + checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 1896 + 1897 + [[package]] 1898 + name = "litemap" 1899 + version = "0.8.1" 1900 + source = "registry+https://github.com/rust-lang/crates.io-index" 1901 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 1902 + 1903 + [[package]] 1904 + name = "lock_api" 1905 + version = "0.4.14" 1906 + source = "registry+https://github.com/rust-lang/crates.io-index" 1907 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1908 + dependencies = [ 1909 + "scopeguard", 1910 + ] 1911 + 1912 + [[package]] 1913 + name = "log" 1914 + version = "0.4.28" 1915 + source = "registry+https://github.com/rust-lang/crates.io-index" 1916 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 1917 + 1918 + [[package]] 1919 + name = "lru-cache" 1920 + version = "0.1.2" 1921 + source = "registry+https://github.com/rust-lang/crates.io-index" 1922 + checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 1923 + dependencies = [ 1924 + "linked-hash-map", 1925 + ] 1926 + 1927 + [[package]] 1928 + name = "lru-slab" 1929 + version = "0.1.2" 1930 + source = "registry+https://github.com/rust-lang/crates.io-index" 1931 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1932 + 1933 + [[package]] 1934 + name = "mac" 1935 + version = "0.1.1" 1936 + source = "registry+https://github.com/rust-lang/crates.io-index" 1937 + checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 1938 + 1939 + [[package]] 1940 + name = "markup5ever" 1941 + version = "0.12.1" 1942 + source = "registry+https://github.com/rust-lang/crates.io-index" 1943 + checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" 1944 + dependencies = [ 1945 + "log", 1946 + "phf", 1947 + "phf_codegen", 1948 + "string_cache", 1949 + "string_cache_codegen", 1950 + "tendril", 1951 + ] 1952 + 1953 + [[package]] 1954 + name = "markup5ever_rcdom" 1955 + version = "0.3.0" 1956 + source = "registry+https://github.com/rust-lang/crates.io-index" 1957 + checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" 1958 + dependencies = [ 1959 + "html5ever", 1960 + "markup5ever", 1961 + "tendril", 1962 + "xml5ever", 1963 + ] 1964 + 1965 + [[package]] 1966 + name = "match-lookup" 1967 + version = "0.1.1" 1968 + source = "registry+https://github.com/rust-lang/crates.io-index" 1969 + checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" 1970 + dependencies = [ 1971 + "proc-macro2", 1972 + "quote", 1973 + "syn 1.0.109", 1974 + ] 1975 + 1976 + [[package]] 1977 + name = "memchr" 1978 + version = "2.7.6" 1979 + source = "registry+https://github.com/rust-lang/crates.io-index" 1980 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1981 + 1982 + [[package]] 1983 + name = "miette" 1984 + version = "7.6.0" 1985 + source = "registry+https://github.com/rust-lang/crates.io-index" 1986 + checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" 1987 + dependencies = [ 1988 + "backtrace", 1989 + "backtrace-ext", 1990 + "cfg-if", 1991 + "miette-derive", 1992 + "owo-colors", 1993 + "supports-color", 1994 + "supports-hyperlinks", 1995 + "supports-unicode", 1996 + "terminal_size", 1997 + "textwrap", 1998 + "unicode-width 0.1.14", 1999 + ] 2000 + 2001 + [[package]] 2002 + name = "miette-derive" 2003 + version = "7.6.0" 2004 + source = "registry+https://github.com/rust-lang/crates.io-index" 2005 + checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" 2006 + dependencies = [ 2007 + "proc-macro2", 2008 + "quote", 2009 + "syn 2.0.108", 2010 + ] 2011 + 2012 + [[package]] 2013 + name = "mime" 2014 + version = "0.3.17" 2015 + source = "registry+https://github.com/rust-lang/crates.io-index" 2016 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2017 + 2018 + [[package]] 2019 + name = "mime_guess" 2020 + version = "2.0.5" 2021 + source = "registry+https://github.com/rust-lang/crates.io-index" 2022 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 2023 + dependencies = [ 2024 + "mime", 2025 + "unicase", 2026 + ] 2027 + 2028 + [[package]] 2029 + name = "mini-moka" 2030 + version = "0.11.0" 2031 + source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" 2032 + dependencies = [ 2033 + "crossbeam-channel", 2034 + "crossbeam-utils", 2035 + "dashmap", 2036 + "smallvec", 2037 + "tagptr", 2038 + "triomphe", 2039 + "web-time", 2040 + ] 2041 + 2042 + [[package]] 2043 + name = "minimal-lexical" 2044 + version = "0.2.1" 2045 + source = "registry+https://github.com/rust-lang/crates.io-index" 2046 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 2047 + 2048 + [[package]] 2049 + name = "miniz_oxide" 2050 + version = "0.8.9" 2051 + source = "registry+https://github.com/rust-lang/crates.io-index" 2052 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 2053 + dependencies = [ 2054 + "adler2", 2055 + "simd-adler32", 2056 + ] 2057 + 2058 + [[package]] 2059 + name = "mio" 2060 + version = "1.1.0" 2061 + source = "registry+https://github.com/rust-lang/crates.io-index" 2062 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 2063 + dependencies = [ 2064 + "libc", 2065 + "wasi", 2066 + "windows-sys 0.61.2", 2067 + ] 2068 + 2069 + [[package]] 2070 + name = "multibase" 2071 + version = "0.9.2" 2072 + source = "registry+https://github.com/rust-lang/crates.io-index" 2073 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 2074 + dependencies = [ 2075 + "base-x", 2076 + "base256emoji", 2077 + "data-encoding", 2078 + "data-encoding-macro", 2079 + ] 2080 + 2081 + [[package]] 2082 + name = "multihash" 2083 + version = "0.19.3" 2084 + source = "registry+https://github.com/rust-lang/crates.io-index" 2085 + checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 2086 + dependencies = [ 2087 + "core2", 2088 + "serde", 2089 + "unsigned-varint", 2090 + ] 2091 + 2092 + [[package]] 2093 + name = "multipart" 2094 + version = "0.18.0" 2095 + source = "registry+https://github.com/rust-lang/crates.io-index" 2096 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 2097 + dependencies = [ 2098 + "buf_redux", 2099 + "httparse", 2100 + "log", 2101 + "mime", 2102 + "mime_guess", 2103 + "quick-error", 2104 + "rand 0.8.5", 2105 + "safemem", 2106 + "tempfile", 2107 + "twoway", 2108 + ] 2109 + 2110 + [[package]] 2111 + name = "ndk-context" 2112 + version = "0.1.1" 2113 + source = "registry+https://github.com/rust-lang/crates.io-index" 2114 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 2115 + 2116 + [[package]] 2117 + name = "new_debug_unreachable" 2118 + version = "1.0.6" 2119 + source = "registry+https://github.com/rust-lang/crates.io-index" 2120 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 2121 + 2122 + [[package]] 2123 + name = "nom" 2124 + version = "7.1.3" 2125 + source = "registry+https://github.com/rust-lang/crates.io-index" 2126 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 2127 + dependencies = [ 2128 + "memchr", 2129 + "minimal-lexical", 2130 + ] 2131 + 2132 + [[package]] 2133 + name = "num-bigint-dig" 2134 + version = "0.8.5" 2135 + source = "registry+https://github.com/rust-lang/crates.io-index" 2136 + checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" 2137 + dependencies = [ 2138 + "lazy_static", 2139 + "libm", 2140 + "num-integer", 2141 + "num-iter", 2142 + "num-traits", 2143 + "rand 0.8.5", 2144 + "smallvec", 2145 + "zeroize", 2146 + ] 2147 + 2148 + [[package]] 2149 + name = "num-conv" 2150 + version = "0.1.0" 2151 + source = "registry+https://github.com/rust-lang/crates.io-index" 2152 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 2153 + 2154 + [[package]] 2155 + name = "num-integer" 2156 + version = "0.1.46" 2157 + source = "registry+https://github.com/rust-lang/crates.io-index" 2158 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 2159 + dependencies = [ 2160 + "num-traits", 2161 + ] 2162 + 2163 + [[package]] 2164 + name = "num-iter" 2165 + version = "0.1.45" 2166 + source = "registry+https://github.com/rust-lang/crates.io-index" 2167 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 2168 + dependencies = [ 2169 + "autocfg", 2170 + "num-integer", 2171 + "num-traits", 2172 + ] 2173 + 2174 + [[package]] 2175 + name = "num-traits" 2176 + version = "0.2.19" 2177 + source = "registry+https://github.com/rust-lang/crates.io-index" 2178 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 2179 + dependencies = [ 2180 + "autocfg", 2181 + "libm", 2182 + ] 2183 + 2184 + [[package]] 2185 + name = "num_cpus" 2186 + version = "1.17.0" 2187 + source = "registry+https://github.com/rust-lang/crates.io-index" 2188 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 2189 + dependencies = [ 2190 + "hermit-abi", 2191 + "libc", 2192 + ] 2193 + 2194 + [[package]] 2195 + name = "num_threads" 2196 + version = "0.1.7" 2197 + source = "registry+https://github.com/rust-lang/crates.io-index" 2198 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 2199 + dependencies = [ 2200 + "libc", 2201 + ] 2202 + 2203 + [[package]] 2204 + name = "objc2" 2205 + version = "0.6.3" 2206 + source = "registry+https://github.com/rust-lang/crates.io-index" 2207 + checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" 2208 + dependencies = [ 2209 + "objc2-encode", 2210 + ] 2211 + 2212 + [[package]] 2213 + name = "objc2-encode" 2214 + version = "4.1.0" 2215 + source = "registry+https://github.com/rust-lang/crates.io-index" 2216 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 2217 + 2218 + [[package]] 2219 + name = "objc2-foundation" 2220 + version = "0.3.2" 2221 + source = "registry+https://github.com/rust-lang/crates.io-index" 2222 + checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 2223 + dependencies = [ 2224 + "bitflags", 2225 + "objc2", 2226 + ] 2227 + 2228 + [[package]] 2229 + name = "object" 2230 + version = "0.37.3" 2231 + source = "registry+https://github.com/rust-lang/crates.io-index" 2232 + checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" 2233 + dependencies = [ 2234 + "memchr", 2235 + ] 2236 + 2237 + [[package]] 2238 + name = "once_cell" 2239 + version = "1.21.3" 2240 + source = "registry+https://github.com/rust-lang/crates.io-index" 2241 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 2242 + 2243 + [[package]] 2244 + name = "once_cell_polyfill" 2245 + version = "1.70.2" 2246 + source = "registry+https://github.com/rust-lang/crates.io-index" 2247 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2248 + 2249 + [[package]] 2250 + name = "option-ext" 2251 + version = "0.2.0" 2252 + source = "registry+https://github.com/rust-lang/crates.io-index" 2253 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 2254 + 2255 + [[package]] 2256 + name = "ouroboros" 2257 + version = "0.18.5" 2258 + source = "registry+https://github.com/rust-lang/crates.io-index" 2259 + checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" 2260 + dependencies = [ 2261 + "aliasable", 2262 + "ouroboros_macro", 2263 + "static_assertions", 2264 + ] 2265 + 2266 + [[package]] 2267 + name = "ouroboros_macro" 2268 + version = "0.18.5" 2269 + source = "registry+https://github.com/rust-lang/crates.io-index" 2270 + checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" 2271 + dependencies = [ 2272 + "heck 0.4.1", 2273 + "proc-macro2", 2274 + "proc-macro2-diagnostics", 2275 + "quote", 2276 + "syn 2.0.108", 2277 + ] 2278 + 2279 + [[package]] 2280 + name = "owo-colors" 2281 + version = "4.2.3" 2282 + source = "registry+https://github.com/rust-lang/crates.io-index" 2283 + checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" 2284 + 2285 + [[package]] 2286 + name = "p256" 2287 + version = "0.13.2" 2288 + source = "registry+https://github.com/rust-lang/crates.io-index" 2289 + checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2290 + dependencies = [ 2291 + "ecdsa", 2292 + "elliptic-curve", 2293 + "primeorder", 2294 + "sha2", 2295 + ] 2296 + 2297 + [[package]] 2298 + name = "p384" 2299 + version = "0.13.1" 2300 + source = "registry+https://github.com/rust-lang/crates.io-index" 2301 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2302 + dependencies = [ 2303 + "elliptic-curve", 2304 + "primeorder", 2305 + ] 2306 + 2307 + [[package]] 2308 + name = "parking_lot" 2309 + version = "0.12.5" 2310 + source = "registry+https://github.com/rust-lang/crates.io-index" 2311 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 2312 + dependencies = [ 2313 + "lock_api", 2314 + "parking_lot_core", 2315 + ] 2316 + 2317 + [[package]] 2318 + name = "parking_lot_core" 2319 + version = "0.9.12" 2320 + source = "registry+https://github.com/rust-lang/crates.io-index" 2321 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 2322 + dependencies = [ 2323 + "cfg-if", 2324 + "libc", 2325 + "redox_syscall", 2326 + "smallvec", 2327 + "windows-link 0.2.1", 2328 + ] 2329 + 2330 + [[package]] 2331 + name = "pem-rfc7468" 2332 + version = "0.7.0" 2333 + source = "registry+https://github.com/rust-lang/crates.io-index" 2334 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 2335 + dependencies = [ 2336 + "base64ct", 2337 + ] 2338 + 2339 + [[package]] 2340 + name = "percent-encoding" 2341 + version = "2.3.2" 2342 + source = "registry+https://github.com/rust-lang/crates.io-index" 2343 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 2344 + 2345 + [[package]] 2346 + name = "phf" 2347 + version = "0.11.3" 2348 + source = "registry+https://github.com/rust-lang/crates.io-index" 2349 + checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 2350 + dependencies = [ 2351 + "phf_shared", 2352 + ] 2353 + 2354 + [[package]] 2355 + name = "phf_codegen" 2356 + version = "0.11.3" 2357 + source = "registry+https://github.com/rust-lang/crates.io-index" 2358 + checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" 2359 + dependencies = [ 2360 + "phf_generator", 2361 + "phf_shared", 2362 + ] 2363 + 2364 + [[package]] 2365 + name = "phf_generator" 2366 + version = "0.11.3" 2367 + source = "registry+https://github.com/rust-lang/crates.io-index" 2368 + checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 2369 + dependencies = [ 2370 + "phf_shared", 2371 + "rand 0.8.5", 2372 + ] 2373 + 2374 + [[package]] 2375 + name = "phf_shared" 2376 + version = "0.11.3" 2377 + source = "registry+https://github.com/rust-lang/crates.io-index" 2378 + checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 2379 + dependencies = [ 2380 + "siphasher", 2381 + ] 2382 + 2383 + [[package]] 2384 + name = "pin-project-lite" 2385 + version = "0.2.16" 2386 + source = "registry+https://github.com/rust-lang/crates.io-index" 2387 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 2388 + 2389 + [[package]] 2390 + name = "pin-utils" 2391 + version = "0.1.0" 2392 + source = "registry+https://github.com/rust-lang/crates.io-index" 2393 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2394 + 2395 + [[package]] 2396 + name = "pkcs1" 2397 + version = "0.7.5" 2398 + source = "registry+https://github.com/rust-lang/crates.io-index" 2399 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 2400 + dependencies = [ 2401 + "der", 2402 + "pkcs8", 2403 + "spki", 2404 + ] 2405 + 2406 + [[package]] 2407 + name = "pkcs8" 2408 + version = "0.10.2" 2409 + source = "registry+https://github.com/rust-lang/crates.io-index" 2410 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 2411 + dependencies = [ 2412 + "der", 2413 + "spki", 2414 + ] 2415 + 2416 + [[package]] 2417 + name = "potential_utf" 2418 + version = "0.1.4" 2419 + source = "registry+https://github.com/rust-lang/crates.io-index" 2420 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 2421 + dependencies = [ 2422 + "zerovec", 2423 + ] 2424 + 2425 + [[package]] 2426 + name = "powerfmt" 2427 + version = "0.2.0" 2428 + source = "registry+https://github.com/rust-lang/crates.io-index" 2429 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 2430 + 2431 + [[package]] 2432 + name = "ppv-lite86" 2433 + version = "0.2.21" 2434 + source = "registry+https://github.com/rust-lang/crates.io-index" 2435 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 2436 + dependencies = [ 2437 + "zerocopy", 2438 + ] 2439 + 2440 + [[package]] 2441 + name = "precomputed-hash" 2442 + version = "0.1.1" 2443 + source = "registry+https://github.com/rust-lang/crates.io-index" 2444 + checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 2445 + 2446 + [[package]] 2447 + name = "prettyplease" 2448 + version = "0.2.37" 2449 + source = "registry+https://github.com/rust-lang/crates.io-index" 2450 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2451 + dependencies = [ 2452 + "proc-macro2", 2453 + "syn 2.0.108", 2454 + ] 2455 + 2456 + [[package]] 2457 + name = "primeorder" 2458 + version = "0.13.6" 2459 + source = "registry+https://github.com/rust-lang/crates.io-index" 2460 + checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 2461 + dependencies = [ 2462 + "elliptic-curve", 2463 + ] 2464 + 2465 + [[package]] 2466 + name = "proc-macro-error" 2467 + version = "1.0.4" 2468 + source = "registry+https://github.com/rust-lang/crates.io-index" 2469 + checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 2470 + dependencies = [ 2471 + "proc-macro-error-attr", 2472 + "proc-macro2", 2473 + "quote", 2474 + "syn 1.0.109", 2475 + "version_check", 2476 + ] 2477 + 2478 + [[package]] 2479 + name = "proc-macro-error-attr" 2480 + version = "1.0.4" 2481 + source = "registry+https://github.com/rust-lang/crates.io-index" 2482 + checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 2483 + dependencies = [ 2484 + "proc-macro2", 2485 + "quote", 2486 + "version_check", 2487 + ] 2488 + 2489 + [[package]] 2490 + name = "proc-macro2" 2491 + version = "1.0.103" 2492 + source = "registry+https://github.com/rust-lang/crates.io-index" 2493 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 2494 + dependencies = [ 2495 + "unicode-ident", 2496 + ] 2497 + 2498 + [[package]] 2499 + name = "proc-macro2-diagnostics" 2500 + version = "0.10.1" 2501 + source = "registry+https://github.com/rust-lang/crates.io-index" 2502 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 2503 + dependencies = [ 2504 + "proc-macro2", 2505 + "quote", 2506 + "syn 2.0.108", 2507 + "version_check", 2508 + "yansi", 2509 + ] 2510 + 2511 + [[package]] 2512 + name = "quick-error" 2513 + version = "1.2.3" 2514 + source = "registry+https://github.com/rust-lang/crates.io-index" 2515 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2516 + 2517 + [[package]] 2518 + name = "quinn" 2519 + version = "0.11.9" 2520 + source = "registry+https://github.com/rust-lang/crates.io-index" 2521 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 2522 + dependencies = [ 2523 + "bytes", 2524 + "cfg_aliases", 2525 + "pin-project-lite", 2526 + "quinn-proto", 2527 + "quinn-udp", 2528 + "rustc-hash", 2529 + "rustls", 2530 + "socket2 0.6.1", 2531 + "thiserror 2.0.17", 2532 + "tokio", 2533 + "tracing", 2534 + "web-time", 2535 + ] 2536 + 2537 + [[package]] 2538 + name = "quinn-proto" 2539 + version = "0.11.13" 2540 + source = "registry+https://github.com/rust-lang/crates.io-index" 2541 + checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 2542 + dependencies = [ 2543 + "bytes", 2544 + "getrandom 0.3.4", 2545 + "lru-slab", 2546 + "rand 0.9.2", 2547 + "ring", 2548 + "rustc-hash", 2549 + "rustls", 2550 + "rustls-pki-types", 2551 + "slab", 2552 + "thiserror 2.0.17", 2553 + "tinyvec", 2554 + "tracing", 2555 + "web-time", 2556 + ] 2557 + 2558 + [[package]] 2559 + name = "quinn-udp" 2560 + version = "0.5.14" 2561 + source = "registry+https://github.com/rust-lang/crates.io-index" 2562 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 2563 + dependencies = [ 2564 + "cfg_aliases", 2565 + "libc", 2566 + "once_cell", 2567 + "socket2 0.6.1", 2568 + "tracing", 2569 + "windows-sys 0.60.2", 2570 + ] 2571 + 2572 + [[package]] 2573 + name = "quote" 2574 + version = "1.0.41" 2575 + source = "registry+https://github.com/rust-lang/crates.io-index" 2576 + checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 2577 + dependencies = [ 2578 + "proc-macro2", 2579 + ] 2580 + 2581 + [[package]] 2582 + name = "r-efi" 2583 + version = "5.3.0" 2584 + source = "registry+https://github.com/rust-lang/crates.io-index" 2585 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2586 + 2587 + [[package]] 2588 + name = "rand" 2589 + version = "0.8.5" 2590 + source = "registry+https://github.com/rust-lang/crates.io-index" 2591 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 2592 + dependencies = [ 2593 + "libc", 2594 + "rand_chacha 0.3.1", 2595 + "rand_core 0.6.4", 2596 + ] 2597 + 2598 + [[package]] 2599 + name = "rand" 2600 + version = "0.9.2" 2601 + source = "registry+https://github.com/rust-lang/crates.io-index" 2602 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 2603 + dependencies = [ 2604 + "rand_chacha 0.9.0", 2605 + "rand_core 0.9.3", 2606 + ] 2607 + 2608 + [[package]] 2609 + name = "rand_chacha" 2610 + version = "0.3.1" 2611 + source = "registry+https://github.com/rust-lang/crates.io-index" 2612 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2613 + dependencies = [ 2614 + "ppv-lite86", 2615 + "rand_core 0.6.4", 2616 + ] 2617 + 2618 + [[package]] 2619 + name = "rand_chacha" 2620 + version = "0.9.0" 2621 + source = "registry+https://github.com/rust-lang/crates.io-index" 2622 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2623 + dependencies = [ 2624 + "ppv-lite86", 2625 + "rand_core 0.9.3", 2626 + ] 2627 + 2628 + [[package]] 2629 + name = "rand_core" 2630 + version = "0.6.4" 2631 + source = "registry+https://github.com/rust-lang/crates.io-index" 2632 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 2633 + dependencies = [ 2634 + "getrandom 0.2.16", 2635 + ] 2636 + 2637 + [[package]] 2638 + name = "rand_core" 2639 + version = "0.9.3" 2640 + source = "registry+https://github.com/rust-lang/crates.io-index" 2641 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 2642 + dependencies = [ 2643 + "getrandom 0.3.4", 2644 + ] 2645 + 2646 + [[package]] 2647 + name = "range-traits" 2648 + version = "0.3.2" 2649 + source = "registry+https://github.com/rust-lang/crates.io-index" 2650 + checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab" 2651 + 2652 + [[package]] 2653 + name = "redox_syscall" 2654 + version = "0.5.18" 2655 + source = "registry+https://github.com/rust-lang/crates.io-index" 2656 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 2657 + dependencies = [ 2658 + "bitflags", 2659 + ] 2660 + 2661 + [[package]] 2662 + name = "redox_users" 2663 + version = "0.5.2" 2664 + source = "registry+https://github.com/rust-lang/crates.io-index" 2665 + checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" 2666 + dependencies = [ 2667 + "getrandom 0.2.16", 2668 + "libredox", 2669 + "thiserror 2.0.17", 2670 + ] 2671 + 2672 + [[package]] 2673 + name = "ref-cast" 2674 + version = "1.0.25" 2675 + source = "registry+https://github.com/rust-lang/crates.io-index" 2676 + checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" 2677 + dependencies = [ 2678 + "ref-cast-impl", 2679 + ] 2680 + 2681 + [[package]] 2682 + name = "ref-cast-impl" 2683 + version = "1.0.25" 2684 + source = "registry+https://github.com/rust-lang/crates.io-index" 2685 + checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" 2686 + dependencies = [ 2687 + "proc-macro2", 2688 + "quote", 2689 + "syn 2.0.108", 2690 + ] 2691 + 2692 + [[package]] 2693 + name = "regex" 2694 + version = "1.12.2" 2695 + source = "registry+https://github.com/rust-lang/crates.io-index" 2696 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 2697 + dependencies = [ 2698 + "aho-corasick", 2699 + "memchr", 2700 + "regex-automata", 2701 + "regex-syntax", 2702 + ] 2703 + 2704 + [[package]] 2705 + name = "regex-automata" 2706 + version = "0.4.13" 2707 + source = "registry+https://github.com/rust-lang/crates.io-index" 2708 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 2709 + dependencies = [ 2710 + "aho-corasick", 2711 + "memchr", 2712 + "regex-syntax", 2713 + ] 2714 + 2715 + [[package]] 2716 + name = "regex-syntax" 2717 + version = "0.8.8" 2718 + source = "registry+https://github.com/rust-lang/crates.io-index" 2719 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 2720 + 2721 + [[package]] 2722 + name = "reqwest" 2723 + version = "0.12.24" 2724 + source = "registry+https://github.com/rust-lang/crates.io-index" 2725 + checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" 2726 + dependencies = [ 2727 + "async-compression", 2728 + "base64 0.22.1", 2729 + "bytes", 2730 + "encoding_rs", 2731 + "futures-core", 2732 + "futures-util", 2733 + "h2", 2734 + "http", 2735 + "http-body", 2736 + "http-body-util", 2737 + "hyper", 2738 + "hyper-rustls", 2739 + "hyper-util", 2740 + "js-sys", 2741 + "log", 2742 + "mime", 2743 + "percent-encoding", 2744 + "pin-project-lite", 2745 + "quinn", 2746 + "rustls", 2747 + "rustls-pki-types", 2748 + "serde", 2749 + "serde_json", 2750 + "serde_urlencoded", 2751 + "sync_wrapper", 2752 + "tokio", 2753 + "tokio-rustls", 2754 + "tokio-util", 2755 + "tower", 2756 + "tower-http", 2757 + "tower-service", 2758 + "url", 2759 + "wasm-bindgen", 2760 + "wasm-bindgen-futures", 2761 + "wasm-streams", 2762 + "web-sys", 2763 + "webpki-roots", 2764 + ] 2765 + 2766 + [[package]] 2767 + name = "resolv-conf" 2768 + version = "0.7.5" 2769 + source = "registry+https://github.com/rust-lang/crates.io-index" 2770 + checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2771 + 2772 + [[package]] 2773 + name = "rfc6979" 2774 + version = "0.4.0" 2775 + source = "registry+https://github.com/rust-lang/crates.io-index" 2776 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2777 + dependencies = [ 2778 + "hmac", 2779 + "subtle", 2780 + ] 2781 + 2782 + [[package]] 2783 + name = "ring" 2784 + version = "0.17.14" 2785 + source = "registry+https://github.com/rust-lang/crates.io-index" 2786 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 2787 + dependencies = [ 2788 + "cc", 2789 + "cfg-if", 2790 + "getrandom 0.2.16", 2791 + "libc", 2792 + "untrusted", 2793 + "windows-sys 0.52.0", 2794 + ] 2795 + 2796 + [[package]] 2797 + name = "rouille" 2798 + version = "3.6.2" 2799 + source = "registry+https://github.com/rust-lang/crates.io-index" 2800 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 2801 + dependencies = [ 2802 + "base64 0.13.1", 2803 + "brotli", 2804 + "chrono", 2805 + "deflate", 2806 + "filetime", 2807 + "multipart", 2808 + "percent-encoding", 2809 + "rand 0.8.5", 2810 + "serde", 2811 + "serde_derive", 2812 + "serde_json", 2813 + "sha1_smol", 2814 + "threadpool", 2815 + "time", 2816 + "tiny_http", 2817 + "url", 2818 + ] 2819 + 2820 + [[package]] 2821 + name = "rsa" 2822 + version = "0.9.8" 2823 + source = "registry+https://github.com/rust-lang/crates.io-index" 2824 + checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" 2825 + dependencies = [ 2826 + "const-oid", 2827 + "digest", 2828 + "num-bigint-dig", 2829 + "num-integer", 2830 + "num-traits", 2831 + "pkcs1", 2832 + "pkcs8", 2833 + "rand_core 0.6.4", 2834 + "signature", 2835 + "spki", 2836 + "subtle", 2837 + "zeroize", 2838 + ] 2839 + 2840 + [[package]] 2841 + name = "rustc-demangle" 2842 + version = "0.1.26" 2843 + source = "registry+https://github.com/rust-lang/crates.io-index" 2844 + checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 2845 + 2846 + [[package]] 2847 + name = "rustc-hash" 2848 + version = "2.1.1" 2849 + source = "registry+https://github.com/rust-lang/crates.io-index" 2850 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2851 + 2852 + [[package]] 2853 + name = "rustix" 2854 + version = "1.1.2" 2855 + source = "registry+https://github.com/rust-lang/crates.io-index" 2856 + checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 2857 + dependencies = [ 2858 + "bitflags", 2859 + "errno", 2860 + "libc", 2861 + "linux-raw-sys", 2862 + "windows-sys 0.61.2", 2863 + ] 2864 + 2865 + [[package]] 2866 + name = "rustls" 2867 + version = "0.23.34" 2868 + source = "registry+https://github.com/rust-lang/crates.io-index" 2869 + checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" 2870 + dependencies = [ 2871 + "once_cell", 2872 + "ring", 2873 + "rustls-pki-types", 2874 + "rustls-webpki", 2875 + "subtle", 2876 + "zeroize", 2877 + ] 2878 + 2879 + [[package]] 2880 + name = "rustls-pki-types" 2881 + version = "1.13.0" 2882 + source = "registry+https://github.com/rust-lang/crates.io-index" 2883 + checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" 2884 + dependencies = [ 2885 + "web-time", 2886 + "zeroize", 2887 + ] 2888 + 2889 + [[package]] 2890 + name = "rustls-webpki" 2891 + version = "0.103.8" 2892 + source = "registry+https://github.com/rust-lang/crates.io-index" 2893 + checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" 2894 + dependencies = [ 2895 + "ring", 2896 + "rustls-pki-types", 2897 + "untrusted", 2898 + ] 2899 + 2900 + [[package]] 2901 + name = "rustversion" 2902 + version = "1.0.22" 2903 + source = "registry+https://github.com/rust-lang/crates.io-index" 2904 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2905 + 2906 + [[package]] 2907 + name = "ryu" 2908 + version = "1.0.20" 2909 + source = "registry+https://github.com/rust-lang/crates.io-index" 2910 + checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2911 + 2912 + [[package]] 2913 + name = "safemem" 2914 + version = "0.3.3" 2915 + source = "registry+https://github.com/rust-lang/crates.io-index" 2916 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2917 + 2918 + [[package]] 2919 + name = "same-file" 2920 + version = "1.0.6" 2921 + source = "registry+https://github.com/rust-lang/crates.io-index" 2922 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 2923 + dependencies = [ 2924 + "winapi-util", 2925 + ] 2926 + 2927 + [[package]] 2928 + name = "schemars" 2929 + version = "0.9.0" 2930 + source = "registry+https://github.com/rust-lang/crates.io-index" 2931 + checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" 2932 + dependencies = [ 2933 + "dyn-clone", 2934 + "ref-cast", 2935 + "serde", 2936 + "serde_json", 2937 + ] 2938 + 2939 + [[package]] 2940 + name = "schemars" 2941 + version = "1.0.4" 2942 + source = "registry+https://github.com/rust-lang/crates.io-index" 2943 + checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" 2944 + dependencies = [ 2945 + "dyn-clone", 2946 + "ref-cast", 2947 + "serde", 2948 + "serde_json", 2949 + ] 2950 + 2951 + [[package]] 2952 + name = "scopeguard" 2953 + version = "1.2.0" 2954 + source = "registry+https://github.com/rust-lang/crates.io-index" 2955 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2956 + 2957 + [[package]] 2958 + name = "sec1" 2959 + version = "0.7.3" 2960 + source = "registry+https://github.com/rust-lang/crates.io-index" 2961 + checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 2962 + dependencies = [ 2963 + "base16ct", 2964 + "der", 2965 + "generic-array", 2966 + "pkcs8", 2967 + "subtle", 2968 + "zeroize", 2969 + ] 2970 + 2971 + [[package]] 2972 + name = "serde" 2973 + version = "1.0.228" 2974 + source = "registry+https://github.com/rust-lang/crates.io-index" 2975 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2976 + dependencies = [ 2977 + "serde_core", 2978 + "serde_derive", 2979 + ] 2980 + 2981 + [[package]] 2982 + name = "serde_bytes" 2983 + version = "0.11.19" 2984 + source = "registry+https://github.com/rust-lang/crates.io-index" 2985 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2986 + dependencies = [ 2987 + "serde", 2988 + "serde_core", 2989 + ] 2990 + 2991 + [[package]] 2992 + name = "serde_core" 2993 + version = "1.0.228" 2994 + source = "registry+https://github.com/rust-lang/crates.io-index" 2995 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2996 + dependencies = [ 2997 + "serde_derive", 2998 + ] 2999 + 3000 + [[package]] 3001 + name = "serde_derive" 3002 + version = "1.0.228" 3003 + source = "registry+https://github.com/rust-lang/crates.io-index" 3004 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 3005 + dependencies = [ 3006 + "proc-macro2", 3007 + "quote", 3008 + "syn 2.0.108", 3009 + ] 3010 + 3011 + [[package]] 3012 + name = "serde_html_form" 3013 + version = "0.2.8" 3014 + source = "registry+https://github.com/rust-lang/crates.io-index" 3015 + checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" 3016 + dependencies = [ 3017 + "form_urlencoded", 3018 + "indexmap 2.12.0", 3019 + "itoa", 3020 + "ryu", 3021 + "serde_core", 3022 + ] 3023 + 3024 + [[package]] 3025 + name = "serde_ipld_dagcbor" 3026 + version = "0.6.4" 3027 + source = "registry+https://github.com/rust-lang/crates.io-index" 3028 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 3029 + dependencies = [ 3030 + "cbor4ii", 3031 + "ipld-core", 3032 + "scopeguard", 3033 + "serde", 3034 + ] 3035 + 3036 + [[package]] 3037 + name = "serde_json" 3038 + version = "1.0.145" 3039 + source = "registry+https://github.com/rust-lang/crates.io-index" 3040 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 3041 + dependencies = [ 3042 + "itoa", 3043 + "memchr", 3044 + "ryu", 3045 + "serde", 3046 + "serde_core", 3047 + ] 3048 + 3049 + [[package]] 3050 + name = "serde_repr" 3051 + version = "0.1.20" 3052 + source = "registry+https://github.com/rust-lang/crates.io-index" 3053 + checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" 3054 + dependencies = [ 3055 + "proc-macro2", 3056 + "quote", 3057 + "syn 2.0.108", 3058 + ] 3059 + 3060 + [[package]] 3061 + name = "serde_urlencoded" 3062 + version = "0.7.1" 3063 + source = "registry+https://github.com/rust-lang/crates.io-index" 3064 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 3065 + dependencies = [ 3066 + "form_urlencoded", 3067 + "itoa", 3068 + "ryu", 3069 + "serde", 3070 + ] 3071 + 3072 + [[package]] 3073 + name = "serde_with" 3074 + version = "3.15.1" 3075 + source = "registry+https://github.com/rust-lang/crates.io-index" 3076 + checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" 3077 + dependencies = [ 3078 + "base64 0.22.1", 3079 + "chrono", 3080 + "hex", 3081 + "indexmap 1.9.3", 3082 + "indexmap 2.12.0", 3083 + "schemars 0.9.0", 3084 + "schemars 1.0.4", 3085 + "serde_core", 3086 + "serde_json", 3087 + "serde_with_macros", 3088 + "time", 3089 + ] 3090 + 3091 + [[package]] 3092 + name = "serde_with_macros" 3093 + version = "3.15.1" 3094 + source = "registry+https://github.com/rust-lang/crates.io-index" 3095 + checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" 3096 + dependencies = [ 3097 + "darling", 3098 + "proc-macro2", 3099 + "quote", 3100 + "syn 2.0.108", 3101 + ] 3102 + 3103 + [[package]] 3104 + name = "sha1_smol" 3105 + version = "1.0.1" 3106 + source = "registry+https://github.com/rust-lang/crates.io-index" 3107 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 3108 + 3109 + [[package]] 3110 + name = "sha2" 3111 + version = "0.10.9" 3112 + source = "registry+https://github.com/rust-lang/crates.io-index" 3113 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 3114 + dependencies = [ 3115 + "cfg-if", 3116 + "cpufeatures", 3117 + "digest", 3118 + ] 3119 + 3120 + [[package]] 3121 + name = "shellexpand" 3122 + version = "3.1.1" 3123 + source = "registry+https://github.com/rust-lang/crates.io-index" 3124 + checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" 3125 + dependencies = [ 3126 + "dirs", 3127 + ] 3128 + 3129 + [[package]] 3130 + name = "shlex" 3131 + version = "1.3.0" 3132 + source = "registry+https://github.com/rust-lang/crates.io-index" 3133 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3134 + 3135 + [[package]] 3136 + name = "signal-hook-registry" 3137 + version = "1.4.6" 3138 + source = "registry+https://github.com/rust-lang/crates.io-index" 3139 + checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 3140 + dependencies = [ 3141 + "libc", 3142 + ] 3143 + 3144 + [[package]] 3145 + name = "signature" 3146 + version = "2.2.0" 3147 + source = "registry+https://github.com/rust-lang/crates.io-index" 3148 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3149 + dependencies = [ 3150 + "digest", 3151 + "rand_core 0.6.4", 3152 + ] 3153 + 3154 + [[package]] 3155 + name = "simd-adler32" 3156 + version = "0.3.7" 3157 + source = "registry+https://github.com/rust-lang/crates.io-index" 3158 + checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 3159 + 3160 + [[package]] 3161 + name = "siphasher" 3162 + version = "1.0.1" 3163 + source = "registry+https://github.com/rust-lang/crates.io-index" 3164 + checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 3165 + 3166 + [[package]] 3167 + name = "slab" 3168 + version = "0.4.11" 3169 + source = "registry+https://github.com/rust-lang/crates.io-index" 3170 + checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 3171 + 3172 + [[package]] 3173 + name = "smallvec" 3174 + version = "1.15.1" 3175 + source = "registry+https://github.com/rust-lang/crates.io-index" 3176 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 3177 + 3178 + [[package]] 3179 + name = "smol_str" 3180 + version = "0.3.4" 3181 + source = "registry+https://github.com/rust-lang/crates.io-index" 3182 + checksum = "3498b0a27f93ef1402f20eefacfaa1691272ac4eca1cdc8c596cb0a245d6cbf5" 3183 + dependencies = [ 3184 + "borsh", 3185 + "serde_core", 3186 + ] 3187 + 3188 + [[package]] 3189 + name = "socket2" 3190 + version = "0.5.10" 3191 + source = "registry+https://github.com/rust-lang/crates.io-index" 3192 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 3193 + dependencies = [ 3194 + "libc", 3195 + "windows-sys 0.52.0", 3196 + ] 3197 + 3198 + [[package]] 3199 + name = "socket2" 3200 + version = "0.6.1" 3201 + source = "registry+https://github.com/rust-lang/crates.io-index" 3202 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 3203 + dependencies = [ 3204 + "libc", 3205 + "windows-sys 0.60.2", 3206 + ] 3207 + 3208 + [[package]] 3209 + name = "spin" 3210 + version = "0.9.8" 3211 + source = "registry+https://github.com/rust-lang/crates.io-index" 3212 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3213 + 3214 + [[package]] 3215 + name = "spki" 3216 + version = "0.7.3" 3217 + source = "registry+https://github.com/rust-lang/crates.io-index" 3218 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 3219 + dependencies = [ 3220 + "base64ct", 3221 + "der", 3222 + ] 3223 + 3224 + [[package]] 3225 + name = "stable_deref_trait" 3226 + version = "1.2.1" 3227 + source = "registry+https://github.com/rust-lang/crates.io-index" 3228 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 3229 + 3230 + [[package]] 3231 + name = "static-regular-grammar" 3232 + version = "2.0.2" 3233 + source = "registry+https://github.com/rust-lang/crates.io-index" 3234 + checksum = "4f4a6c40247579acfbb138c3cd7de3dab113ab4ac6227f1b7de7d626ee667957" 3235 + dependencies = [ 3236 + "abnf", 3237 + "btree-range-map", 3238 + "ciborium", 3239 + "hex_fmt", 3240 + "indoc", 3241 + "proc-macro-error", 3242 + "proc-macro2", 3243 + "quote", 3244 + "serde", 3245 + "sha2", 3246 + "syn 2.0.108", 3247 + "thiserror 1.0.69", 3248 + ] 3249 + 3250 + [[package]] 3251 + name = "static_assertions" 3252 + version = "1.1.0" 3253 + source = "registry+https://github.com/rust-lang/crates.io-index" 3254 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 3255 + 3256 + [[package]] 3257 + name = "string_cache" 3258 + version = "0.8.9" 3259 + source = "registry+https://github.com/rust-lang/crates.io-index" 3260 + checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" 3261 + dependencies = [ 3262 + "new_debug_unreachable", 3263 + "parking_lot", 3264 + "phf_shared", 3265 + "precomputed-hash", 3266 + "serde", 3267 + ] 3268 + 3269 + [[package]] 3270 + name = "string_cache_codegen" 3271 + version = "0.5.4" 3272 + source = "registry+https://github.com/rust-lang/crates.io-index" 3273 + checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" 3274 + dependencies = [ 3275 + "phf_generator", 3276 + "phf_shared", 3277 + "proc-macro2", 3278 + "quote", 3279 + ] 3280 + 3281 + [[package]] 3282 + name = "strsim" 3283 + version = "0.11.1" 3284 + source = "registry+https://github.com/rust-lang/crates.io-index" 3285 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3286 + 3287 + [[package]] 3288 + name = "subtle" 3289 + version = "2.6.1" 3290 + source = "registry+https://github.com/rust-lang/crates.io-index" 3291 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 3292 + 3293 + [[package]] 3294 + name = "supports-color" 3295 + version = "3.0.2" 3296 + source = "registry+https://github.com/rust-lang/crates.io-index" 3297 + checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" 3298 + dependencies = [ 3299 + "is_ci", 3300 + ] 3301 + 3302 + [[package]] 3303 + name = "supports-hyperlinks" 3304 + version = "3.1.0" 3305 + source = "registry+https://github.com/rust-lang/crates.io-index" 3306 + checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" 3307 + 3308 + [[package]] 3309 + name = "supports-unicode" 3310 + version = "3.0.0" 3311 + source = "registry+https://github.com/rust-lang/crates.io-index" 3312 + checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" 3313 + 3314 + [[package]] 3315 + name = "syn" 3316 + version = "1.0.109" 3317 + source = "registry+https://github.com/rust-lang/crates.io-index" 3318 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 3319 + dependencies = [ 3320 + "proc-macro2", 3321 + "quote", 3322 + "unicode-ident", 3323 + ] 3324 + 3325 + [[package]] 3326 + name = "syn" 3327 + version = "2.0.108" 3328 + source = "registry+https://github.com/rust-lang/crates.io-index" 3329 + checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" 3330 + dependencies = [ 3331 + "proc-macro2", 3332 + "quote", 3333 + "unicode-ident", 3334 + ] 3335 + 3336 + [[package]] 3337 + name = "sync_wrapper" 3338 + version = "1.0.2" 3339 + source = "registry+https://github.com/rust-lang/crates.io-index" 3340 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 3341 + dependencies = [ 3342 + "futures-core", 3343 + ] 3344 + 3345 + [[package]] 3346 + name = "synstructure" 3347 + version = "0.13.2" 3348 + source = "registry+https://github.com/rust-lang/crates.io-index" 3349 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 3350 + dependencies = [ 3351 + "proc-macro2", 3352 + "quote", 3353 + "syn 2.0.108", 3354 + ] 3355 + 3356 + [[package]] 3357 + name = "system-configuration" 3358 + version = "0.6.1" 3359 + source = "registry+https://github.com/rust-lang/crates.io-index" 3360 + checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 3361 + dependencies = [ 3362 + "bitflags", 3363 + "core-foundation 0.9.4", 3364 + "system-configuration-sys", 3365 + ] 3366 + 3367 + [[package]] 3368 + name = "system-configuration-sys" 3369 + version = "0.6.0" 3370 + source = "registry+https://github.com/rust-lang/crates.io-index" 3371 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3372 + dependencies = [ 3373 + "core-foundation-sys", 3374 + "libc", 3375 + ] 3376 + 3377 + [[package]] 3378 + name = "tagptr" 3379 + version = "0.2.0" 3380 + source = "registry+https://github.com/rust-lang/crates.io-index" 3381 + checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 3382 + 3383 + [[package]] 3384 + name = "tempfile" 3385 + version = "3.23.0" 3386 + source = "registry+https://github.com/rust-lang/crates.io-index" 3387 + checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 3388 + dependencies = [ 3389 + "fastrand", 3390 + "getrandom 0.3.4", 3391 + "once_cell", 3392 + "rustix", 3393 + "windows-sys 0.61.2", 3394 + ] 3395 + 3396 + [[package]] 3397 + name = "tendril" 3398 + version = "0.4.3" 3399 + source = "registry+https://github.com/rust-lang/crates.io-index" 3400 + checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" 3401 + dependencies = [ 3402 + "futf", 3403 + "mac", 3404 + "utf-8", 3405 + ] 3406 + 3407 + [[package]] 3408 + name = "terminal_size" 3409 + version = "0.4.3" 3410 + source = "registry+https://github.com/rust-lang/crates.io-index" 3411 + checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" 3412 + dependencies = [ 3413 + "rustix", 3414 + "windows-sys 0.60.2", 3415 + ] 3416 + 3417 + [[package]] 3418 + name = "textwrap" 3419 + version = "0.16.2" 3420 + source = "registry+https://github.com/rust-lang/crates.io-index" 3421 + checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" 3422 + dependencies = [ 3423 + "unicode-linebreak", 3424 + "unicode-width 0.2.2", 3425 + ] 3426 + 3427 + [[package]] 3428 + name = "thiserror" 3429 + version = "1.0.69" 3430 + source = "registry+https://github.com/rust-lang/crates.io-index" 3431 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 3432 + dependencies = [ 3433 + "thiserror-impl 1.0.69", 3434 + ] 3435 + 3436 + [[package]] 3437 + name = "thiserror" 3438 + version = "2.0.17" 3439 + source = "registry+https://github.com/rust-lang/crates.io-index" 3440 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 3441 + dependencies = [ 3442 + "thiserror-impl 2.0.17", 3443 + ] 3444 + 3445 + [[package]] 3446 + name = "thiserror-impl" 3447 + version = "1.0.69" 3448 + source = "registry+https://github.com/rust-lang/crates.io-index" 3449 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 3450 + dependencies = [ 3451 + "proc-macro2", 3452 + "quote", 3453 + "syn 2.0.108", 3454 + ] 3455 + 3456 + [[package]] 3457 + name = "thiserror-impl" 3458 + version = "2.0.17" 3459 + source = "registry+https://github.com/rust-lang/crates.io-index" 3460 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 3461 + dependencies = [ 3462 + "proc-macro2", 3463 + "quote", 3464 + "syn 2.0.108", 3465 + ] 3466 + 3467 + [[package]] 3468 + name = "threadpool" 3469 + version = "1.8.1" 3470 + source = "registry+https://github.com/rust-lang/crates.io-index" 3471 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 3472 + dependencies = [ 3473 + "num_cpus", 3474 + ] 3475 + 3476 + [[package]] 3477 + name = "time" 3478 + version = "0.3.44" 3479 + source = "registry+https://github.com/rust-lang/crates.io-index" 3480 + checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 3481 + dependencies = [ 3482 + "deranged", 3483 + "itoa", 3484 + "libc", 3485 + "num-conv", 3486 + "num_threads", 3487 + "powerfmt", 3488 + "serde", 3489 + "time-core", 3490 + "time-macros", 3491 + ] 3492 + 3493 + [[package]] 3494 + name = "time-core" 3495 + version = "0.1.6" 3496 + source = "registry+https://github.com/rust-lang/crates.io-index" 3497 + checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 3498 + 3499 + [[package]] 3500 + name = "time-macros" 3501 + version = "0.2.24" 3502 + source = "registry+https://github.com/rust-lang/crates.io-index" 3503 + checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" 3504 + dependencies = [ 3505 + "num-conv", 3506 + "time-core", 3507 + ] 3508 + 3509 + [[package]] 3510 + name = "tiny_http" 3511 + version = "0.12.0" 3512 + source = "registry+https://github.com/rust-lang/crates.io-index" 3513 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 3514 + dependencies = [ 3515 + "ascii", 3516 + "chunked_transfer", 3517 + "httpdate", 3518 + "log", 3519 + ] 3520 + 3521 + [[package]] 3522 + name = "tinystr" 3523 + version = "0.8.2" 3524 + source = "registry+https://github.com/rust-lang/crates.io-index" 3525 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 3526 + dependencies = [ 3527 + "displaydoc", 3528 + "zerovec", 3529 + ] 3530 + 3531 + [[package]] 3532 + name = "tinyvec" 3533 + version = "1.10.0" 3534 + source = "registry+https://github.com/rust-lang/crates.io-index" 3535 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 3536 + dependencies = [ 3537 + "tinyvec_macros", 3538 + ] 3539 + 3540 + [[package]] 3541 + name = "tinyvec_macros" 3542 + version = "0.1.1" 3543 + source = "registry+https://github.com/rust-lang/crates.io-index" 3544 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 3545 + 3546 + [[package]] 3547 + name = "tokio" 3548 + version = "1.48.0" 3549 + source = "registry+https://github.com/rust-lang/crates.io-index" 3550 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 3551 + dependencies = [ 3552 + "bytes", 3553 + "libc", 3554 + "mio", 3555 + "parking_lot", 3556 + "pin-project-lite", 3557 + "signal-hook-registry", 3558 + "socket2 0.6.1", 3559 + "tokio-macros", 3560 + "windows-sys 0.61.2", 3561 + ] 3562 + 3563 + [[package]] 3564 + name = "tokio-macros" 3565 + version = "2.6.0" 3566 + source = "registry+https://github.com/rust-lang/crates.io-index" 3567 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 3568 + dependencies = [ 3569 + "proc-macro2", 3570 + "quote", 3571 + "syn 2.0.108", 3572 + ] 3573 + 3574 + [[package]] 3575 + name = "tokio-rustls" 3576 + version = "0.26.4" 3577 + source = "registry+https://github.com/rust-lang/crates.io-index" 3578 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 3579 + dependencies = [ 3580 + "rustls", 3581 + "tokio", 3582 + ] 3583 + 3584 + [[package]] 3585 + name = "tokio-util" 3586 + version = "0.7.16" 3587 + source = "registry+https://github.com/rust-lang/crates.io-index" 3588 + checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" 3589 + dependencies = [ 3590 + "bytes", 3591 + "futures-core", 3592 + "futures-sink", 3593 + "pin-project-lite", 3594 + "tokio", 3595 + ] 3596 + 3597 + [[package]] 3598 + name = "tower" 3599 + version = "0.5.2" 3600 + source = "registry+https://github.com/rust-lang/crates.io-index" 3601 + checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 3602 + dependencies = [ 3603 + "futures-core", 3604 + "futures-util", 3605 + "pin-project-lite", 3606 + "sync_wrapper", 3607 + "tokio", 3608 + "tower-layer", 3609 + "tower-service", 3610 + ] 3611 + 3612 + [[package]] 3613 + name = "tower-http" 3614 + version = "0.6.6" 3615 + source = "registry+https://github.com/rust-lang/crates.io-index" 3616 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 3617 + dependencies = [ 3618 + "bitflags", 3619 + "bytes", 3620 + "futures-util", 3621 + "http", 3622 + "http-body", 3623 + "iri-string", 3624 + "pin-project-lite", 3625 + "tower", 3626 + "tower-layer", 3627 + "tower-service", 3628 + ] 3629 + 3630 + [[package]] 3631 + name = "tower-layer" 3632 + version = "0.3.3" 3633 + source = "registry+https://github.com/rust-lang/crates.io-index" 3634 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 3635 + 3636 + [[package]] 3637 + name = "tower-service" 3638 + version = "0.3.3" 3639 + source = "registry+https://github.com/rust-lang/crates.io-index" 3640 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 3641 + 3642 + [[package]] 3643 + name = "tracing" 3644 + version = "0.1.41" 3645 + source = "registry+https://github.com/rust-lang/crates.io-index" 3646 + checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 3647 + dependencies = [ 3648 + "pin-project-lite", 3649 + "tracing-attributes", 3650 + "tracing-core", 3651 + ] 3652 + 3653 + [[package]] 3654 + name = "tracing-attributes" 3655 + version = "0.1.30" 3656 + source = "registry+https://github.com/rust-lang/crates.io-index" 3657 + checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 3658 + dependencies = [ 3659 + "proc-macro2", 3660 + "quote", 3661 + "syn 2.0.108", 3662 + ] 3663 + 3664 + [[package]] 3665 + name = "tracing-core" 3666 + version = "0.1.34" 3667 + source = "registry+https://github.com/rust-lang/crates.io-index" 3668 + checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3669 + dependencies = [ 3670 + "once_cell", 3671 + ] 3672 + 3673 + [[package]] 3674 + name = "trait-variant" 3675 + version = "0.1.2" 3676 + source = "registry+https://github.com/rust-lang/crates.io-index" 3677 + checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 3678 + dependencies = [ 3679 + "proc-macro2", 3680 + "quote", 3681 + "syn 2.0.108", 3682 + ] 3683 + 3684 + [[package]] 3685 + name = "triomphe" 3686 + version = "0.1.15" 3687 + source = "registry+https://github.com/rust-lang/crates.io-index" 3688 + checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" 3689 + 3690 + [[package]] 3691 + name = "try-lock" 3692 + version = "0.2.5" 3693 + source = "registry+https://github.com/rust-lang/crates.io-index" 3694 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3695 + 3696 + [[package]] 3697 + name = "twoway" 3698 + version = "0.1.8" 3699 + source = "registry+https://github.com/rust-lang/crates.io-index" 3700 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 3701 + dependencies = [ 3702 + "memchr", 3703 + ] 3704 + 3705 + [[package]] 3706 + name = "typenum" 3707 + version = "1.19.0" 3708 + source = "registry+https://github.com/rust-lang/crates.io-index" 3709 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 3710 + 3711 + [[package]] 3712 + name = "unicase" 3713 + version = "2.8.1" 3714 + source = "registry+https://github.com/rust-lang/crates.io-index" 3715 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 3716 + 3717 + [[package]] 3718 + name = "unicode-ident" 3719 + version = "1.0.22" 3720 + source = "registry+https://github.com/rust-lang/crates.io-index" 3721 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 3722 + 3723 + [[package]] 3724 + name = "unicode-linebreak" 3725 + version = "0.1.5" 3726 + source = "registry+https://github.com/rust-lang/crates.io-index" 3727 + checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 3728 + 3729 + [[package]] 3730 + name = "unicode-segmentation" 3731 + version = "1.12.0" 3732 + source = "registry+https://github.com/rust-lang/crates.io-index" 3733 + checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 3734 + 3735 + [[package]] 3736 + name = "unicode-width" 3737 + version = "0.1.14" 3738 + source = "registry+https://github.com/rust-lang/crates.io-index" 3739 + checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 3740 + 3741 + [[package]] 3742 + name = "unicode-width" 3743 + version = "0.2.2" 3744 + source = "registry+https://github.com/rust-lang/crates.io-index" 3745 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 3746 + 3747 + [[package]] 3748 + name = "unsigned-varint" 3749 + version = "0.8.0" 3750 + source = "registry+https://github.com/rust-lang/crates.io-index" 3751 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3752 + 3753 + [[package]] 3754 + name = "untrusted" 3755 + version = "0.9.0" 3756 + source = "registry+https://github.com/rust-lang/crates.io-index" 3757 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 3758 + 3759 + [[package]] 3760 + name = "url" 3761 + version = "2.5.7" 3762 + source = "registry+https://github.com/rust-lang/crates.io-index" 3763 + checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" 3764 + dependencies = [ 3765 + "form_urlencoded", 3766 + "idna", 3767 + "percent-encoding", 3768 + "serde", 3769 + ] 3770 + 3771 + [[package]] 3772 + name = "urlencoding" 3773 + version = "2.1.3" 3774 + source = "registry+https://github.com/rust-lang/crates.io-index" 3775 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 3776 + 3777 + [[package]] 3778 + name = "utf-8" 3779 + version = "0.7.6" 3780 + source = "registry+https://github.com/rust-lang/crates.io-index" 3781 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3782 + 3783 + [[package]] 3784 + name = "utf8_iter" 3785 + version = "1.0.4" 3786 + source = "registry+https://github.com/rust-lang/crates.io-index" 3787 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 3788 + 3789 + [[package]] 3790 + name = "utf8parse" 3791 + version = "0.2.2" 3792 + source = "registry+https://github.com/rust-lang/crates.io-index" 3793 + checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3794 + 3795 + [[package]] 3796 + name = "version_check" 3797 + version = "0.9.5" 3798 + source = "registry+https://github.com/rust-lang/crates.io-index" 3799 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3800 + 3801 + [[package]] 3802 + name = "walkdir" 3803 + version = "2.5.0" 3804 + source = "registry+https://github.com/rust-lang/crates.io-index" 3805 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 3806 + dependencies = [ 3807 + "same-file", 3808 + "winapi-util", 3809 + ] 3810 + 3811 + [[package]] 3812 + name = "want" 3813 + version = "0.3.1" 3814 + source = "registry+https://github.com/rust-lang/crates.io-index" 3815 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 3816 + dependencies = [ 3817 + "try-lock", 3818 + ] 3819 + 3820 + [[package]] 3821 + name = "wasi" 3822 + version = "0.11.1+wasi-snapshot-preview1" 3823 + source = "registry+https://github.com/rust-lang/crates.io-index" 3824 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 3825 + 3826 + [[package]] 3827 + name = "wasip2" 3828 + version = "1.0.1+wasi-0.2.4" 3829 + source = "registry+https://github.com/rust-lang/crates.io-index" 3830 + checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 3831 + dependencies = [ 3832 + "wit-bindgen", 3833 + ] 3834 + 3835 + [[package]] 3836 + name = "wasm-bindgen" 3837 + version = "0.2.105" 3838 + source = "registry+https://github.com/rust-lang/crates.io-index" 3839 + checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 3840 + dependencies = [ 3841 + "cfg-if", 3842 + "once_cell", 3843 + "rustversion", 3844 + "wasm-bindgen-macro", 3845 + "wasm-bindgen-shared", 3846 + ] 3847 + 3848 + [[package]] 3849 + name = "wasm-bindgen-futures" 3850 + version = "0.4.55" 3851 + source = "registry+https://github.com/rust-lang/crates.io-index" 3852 + checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" 3853 + dependencies = [ 3854 + "cfg-if", 3855 + "js-sys", 3856 + "once_cell", 3857 + "wasm-bindgen", 3858 + "web-sys", 3859 + ] 3860 + 3861 + [[package]] 3862 + name = "wasm-bindgen-macro" 3863 + version = "0.2.105" 3864 + source = "registry+https://github.com/rust-lang/crates.io-index" 3865 + checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 3866 + dependencies = [ 3867 + "quote", 3868 + "wasm-bindgen-macro-support", 3869 + ] 3870 + 3871 + [[package]] 3872 + name = "wasm-bindgen-macro-support" 3873 + version = "0.2.105" 3874 + source = "registry+https://github.com/rust-lang/crates.io-index" 3875 + checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 3876 + dependencies = [ 3877 + "bumpalo", 3878 + "proc-macro2", 3879 + "quote", 3880 + "syn 2.0.108", 3881 + "wasm-bindgen-shared", 3882 + ] 3883 + 3884 + [[package]] 3885 + name = "wasm-bindgen-shared" 3886 + version = "0.2.105" 3887 + source = "registry+https://github.com/rust-lang/crates.io-index" 3888 + checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 3889 + dependencies = [ 3890 + "unicode-ident", 3891 + ] 3892 + 3893 + [[package]] 3894 + name = "wasm-streams" 3895 + version = "0.4.2" 3896 + source = "registry+https://github.com/rust-lang/crates.io-index" 3897 + checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" 3898 + dependencies = [ 3899 + "futures-util", 3900 + "js-sys", 3901 + "wasm-bindgen", 3902 + "wasm-bindgen-futures", 3903 + "web-sys", 3904 + ] 3905 + 3906 + [[package]] 3907 + name = "web-sys" 3908 + version = "0.3.82" 3909 + source = "registry+https://github.com/rust-lang/crates.io-index" 3910 + checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" 3911 + dependencies = [ 3912 + "js-sys", 3913 + "wasm-bindgen", 3914 + ] 3915 + 3916 + [[package]] 3917 + name = "web-time" 3918 + version = "1.1.0" 3919 + source = "registry+https://github.com/rust-lang/crates.io-index" 3920 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 3921 + dependencies = [ 3922 + "js-sys", 3923 + "wasm-bindgen", 3924 + ] 3925 + 3926 + [[package]] 3927 + name = "webbrowser" 3928 + version = "1.0.6" 3929 + source = "registry+https://github.com/rust-lang/crates.io-index" 3930 + checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" 3931 + dependencies = [ 3932 + "core-foundation 0.10.1", 3933 + "jni", 3934 + "log", 3935 + "ndk-context", 3936 + "objc2", 3937 + "objc2-foundation", 3938 + "url", 3939 + "web-sys", 3940 + ] 3941 + 3942 + [[package]] 3943 + name = "webpage" 3944 + version = "2.0.1" 3945 + source = "registry+https://github.com/rust-lang/crates.io-index" 3946 + checksum = "70862efc041d46e6bbaa82bb9c34ae0596d090e86cbd14bd9e93b36ee6802eac" 3947 + dependencies = [ 3948 + "html5ever", 3949 + "markup5ever_rcdom", 3950 + "serde_json", 3951 + "url", 3952 + ] 3953 + 3954 + [[package]] 3955 + name = "webpki-roots" 3956 + version = "1.0.4" 3957 + source = "registry+https://github.com/rust-lang/crates.io-index" 3958 + checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" 3959 + dependencies = [ 3960 + "rustls-pki-types", 3961 + ] 3962 + 3963 + [[package]] 3964 + name = "widestring" 3965 + version = "1.2.1" 3966 + source = "registry+https://github.com/rust-lang/crates.io-index" 3967 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 3968 + 3969 + [[package]] 3970 + name = "winapi-util" 3971 + version = "0.1.11" 3972 + source = "registry+https://github.com/rust-lang/crates.io-index" 3973 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 3974 + dependencies = [ 3975 + "windows-sys 0.61.2", 3976 + ] 3977 + 3978 + [[package]] 3979 + name = "windows-core" 3980 + version = "0.62.2" 3981 + source = "registry+https://github.com/rust-lang/crates.io-index" 3982 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 3983 + dependencies = [ 3984 + "windows-implement", 3985 + "windows-interface", 3986 + "windows-link 0.2.1", 3987 + "windows-result 0.4.1", 3988 + "windows-strings 0.5.1", 3989 + ] 3990 + 3991 + [[package]] 3992 + name = "windows-implement" 3993 + version = "0.60.2" 3994 + source = "registry+https://github.com/rust-lang/crates.io-index" 3995 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 3996 + dependencies = [ 3997 + "proc-macro2", 3998 + "quote", 3999 + "syn 2.0.108", 4000 + ] 4001 + 4002 + [[package]] 4003 + name = "windows-interface" 4004 + version = "0.59.3" 4005 + source = "registry+https://github.com/rust-lang/crates.io-index" 4006 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 4007 + dependencies = [ 4008 + "proc-macro2", 4009 + "quote", 4010 + "syn 2.0.108", 4011 + ] 4012 + 4013 + [[package]] 4014 + name = "windows-link" 4015 + version = "0.1.3" 4016 + source = "registry+https://github.com/rust-lang/crates.io-index" 4017 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 4018 + 4019 + [[package]] 4020 + name = "windows-link" 4021 + version = "0.2.1" 4022 + source = "registry+https://github.com/rust-lang/crates.io-index" 4023 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 4024 + 4025 + [[package]] 4026 + name = "windows-registry" 4027 + version = "0.5.3" 4028 + source = "registry+https://github.com/rust-lang/crates.io-index" 4029 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 4030 + dependencies = [ 4031 + "windows-link 0.1.3", 4032 + "windows-result 0.3.4", 4033 + "windows-strings 0.4.2", 4034 + ] 4035 + 4036 + [[package]] 4037 + name = "windows-result" 4038 + version = "0.3.4" 4039 + source = "registry+https://github.com/rust-lang/crates.io-index" 4040 + checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 4041 + dependencies = [ 4042 + "windows-link 0.1.3", 4043 + ] 4044 + 4045 + [[package]] 4046 + name = "windows-result" 4047 + version = "0.4.1" 4048 + source = "registry+https://github.com/rust-lang/crates.io-index" 4049 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 4050 + dependencies = [ 4051 + "windows-link 0.2.1", 4052 + ] 4053 + 4054 + [[package]] 4055 + name = "windows-strings" 4056 + version = "0.4.2" 4057 + source = "registry+https://github.com/rust-lang/crates.io-index" 4058 + checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 4059 + dependencies = [ 4060 + "windows-link 0.1.3", 4061 + ] 4062 + 4063 + [[package]] 4064 + name = "windows-strings" 4065 + version = "0.5.1" 4066 + source = "registry+https://github.com/rust-lang/crates.io-index" 4067 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 4068 + dependencies = [ 4069 + "windows-link 0.2.1", 4070 + ] 4071 + 4072 + [[package]] 4073 + name = "windows-sys" 4074 + version = "0.45.0" 4075 + source = "registry+https://github.com/rust-lang/crates.io-index" 4076 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 4077 + dependencies = [ 4078 + "windows-targets 0.42.2", 4079 + ] 4080 + 4081 + [[package]] 4082 + name = "windows-sys" 4083 + version = "0.48.0" 4084 + source = "registry+https://github.com/rust-lang/crates.io-index" 4085 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 4086 + dependencies = [ 4087 + "windows-targets 0.48.5", 4088 + ] 4089 + 4090 + [[package]] 4091 + name = "windows-sys" 4092 + version = "0.52.0" 4093 + source = "registry+https://github.com/rust-lang/crates.io-index" 4094 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 4095 + dependencies = [ 4096 + "windows-targets 0.52.6", 4097 + ] 4098 + 4099 + [[package]] 4100 + name = "windows-sys" 4101 + version = "0.60.2" 4102 + source = "registry+https://github.com/rust-lang/crates.io-index" 4103 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 4104 + dependencies = [ 4105 + "windows-targets 0.53.5", 4106 + ] 4107 + 4108 + [[package]] 4109 + name = "windows-sys" 4110 + version = "0.61.2" 4111 + source = "registry+https://github.com/rust-lang/crates.io-index" 4112 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 4113 + dependencies = [ 4114 + "windows-link 0.2.1", 4115 + ] 4116 + 4117 + [[package]] 4118 + name = "windows-targets" 4119 + version = "0.42.2" 4120 + source = "registry+https://github.com/rust-lang/crates.io-index" 4121 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 4122 + dependencies = [ 4123 + "windows_aarch64_gnullvm 0.42.2", 4124 + "windows_aarch64_msvc 0.42.2", 4125 + "windows_i686_gnu 0.42.2", 4126 + "windows_i686_msvc 0.42.2", 4127 + "windows_x86_64_gnu 0.42.2", 4128 + "windows_x86_64_gnullvm 0.42.2", 4129 + "windows_x86_64_msvc 0.42.2", 4130 + ] 4131 + 4132 + [[package]] 4133 + name = "windows-targets" 4134 + version = "0.48.5" 4135 + source = "registry+https://github.com/rust-lang/crates.io-index" 4136 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 4137 + dependencies = [ 4138 + "windows_aarch64_gnullvm 0.48.5", 4139 + "windows_aarch64_msvc 0.48.5", 4140 + "windows_i686_gnu 0.48.5", 4141 + "windows_i686_msvc 0.48.5", 4142 + "windows_x86_64_gnu 0.48.5", 4143 + "windows_x86_64_gnullvm 0.48.5", 4144 + "windows_x86_64_msvc 0.48.5", 4145 + ] 4146 + 4147 + [[package]] 4148 + name = "windows-targets" 4149 + version = "0.52.6" 4150 + source = "registry+https://github.com/rust-lang/crates.io-index" 4151 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 4152 + dependencies = [ 4153 + "windows_aarch64_gnullvm 0.52.6", 4154 + "windows_aarch64_msvc 0.52.6", 4155 + "windows_i686_gnu 0.52.6", 4156 + "windows_i686_gnullvm 0.52.6", 4157 + "windows_i686_msvc 0.52.6", 4158 + "windows_x86_64_gnu 0.52.6", 4159 + "windows_x86_64_gnullvm 0.52.6", 4160 + "windows_x86_64_msvc 0.52.6", 4161 + ] 4162 + 4163 + [[package]] 4164 + name = "windows-targets" 4165 + version = "0.53.5" 4166 + source = "registry+https://github.com/rust-lang/crates.io-index" 4167 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 4168 + dependencies = [ 4169 + "windows-link 0.2.1", 4170 + "windows_aarch64_gnullvm 0.53.1", 4171 + "windows_aarch64_msvc 0.53.1", 4172 + "windows_i686_gnu 0.53.1", 4173 + "windows_i686_gnullvm 0.53.1", 4174 + "windows_i686_msvc 0.53.1", 4175 + "windows_x86_64_gnu 0.53.1", 4176 + "windows_x86_64_gnullvm 0.53.1", 4177 + "windows_x86_64_msvc 0.53.1", 4178 + ] 4179 + 4180 + [[package]] 4181 + name = "windows_aarch64_gnullvm" 4182 + version = "0.42.2" 4183 + source = "registry+https://github.com/rust-lang/crates.io-index" 4184 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 4185 + 4186 + [[package]] 4187 + name = "windows_aarch64_gnullvm" 4188 + version = "0.48.5" 4189 + source = "registry+https://github.com/rust-lang/crates.io-index" 4190 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 4191 + 4192 + [[package]] 4193 + name = "windows_aarch64_gnullvm" 4194 + version = "0.52.6" 4195 + source = "registry+https://github.com/rust-lang/crates.io-index" 4196 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 4197 + 4198 + [[package]] 4199 + name = "windows_aarch64_gnullvm" 4200 + version = "0.53.1" 4201 + source = "registry+https://github.com/rust-lang/crates.io-index" 4202 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 4203 + 4204 + [[package]] 4205 + name = "windows_aarch64_msvc" 4206 + version = "0.42.2" 4207 + source = "registry+https://github.com/rust-lang/crates.io-index" 4208 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 4209 + 4210 + [[package]] 4211 + name = "windows_aarch64_msvc" 4212 + version = "0.48.5" 4213 + source = "registry+https://github.com/rust-lang/crates.io-index" 4214 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 4215 + 4216 + [[package]] 4217 + name = "windows_aarch64_msvc" 4218 + version = "0.52.6" 4219 + source = "registry+https://github.com/rust-lang/crates.io-index" 4220 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 4221 + 4222 + [[package]] 4223 + name = "windows_aarch64_msvc" 4224 + version = "0.53.1" 4225 + source = "registry+https://github.com/rust-lang/crates.io-index" 4226 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 4227 + 4228 + [[package]] 4229 + name = "windows_i686_gnu" 4230 + version = "0.42.2" 4231 + source = "registry+https://github.com/rust-lang/crates.io-index" 4232 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 4233 + 4234 + [[package]] 4235 + name = "windows_i686_gnu" 4236 + version = "0.48.5" 4237 + source = "registry+https://github.com/rust-lang/crates.io-index" 4238 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 4239 + 4240 + [[package]] 4241 + name = "windows_i686_gnu" 4242 + version = "0.52.6" 4243 + source = "registry+https://github.com/rust-lang/crates.io-index" 4244 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 4245 + 4246 + [[package]] 4247 + name = "windows_i686_gnu" 4248 + version = "0.53.1" 4249 + source = "registry+https://github.com/rust-lang/crates.io-index" 4250 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 4251 + 4252 + [[package]] 4253 + name = "windows_i686_gnullvm" 4254 + version = "0.52.6" 4255 + source = "registry+https://github.com/rust-lang/crates.io-index" 4256 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 4257 + 4258 + [[package]] 4259 + name = "windows_i686_gnullvm" 4260 + version = "0.53.1" 4261 + source = "registry+https://github.com/rust-lang/crates.io-index" 4262 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 4263 + 4264 + [[package]] 4265 + name = "windows_i686_msvc" 4266 + version = "0.42.2" 4267 + source = "registry+https://github.com/rust-lang/crates.io-index" 4268 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 4269 + 4270 + [[package]] 4271 + name = "windows_i686_msvc" 4272 + version = "0.48.5" 4273 + source = "registry+https://github.com/rust-lang/crates.io-index" 4274 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 4275 + 4276 + [[package]] 4277 + name = "windows_i686_msvc" 4278 + version = "0.52.6" 4279 + source = "registry+https://github.com/rust-lang/crates.io-index" 4280 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 4281 + 4282 + [[package]] 4283 + name = "windows_i686_msvc" 4284 + version = "0.53.1" 4285 + source = "registry+https://github.com/rust-lang/crates.io-index" 4286 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 4287 + 4288 + [[package]] 4289 + name = "windows_x86_64_gnu" 4290 + version = "0.42.2" 4291 + source = "registry+https://github.com/rust-lang/crates.io-index" 4292 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 4293 + 4294 + [[package]] 4295 + name = "windows_x86_64_gnu" 4296 + version = "0.48.5" 4297 + source = "registry+https://github.com/rust-lang/crates.io-index" 4298 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 4299 + 4300 + [[package]] 4301 + name = "windows_x86_64_gnu" 4302 + version = "0.52.6" 4303 + source = "registry+https://github.com/rust-lang/crates.io-index" 4304 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 4305 + 4306 + [[package]] 4307 + name = "windows_x86_64_gnu" 4308 + version = "0.53.1" 4309 + source = "registry+https://github.com/rust-lang/crates.io-index" 4310 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 4311 + 4312 + [[package]] 4313 + name = "windows_x86_64_gnullvm" 4314 + version = "0.42.2" 4315 + source = "registry+https://github.com/rust-lang/crates.io-index" 4316 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 4317 + 4318 + [[package]] 4319 + name = "windows_x86_64_gnullvm" 4320 + version = "0.48.5" 4321 + source = "registry+https://github.com/rust-lang/crates.io-index" 4322 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 4323 + 4324 + [[package]] 4325 + name = "windows_x86_64_gnullvm" 4326 + version = "0.52.6" 4327 + source = "registry+https://github.com/rust-lang/crates.io-index" 4328 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 4329 + 4330 + [[package]] 4331 + name = "windows_x86_64_gnullvm" 4332 + version = "0.53.1" 4333 + source = "registry+https://github.com/rust-lang/crates.io-index" 4334 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 4335 + 4336 + [[package]] 4337 + name = "windows_x86_64_msvc" 4338 + version = "0.42.2" 4339 + source = "registry+https://github.com/rust-lang/crates.io-index" 4340 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 4341 + 4342 + [[package]] 4343 + name = "windows_x86_64_msvc" 4344 + version = "0.48.5" 4345 + source = "registry+https://github.com/rust-lang/crates.io-index" 4346 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 4347 + 4348 + [[package]] 4349 + name = "windows_x86_64_msvc" 4350 + version = "0.52.6" 4351 + source = "registry+https://github.com/rust-lang/crates.io-index" 4352 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 4353 + 4354 + [[package]] 4355 + name = "windows_x86_64_msvc" 4356 + version = "0.53.1" 4357 + source = "registry+https://github.com/rust-lang/crates.io-index" 4358 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 4359 + 4360 + [[package]] 4361 + name = "winreg" 4362 + version = "0.50.0" 4363 + source = "registry+https://github.com/rust-lang/crates.io-index" 4364 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 4365 + dependencies = [ 4366 + "cfg-if", 4367 + "windows-sys 0.48.0", 4368 + ] 4369 + 4370 + [[package]] 4371 + name = "wisp-cli" 4372 + version = "0.1.0" 4373 + dependencies = [ 4374 + "base64 0.22.1", 4375 + "bytes", 4376 + "clap", 4377 + "flate2", 4378 + "futures", 4379 + "jacquard", 4380 + "jacquard-api", 4381 + "jacquard-common", 4382 + "jacquard-derive", 4383 + "jacquard-identity", 4384 + "jacquard-lexicon", 4385 + "jacquard-oauth", 4386 + "miette", 4387 + "mime_guess", 4388 + "reqwest", 4389 + "rustversion", 4390 + "serde", 4391 + "serde_json", 4392 + "shellexpand", 4393 + "tokio", 4394 + "walkdir", 4395 + ] 4396 + 4397 + [[package]] 4398 + name = "wit-bindgen" 4399 + version = "0.46.0" 4400 + source = "registry+https://github.com/rust-lang/crates.io-index" 4401 + checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 4402 + 4403 + [[package]] 4404 + name = "writeable" 4405 + version = "0.6.2" 4406 + source = "registry+https://github.com/rust-lang/crates.io-index" 4407 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 4408 + 4409 + [[package]] 4410 + name = "xml5ever" 4411 + version = "0.18.1" 4412 + source = "registry+https://github.com/rust-lang/crates.io-index" 4413 + checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" 4414 + dependencies = [ 4415 + "log", 4416 + "mac", 4417 + "markup5ever", 4418 + ] 4419 + 4420 + [[package]] 4421 + name = "yansi" 4422 + version = "1.0.1" 4423 + source = "registry+https://github.com/rust-lang/crates.io-index" 4424 + checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 4425 + 4426 + [[package]] 4427 + name = "yoke" 4428 + version = "0.8.1" 4429 + source = "registry+https://github.com/rust-lang/crates.io-index" 4430 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 4431 + dependencies = [ 4432 + "stable_deref_trait", 4433 + "yoke-derive", 4434 + "zerofrom", 4435 + ] 4436 + 4437 + [[package]] 4438 + name = "yoke-derive" 4439 + version = "0.8.1" 4440 + source = "registry+https://github.com/rust-lang/crates.io-index" 4441 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 4442 + dependencies = [ 4443 + "proc-macro2", 4444 + "quote", 4445 + "syn 2.0.108", 4446 + "synstructure", 4447 + ] 4448 + 4449 + [[package]] 4450 + name = "zerocopy" 4451 + version = "0.8.27" 4452 + source = "registry+https://github.com/rust-lang/crates.io-index" 4453 + checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 4454 + dependencies = [ 4455 + "zerocopy-derive", 4456 + ] 4457 + 4458 + [[package]] 4459 + name = "zerocopy-derive" 4460 + version = "0.8.27" 4461 + source = "registry+https://github.com/rust-lang/crates.io-index" 4462 + checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 4463 + dependencies = [ 4464 + "proc-macro2", 4465 + "quote", 4466 + "syn 2.0.108", 4467 + ] 4468 + 4469 + [[package]] 4470 + name = "zerofrom" 4471 + version = "0.1.6" 4472 + source = "registry+https://github.com/rust-lang/crates.io-index" 4473 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 4474 + dependencies = [ 4475 + "zerofrom-derive", 4476 + ] 4477 + 4478 + [[package]] 4479 + name = "zerofrom-derive" 4480 + version = "0.1.6" 4481 + source = "registry+https://github.com/rust-lang/crates.io-index" 4482 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 4483 + dependencies = [ 4484 + "proc-macro2", 4485 + "quote", 4486 + "syn 2.0.108", 4487 + "synstructure", 4488 + ] 4489 + 4490 + [[package]] 4491 + name = "zeroize" 4492 + version = "1.8.2" 4493 + source = "registry+https://github.com/rust-lang/crates.io-index" 4494 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 4495 + dependencies = [ 4496 + "serde", 4497 + ] 4498 + 4499 + [[package]] 4500 + name = "zerotrie" 4501 + version = "0.2.3" 4502 + source = "registry+https://github.com/rust-lang/crates.io-index" 4503 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 4504 + dependencies = [ 4505 + "displaydoc", 4506 + "yoke", 4507 + "zerofrom", 4508 + ] 4509 + 4510 + [[package]] 4511 + name = "zerovec" 4512 + version = "0.11.5" 4513 + source = "registry+https://github.com/rust-lang/crates.io-index" 4514 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 4515 + dependencies = [ 4516 + "yoke", 4517 + "zerofrom", 4518 + "zerovec-derive", 4519 + ] 4520 + 4521 + [[package]] 4522 + name = "zerovec-derive" 4523 + version = "0.11.2" 4524 + source = "registry+https://github.com/rust-lang/crates.io-index" 4525 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 4526 + dependencies = [ 4527 + "proc-macro2", 4528 + "quote", 4529 + "syn 2.0.108", 4530 + ]
+32
cli/Cargo.toml
··· 1 + [package] 2 + name = "wisp-cli" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [features] 7 + default = ["place_wisp"] 8 + place_wisp = [] 9 + 10 + [dependencies] 11 + jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] } 12 + jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 13 + jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 14 + jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 15 + jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] } 16 + jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 17 + jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 18 + clap = { version = "4.5.51", features = ["derive"] } 19 + tokio = { version = "1.48", features = ["full"] } 20 + miette = { version = "7.6.0", features = ["fancy"] } 21 + serde_json = "1.0.145" 22 + serde = { version = "1.0", features = ["derive"] } 23 + shellexpand = "3.1.1" 24 + #reqwest = "0.12" 25 + reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 26 + rustversion = "1.0" 27 + flate2 = "1.0" 28 + base64 = "0.22" 29 + walkdir = "2.5" 30 + mime_guess = "2.0" 31 + bytes = "1.10" 32 + futures = "0.3.31"
+23
cli/build-linux.sh
··· 1 + #!/usr/bin/env bash 2 + # Build Linux binaries (statically linked) 3 + set -e 4 + mkdir -p binaries 5 + 6 + # Build Linux binaries 7 + echo "Building Linux binaries..." 8 + 9 + echo "Building Linux ARM64 (static)..." 10 + nix-shell -p rustup --run ' 11 + rustup target add aarch64-unknown-linux-musl 12 + RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl 13 + ' 14 + cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux 15 + 16 + echo "Building Linux x86_64 (static)..." 17 + nix-shell -p rustup --run ' 18 + rustup target add x86_64-unknown-linux-musl 19 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl 20 + ' 21 + cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux 22 + 23 + echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
··· 1 + #!/bin/bash 2 + # Build Linux and macOS binaries 3 + 4 + set -e 5 + 6 + mkdir -p binaries 7 + rm -rf target 8 + 9 + # Build macOS binaries natively 10 + echo "Building macOS binaries..." 11 + rustup target add aarch64-apple-darwin 12 + 13 + echo "Building macOS arm64 binary." 14 + RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin 15 + cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
+51
cli/lexicons/place/wisp/fs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.fs", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Virtual filesystem manifest for a Wisp site", 8 + "record": { 9 + "type": "object", 10 + "required": ["site", "root", "createdAt"], 11 + "properties": { 12 + "site": { "type": "string" }, 13 + "root": { "type": "ref", "ref": "#directory" }, 14 + "fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, 15 + "createdAt": { "type": "string", "format": "datetime" } 16 + } 17 + } 18 + }, 19 + "file": { 20 + "type": "object", 21 + "required": ["type", "blob"], 22 + "properties": { 23 + "type": { "type": "string", "const": "file" }, 24 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" }, 25 + "encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" }, 26 + "mimeType": { "type": "string", "description": "Original MIME type before compression" }, 27 + "base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" } 28 + } 29 + }, 30 + "directory": { 31 + "type": "object", 32 + "required": ["type", "entries"], 33 + "properties": { 34 + "type": { "type": "string", "const": "directory" }, 35 + "entries": { 36 + "type": "array", 37 + "maxLength": 500, 38 + "items": { "type": "ref", "ref": "#entry" } 39 + } 40 + } 41 + }, 42 + "entry": { 43 + "type": "object", 44 + "required": ["name", "node"], 45 + "properties": { 46 + "name": { "type": "string", "maxLength": 255 }, 47 + "node": { "type": "union", "refs": ["#file", "#directory"] } 48 + } 49 + } 50 + } 51 + }
+43
cli/src/builder_types.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // This file was automatically generated from Lexicon schemas. 4 + // Any manual changes will be overwritten on the next regeneration. 5 + 6 + /// Marker type indicating a builder field has been set 7 + pub struct Set<T>(pub T); 8 + impl<T> Set<T> { 9 + /// Extract the inner value 10 + #[inline] 11 + pub fn into_inner(self) -> T { 12 + self.0 13 + } 14 + } 15 + 16 + /// Marker type indicating a builder field has not been set 17 + pub struct Unset; 18 + /// Trait indicating a builder field is set (has a value) 19 + #[rustversion::attr( 20 + since(1.78.0), 21 + diagnostic::on_unimplemented( 22 + message = "the field `{Self}` was not set, but this method requires it to be set", 23 + label = "the field `{Self}` was not set" 24 + ) 25 + )] 26 + pub trait IsSet: private::Sealed {} 27 + /// Trait indicating a builder field is unset (no value yet) 28 + #[rustversion::attr( 29 + since(1.78.0), 30 + diagnostic::on_unimplemented( 31 + message = "the field `{Self}` was already set, but this method requires it to be unset", 32 + label = "the field `{Self}` was already set" 33 + ) 34 + )] 35 + pub trait IsUnset: private::Sealed {} 36 + impl<T> IsSet for Set<T> {} 37 + impl IsUnset for Unset {} 38 + mod private { 39 + /// Sealed trait to prevent external implementations 40 + pub trait Sealed {} 41 + impl<T> Sealed for super::Set<T> {} 42 + impl Sealed for super::Unset {} 43 + }
+263
cli/src/main.rs
··· 1 + mod builder_types; 2 + mod place_wisp; 3 + 4 + use clap::Parser; 5 + use jacquard::CowStr; 6 + use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession}; 7 + use jacquard::oauth::client::OAuthClient; 8 + use jacquard::oauth::loopback::LoopbackConfig; 9 + use jacquard::prelude::IdentityResolver; 10 + use jacquard_common::types::string::{Datetime, Rkey, RecordKey}; 11 + use jacquard_common::types::blob::MimeType; 12 + use miette::IntoDiagnostic; 13 + use std::path::{Path, PathBuf}; 14 + use flate2::Compression; 15 + use flate2::write::GzEncoder; 16 + use std::io::Write; 17 + use base64::Engine; 18 + use futures::stream::{self, StreamExt}; 19 + 20 + use place_wisp::fs::*; 21 + 22 + #[derive(Parser, Debug)] 23 + #[command(author, version, about = "Deploy a static site to wisp.place")] 24 + struct Args { 25 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 26 + input: CowStr<'static>, 27 + 28 + /// Path to the directory containing your static site 29 + #[arg(short, long, default_value = ".")] 30 + path: PathBuf, 31 + 32 + /// Site name (defaults to directory name) 33 + #[arg(short, long)] 34 + site: Option<String>, 35 + 36 + /// Path to auth store file (will be created if missing, only used with OAuth) 37 + #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 38 + store: String, 39 + 40 + /// App Password for authentication (alternative to OAuth) 41 + #[arg(long)] 42 + password: Option<CowStr<'static>>, 43 + } 44 + 45 + #[tokio::main] 46 + async fn main() -> miette::Result<()> { 47 + let args = Args::parse(); 48 + 49 + // Dispatch to appropriate authentication method 50 + if let Some(password) = args.password { 51 + run_with_app_password(args.input, password, args.path, args.site).await 52 + } else { 53 + run_with_oauth(args.input, args.store, args.path, args.site).await 54 + } 55 + } 56 + 57 + /// Run deployment with app password authentication 58 + async fn run_with_app_password( 59 + input: CowStr<'static>, 60 + password: CowStr<'static>, 61 + path: PathBuf, 62 + site: Option<String>, 63 + ) -> miette::Result<()> { 64 + let (session, auth) = 65 + MemoryCredentialSession::authenticated(input, password, None).await?; 66 + println!("Signed in as {}", auth.handle); 67 + 68 + let agent: Agent<_> = Agent::from(session); 69 + deploy_site(&agent, path, site).await 70 + } 71 + 72 + /// Run deployment with OAuth authentication 73 + async fn run_with_oauth( 74 + input: CowStr<'static>, 75 + store: String, 76 + path: PathBuf, 77 + site: Option<String>, 78 + ) -> miette::Result<()> { 79 + let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store)); 80 + let session = oauth 81 + .login_with_local_server(input, Default::default(), LoopbackConfig::default()) 82 + .await?; 83 + 84 + let agent: Agent<_> = Agent::from(session); 85 + deploy_site(&agent, path, site).await 86 + } 87 + 88 + /// Deploy the site using the provided agent 89 + async fn deploy_site( 90 + agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 91 + path: PathBuf, 92 + site: Option<String>, 93 + ) -> miette::Result<()> { 94 + // Verify the path exists 95 + if !path.exists() { 96 + return Err(miette::miette!("Path does not exist: {}", path.display())); 97 + } 98 + 99 + // Get site name 100 + let site_name = site.unwrap_or_else(|| { 101 + path 102 + .file_name() 103 + .and_then(|n| n.to_str()) 104 + .unwrap_or("site") 105 + .to_string() 106 + }); 107 + 108 + println!("Deploying site '{}'...", site_name); 109 + 110 + // Build directory tree 111 + let root_dir = build_directory(agent, &path).await?; 112 + 113 + // Count total files 114 + let file_count = count_files(&root_dir); 115 + 116 + // Create the Fs record 117 + let fs_record = Fs::new() 118 + .site(CowStr::from(site_name.clone())) 119 + .root(root_dir) 120 + .file_count(file_count as i64) 121 + .created_at(Datetime::now()) 122 + .build(); 123 + 124 + // Use site name as the record key 125 + let rkey = Rkey::new(&site_name).map_err(|e| miette::miette!("Invalid rkey: {}", e))?; 126 + let output = agent.put_record(RecordKey::from(rkey), fs_record).await?; 127 + 128 + // Extract DID from the AT URI (format: at://did:plc:xxx/collection/rkey) 129 + let uri_str = output.uri.to_string(); 130 + let did = uri_str 131 + .strip_prefix("at://") 132 + .and_then(|s| s.split('/').next()) 133 + .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 134 + 135 + println!("Deployed site '{}': {}", site_name, output.uri); 136 + println!("Available at: https://sites.wisp.place/{}/{}", did, site_name); 137 + 138 + Ok(()) 139 + } 140 + 141 + /// Recursively build a Directory from a filesystem path 142 + fn build_directory<'a>( 143 + agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 144 + dir_path: &'a Path, 145 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>> 146 + { 147 + Box::pin(async move { 148 + // Collect all directory entries first 149 + let dir_entries: Vec<_> = std::fs::read_dir(dir_path) 150 + .into_diagnostic()? 151 + .collect::<Result<Vec<_>, _>>() 152 + .into_diagnostic()?; 153 + 154 + // Separate files and directories 155 + let mut file_tasks = Vec::new(); 156 + let mut dir_tasks = Vec::new(); 157 + 158 + for entry in dir_entries { 159 + let path = entry.path(); 160 + let name = entry.file_name(); 161 + let name_str = name.to_str() 162 + .ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))? 163 + .to_string(); 164 + 165 + // Skip hidden files 166 + if name_str.starts_with('.') { 167 + continue; 168 + } 169 + 170 + let metadata = entry.metadata().into_diagnostic()?; 171 + 172 + if metadata.is_file() { 173 + file_tasks.push((name_str, path)); 174 + } else if metadata.is_dir() { 175 + dir_tasks.push((name_str, path)); 176 + } 177 + } 178 + 179 + // Process files concurrently with a limit of 5 180 + let file_entries: Vec<Entry> = stream::iter(file_tasks) 181 + .map(|(name, path)| async move { 182 + let file_node = process_file(agent, &path).await?; 183 + Ok::<_, miette::Report>(Entry::new() 184 + .name(CowStr::from(name)) 185 + .node(EntryNode::File(Box::new(file_node))) 186 + .build()) 187 + }) 188 + .buffer_unordered(5) 189 + .collect::<Vec<_>>() 190 + .await 191 + .into_iter() 192 + .collect::<miette::Result<Vec<_>>>()?; 193 + 194 + // Process directories recursively (sequentially to avoid too much nesting) 195 + let mut dir_entries = Vec::new(); 196 + for (name, path) in dir_tasks { 197 + let subdir = build_directory(agent, &path).await?; 198 + dir_entries.push(Entry::new() 199 + .name(CowStr::from(name)) 200 + .node(EntryNode::Directory(Box::new(subdir))) 201 + .build()); 202 + } 203 + 204 + // Combine file and directory entries 205 + let mut entries = file_entries; 206 + entries.extend(dir_entries); 207 + 208 + Ok(Directory::new() 209 + .r#type(CowStr::from("directory")) 210 + .entries(entries) 211 + .build()) 212 + }) 213 + } 214 + 215 + /// Process a single file: gzip -> base64 -> upload blob 216 + async fn process_file( 217 + agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 218 + file_path: &Path, 219 + ) -> miette::Result<File<'static>> 220 + { 221 + // Read file 222 + let file_data = std::fs::read(file_path).into_diagnostic()?; 223 + 224 + // Detect original MIME type 225 + let original_mime = mime_guess::from_path(file_path) 226 + .first_or_octet_stream() 227 + .to_string(); 228 + 229 + // Gzip compress 230 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 231 + encoder.write_all(&file_data).into_diagnostic()?; 232 + let gzipped = encoder.finish().into_diagnostic()?; 233 + 234 + // Base64 encode the gzipped data 235 + let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 236 + 237 + // Upload blob as octet-stream 238 + let blob = agent.upload_blob( 239 + base64_bytes, 240 + MimeType::new_static("application/octet-stream"), 241 + ).await?; 242 + 243 + Ok(File::new() 244 + .r#type(CowStr::from("file")) 245 + .blob(blob) 246 + .encoding(CowStr::from("gzip")) 247 + .mime_type(CowStr::from(original_mime)) 248 + .base64(true) 249 + .build()) 250 + } 251 + 252 + /// Count total files in a directory tree 253 + fn count_files(dir: &Directory) -> usize { 254 + let mut count = 0; 255 + for entry in &dir.entries { 256 + match &entry.node { 257 + EntryNode::File(_) => count += 1, 258 + EntryNode::Directory(subdir) => count += count_files(subdir), 259 + _ => {} // Unknown variants 260 + } 261 + } 262 + count 263 + }
+9
cli/src/mod.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // This file was automatically generated from Lexicon schemas. 4 + // Any manual changes will be overwritten on the next regeneration. 5 + 6 + pub mod builder_types; 7 + 8 + #[cfg(feature = "place_wisp")] 9 + pub mod place_wisp;
+1230
cli/src/place_wisp/fs.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: place.wisp.fs 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[jacquard_derive::lexicon] 9 + #[derive( 10 + serde::Serialize, 11 + serde::Deserialize, 12 + Debug, 13 + Clone, 14 + PartialEq, 15 + Eq, 16 + jacquard_derive::IntoStatic 17 + )] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct Directory<'a> { 20 + #[serde(borrow)] 21 + pub entries: Vec<crate::place_wisp::fs::Entry<'a>>, 22 + #[serde(borrow)] 23 + pub r#type: jacquard_common::CowStr<'a>, 24 + } 25 + 26 + pub mod directory_state { 27 + 28 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 29 + #[allow(unused)] 30 + use ::core::marker::PhantomData; 31 + mod sealed { 32 + pub trait Sealed {} 33 + } 34 + /// State trait tracking which required fields have been set 35 + pub trait State: sealed::Sealed { 36 + type Type; 37 + type Entries; 38 + } 39 + /// Empty state - all required fields are unset 40 + pub struct Empty(()); 41 + impl sealed::Sealed for Empty {} 42 + impl State for Empty { 43 + type Type = Unset; 44 + type Entries = Unset; 45 + } 46 + ///State transition - sets the `type` field to Set 47 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 48 + impl<S: State> sealed::Sealed for SetType<S> {} 49 + impl<S: State> State for SetType<S> { 50 + type Type = Set<members::r#type>; 51 + type Entries = S::Entries; 52 + } 53 + ///State transition - sets the `entries` field to Set 54 + pub struct SetEntries<S: State = Empty>(PhantomData<fn() -> S>); 55 + impl<S: State> sealed::Sealed for SetEntries<S> {} 56 + impl<S: State> State for SetEntries<S> { 57 + type Type = S::Type; 58 + type Entries = Set<members::entries>; 59 + } 60 + /// Marker types for field names 61 + #[allow(non_camel_case_types)] 62 + pub mod members { 63 + ///Marker type for the `type` field 64 + pub struct r#type(()); 65 + ///Marker type for the `entries` field 66 + pub struct entries(()); 67 + } 68 + } 69 + 70 + /// Builder for constructing an instance of this type 71 + pub struct DirectoryBuilder<'a, S: directory_state::State> { 72 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 73 + __unsafe_private_named: ( 74 + ::core::option::Option<Vec<crate::place_wisp::fs::Entry<'a>>>, 75 + ::core::option::Option<jacquard_common::CowStr<'a>>, 76 + ), 77 + _phantom: ::core::marker::PhantomData<&'a ()>, 78 + } 79 + 80 + impl<'a> Directory<'a> { 81 + /// Create a new builder for this type 82 + pub fn new() -> DirectoryBuilder<'a, directory_state::Empty> { 83 + DirectoryBuilder::new() 84 + } 85 + } 86 + 87 + impl<'a> DirectoryBuilder<'a, directory_state::Empty> { 88 + /// Create a new builder with all fields unset 89 + pub fn new() -> Self { 90 + DirectoryBuilder { 91 + _phantom_state: ::core::marker::PhantomData, 92 + __unsafe_private_named: (None, None), 93 + _phantom: ::core::marker::PhantomData, 94 + } 95 + } 96 + } 97 + 98 + impl<'a, S> DirectoryBuilder<'a, S> 99 + where 100 + S: directory_state::State, 101 + S::Entries: directory_state::IsUnset, 102 + { 103 + /// Set the `entries` field (required) 104 + pub fn entries( 105 + mut self, 106 + value: impl Into<Vec<crate::place_wisp::fs::Entry<'a>>>, 107 + ) -> DirectoryBuilder<'a, directory_state::SetEntries<S>> { 108 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 109 + DirectoryBuilder { 110 + _phantom_state: ::core::marker::PhantomData, 111 + __unsafe_private_named: self.__unsafe_private_named, 112 + _phantom: ::core::marker::PhantomData, 113 + } 114 + } 115 + } 116 + 117 + impl<'a, S> DirectoryBuilder<'a, S> 118 + where 119 + S: directory_state::State, 120 + S::Type: directory_state::IsUnset, 121 + { 122 + /// Set the `type` field (required) 123 + pub fn r#type( 124 + mut self, 125 + value: impl Into<jacquard_common::CowStr<'a>>, 126 + ) -> DirectoryBuilder<'a, directory_state::SetType<S>> { 127 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 128 + DirectoryBuilder { 129 + _phantom_state: ::core::marker::PhantomData, 130 + __unsafe_private_named: self.__unsafe_private_named, 131 + _phantom: ::core::marker::PhantomData, 132 + } 133 + } 134 + } 135 + 136 + impl<'a, S> DirectoryBuilder<'a, S> 137 + where 138 + S: directory_state::State, 139 + S::Type: directory_state::IsSet, 140 + S::Entries: directory_state::IsSet, 141 + { 142 + /// Build the final struct 143 + pub fn build(self) -> Directory<'a> { 144 + Directory { 145 + entries: self.__unsafe_private_named.0.unwrap(), 146 + r#type: self.__unsafe_private_named.1.unwrap(), 147 + extra_data: Default::default(), 148 + } 149 + } 150 + /// Build the final struct with custom extra_data 151 + pub fn build_with_data( 152 + self, 153 + extra_data: std::collections::BTreeMap< 154 + jacquard_common::smol_str::SmolStr, 155 + jacquard_common::types::value::Data<'a>, 156 + >, 157 + ) -> Directory<'a> { 158 + Directory { 159 + entries: self.__unsafe_private_named.0.unwrap(), 160 + r#type: self.__unsafe_private_named.1.unwrap(), 161 + extra_data: Some(extra_data), 162 + } 163 + } 164 + } 165 + 166 + fn lexicon_doc_place_wisp_fs() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 167 + ::jacquard_lexicon::lexicon::LexiconDoc { 168 + lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1, 169 + id: ::jacquard_common::CowStr::new_static("place.wisp.fs"), 170 + revision: None, 171 + description: None, 172 + defs: { 173 + let mut map = ::std::collections::BTreeMap::new(); 174 + map.insert( 175 + ::jacquard_common::smol_str::SmolStr::new_static("directory"), 176 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 177 + description: None, 178 + required: Some( 179 + vec![ 180 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 181 + ::jacquard_common::smol_str::SmolStr::new_static("entries") 182 + ], 183 + ), 184 + nullable: None, 185 + properties: { 186 + #[allow(unused_mut)] 187 + let mut map = ::std::collections::BTreeMap::new(); 188 + map.insert( 189 + ::jacquard_common::smol_str::SmolStr::new_static("entries"), 190 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 191 + description: None, 192 + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { 193 + description: None, 194 + r#ref: ::jacquard_common::CowStr::new_static("#entry"), 195 + }), 196 + min_length: None, 197 + max_length: Some(500usize), 198 + }), 199 + ); 200 + map.insert( 201 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 202 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 203 + description: None, 204 + format: None, 205 + default: None, 206 + min_length: None, 207 + max_length: None, 208 + min_graphemes: None, 209 + max_graphemes: None, 210 + r#enum: None, 211 + r#const: None, 212 + known_values: None, 213 + }), 214 + ); 215 + map 216 + }, 217 + }), 218 + ); 219 + map.insert( 220 + ::jacquard_common::smol_str::SmolStr::new_static("entry"), 221 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 222 + description: None, 223 + required: Some( 224 + vec![ 225 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 226 + ::jacquard_common::smol_str::SmolStr::new_static("node") 227 + ], 228 + ), 229 + nullable: None, 230 + properties: { 231 + #[allow(unused_mut)] 232 + let mut map = ::std::collections::BTreeMap::new(); 233 + map.insert( 234 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 235 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 236 + description: None, 237 + format: None, 238 + default: None, 239 + min_length: None, 240 + max_length: Some(255usize), 241 + min_graphemes: None, 242 + max_graphemes: None, 243 + r#enum: None, 244 + r#const: None, 245 + known_values: None, 246 + }), 247 + ); 248 + map.insert( 249 + ::jacquard_common::smol_str::SmolStr::new_static("node"), 250 + ::jacquard_lexicon::lexicon::LexObjectProperty::Union(::jacquard_lexicon::lexicon::LexRefUnion { 251 + description: None, 252 + refs: vec![ 253 + ::jacquard_common::CowStr::new_static("#file"), 254 + ::jacquard_common::CowStr::new_static("#directory") 255 + ], 256 + closed: None, 257 + }), 258 + ); 259 + map 260 + }, 261 + }), 262 + ); 263 + map.insert( 264 + ::jacquard_common::smol_str::SmolStr::new_static("file"), 265 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 266 + description: None, 267 + required: Some( 268 + vec![ 269 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 270 + ::jacquard_common::smol_str::SmolStr::new_static("blob") 271 + ], 272 + ), 273 + nullable: None, 274 + properties: { 275 + #[allow(unused_mut)] 276 + let mut map = ::std::collections::BTreeMap::new(); 277 + map.insert( 278 + ::jacquard_common::smol_str::SmolStr::new_static("base64"), 279 + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { 280 + description: None, 281 + default: None, 282 + r#const: None, 283 + }), 284 + ); 285 + map.insert( 286 + ::jacquard_common::smol_str::SmolStr::new_static("blob"), 287 + ::jacquard_lexicon::lexicon::LexObjectProperty::Blob(::jacquard_lexicon::lexicon::LexBlob { 288 + description: None, 289 + accept: None, 290 + max_size: None, 291 + }), 292 + ); 293 + map.insert( 294 + ::jacquard_common::smol_str::SmolStr::new_static("encoding"), 295 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 296 + description: Some( 297 + ::jacquard_common::CowStr::new_static( 298 + "Content encoding (e.g., gzip for compressed files)", 299 + ), 300 + ), 301 + format: None, 302 + default: None, 303 + min_length: None, 304 + max_length: None, 305 + min_graphemes: None, 306 + max_graphemes: None, 307 + r#enum: None, 308 + r#const: None, 309 + known_values: None, 310 + }), 311 + ); 312 + map.insert( 313 + ::jacquard_common::smol_str::SmolStr::new_static("mimeType"), 314 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 315 + description: Some( 316 + ::jacquard_common::CowStr::new_static( 317 + "Original MIME type before compression", 318 + ), 319 + ), 320 + format: None, 321 + default: None, 322 + min_length: None, 323 + max_length: None, 324 + min_graphemes: None, 325 + max_graphemes: None, 326 + r#enum: None, 327 + r#const: None, 328 + known_values: None, 329 + }), 330 + ); 331 + map.insert( 332 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 333 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 334 + description: None, 335 + format: None, 336 + default: None, 337 + min_length: None, 338 + max_length: None, 339 + min_graphemes: None, 340 + max_graphemes: None, 341 + r#enum: None, 342 + r#const: None, 343 + known_values: None, 344 + }), 345 + ); 346 + map 347 + }, 348 + }), 349 + ); 350 + map.insert( 351 + ::jacquard_common::smol_str::SmolStr::new_static("main"), 352 + ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord { 353 + description: Some( 354 + ::jacquard_common::CowStr::new_static( 355 + "Virtual filesystem manifest for a Wisp site", 356 + ), 357 + ), 358 + key: None, 359 + record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject { 360 + description: None, 361 + required: Some( 362 + vec![ 363 + ::jacquard_common::smol_str::SmolStr::new_static("site"), 364 + ::jacquard_common::smol_str::SmolStr::new_static("root"), 365 + ::jacquard_common::smol_str::SmolStr::new_static("createdAt") 366 + ], 367 + ), 368 + nullable: None, 369 + properties: { 370 + #[allow(unused_mut)] 371 + let mut map = ::std::collections::BTreeMap::new(); 372 + map.insert( 373 + ::jacquard_common::smol_str::SmolStr::new_static( 374 + "createdAt", 375 + ), 376 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 377 + description: None, 378 + format: Some( 379 + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, 380 + ), 381 + default: None, 382 + min_length: None, 383 + max_length: None, 384 + min_graphemes: None, 385 + max_graphemes: None, 386 + r#enum: None, 387 + r#const: None, 388 + known_values: None, 389 + }), 390 + ); 391 + map.insert( 392 + ::jacquard_common::smol_str::SmolStr::new_static( 393 + "fileCount", 394 + ), 395 + ::jacquard_lexicon::lexicon::LexObjectProperty::Integer(::jacquard_lexicon::lexicon::LexInteger { 396 + description: None, 397 + default: None, 398 + minimum: Some(0i64), 399 + maximum: Some(1000i64), 400 + r#enum: None, 401 + r#const: None, 402 + }), 403 + ); 404 + map.insert( 405 + ::jacquard_common::smol_str::SmolStr::new_static("root"), 406 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 407 + description: None, 408 + r#ref: ::jacquard_common::CowStr::new_static("#directory"), 409 + }), 410 + ); 411 + map.insert( 412 + ::jacquard_common::smol_str::SmolStr::new_static("site"), 413 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 414 + description: None, 415 + format: None, 416 + default: None, 417 + min_length: None, 418 + max_length: None, 419 + min_graphemes: None, 420 + max_graphemes: None, 421 + r#enum: None, 422 + r#const: None, 423 + known_values: None, 424 + }), 425 + ); 426 + map 427 + }, 428 + }), 429 + }), 430 + ); 431 + map 432 + }, 433 + } 434 + } 435 + 436 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Directory<'a> { 437 + fn nsid() -> &'static str { 438 + "place.wisp.fs" 439 + } 440 + fn def_name() -> &'static str { 441 + "directory" 442 + } 443 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 444 + lexicon_doc_place_wisp_fs() 445 + } 446 + fn validate( 447 + &self, 448 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 449 + { 450 + let value = &self.entries; 451 + #[allow(unused_comparisons)] 452 + if value.len() > 500usize { 453 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 454 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 455 + "entries", 456 + ), 457 + max: 500usize, 458 + actual: value.len(), 459 + }); 460 + } 461 + } 462 + Ok(()) 463 + } 464 + } 465 + 466 + #[jacquard_derive::lexicon] 467 + #[derive( 468 + serde::Serialize, 469 + serde::Deserialize, 470 + Debug, 471 + Clone, 472 + PartialEq, 473 + Eq, 474 + jacquard_derive::IntoStatic 475 + )] 476 + #[serde(rename_all = "camelCase")] 477 + pub struct Entry<'a> { 478 + #[serde(borrow)] 479 + pub name: jacquard_common::CowStr<'a>, 480 + #[serde(borrow)] 481 + pub node: EntryNode<'a>, 482 + } 483 + 484 + pub mod entry_state { 485 + 486 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 487 + #[allow(unused)] 488 + use ::core::marker::PhantomData; 489 + mod sealed { 490 + pub trait Sealed {} 491 + } 492 + /// State trait tracking which required fields have been set 493 + pub trait State: sealed::Sealed { 494 + type Name; 495 + type Node; 496 + } 497 + /// Empty state - all required fields are unset 498 + pub struct Empty(()); 499 + impl sealed::Sealed for Empty {} 500 + impl State for Empty { 501 + type Name = Unset; 502 + type Node = Unset; 503 + } 504 + ///State transition - sets the `name` field to Set 505 + pub struct SetName<S: State = Empty>(PhantomData<fn() -> S>); 506 + impl<S: State> sealed::Sealed for SetName<S> {} 507 + impl<S: State> State for SetName<S> { 508 + type Name = Set<members::name>; 509 + type Node = S::Node; 510 + } 511 + ///State transition - sets the `node` field to Set 512 + pub struct SetNode<S: State = Empty>(PhantomData<fn() -> S>); 513 + impl<S: State> sealed::Sealed for SetNode<S> {} 514 + impl<S: State> State for SetNode<S> { 515 + type Name = S::Name; 516 + type Node = Set<members::node>; 517 + } 518 + /// Marker types for field names 519 + #[allow(non_camel_case_types)] 520 + pub mod members { 521 + ///Marker type for the `name` field 522 + pub struct name(()); 523 + ///Marker type for the `node` field 524 + pub struct node(()); 525 + } 526 + } 527 + 528 + /// Builder for constructing an instance of this type 529 + pub struct EntryBuilder<'a, S: entry_state::State> { 530 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 531 + __unsafe_private_named: ( 532 + ::core::option::Option<jacquard_common::CowStr<'a>>, 533 + ::core::option::Option<EntryNode<'a>>, 534 + ), 535 + _phantom: ::core::marker::PhantomData<&'a ()>, 536 + } 537 + 538 + impl<'a> Entry<'a> { 539 + /// Create a new builder for this type 540 + pub fn new() -> EntryBuilder<'a, entry_state::Empty> { 541 + EntryBuilder::new() 542 + } 543 + } 544 + 545 + impl<'a> EntryBuilder<'a, entry_state::Empty> { 546 + /// Create a new builder with all fields unset 547 + pub fn new() -> Self { 548 + EntryBuilder { 549 + _phantom_state: ::core::marker::PhantomData, 550 + __unsafe_private_named: (None, None), 551 + _phantom: ::core::marker::PhantomData, 552 + } 553 + } 554 + } 555 + 556 + impl<'a, S> EntryBuilder<'a, S> 557 + where 558 + S: entry_state::State, 559 + S::Name: entry_state::IsUnset, 560 + { 561 + /// Set the `name` field (required) 562 + pub fn name( 563 + mut self, 564 + value: impl Into<jacquard_common::CowStr<'a>>, 565 + ) -> EntryBuilder<'a, entry_state::SetName<S>> { 566 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 567 + EntryBuilder { 568 + _phantom_state: ::core::marker::PhantomData, 569 + __unsafe_private_named: self.__unsafe_private_named, 570 + _phantom: ::core::marker::PhantomData, 571 + } 572 + } 573 + } 574 + 575 + impl<'a, S> EntryBuilder<'a, S> 576 + where 577 + S: entry_state::State, 578 + S::Node: entry_state::IsUnset, 579 + { 580 + /// Set the `node` field (required) 581 + pub fn node( 582 + mut self, 583 + value: impl Into<EntryNode<'a>>, 584 + ) -> EntryBuilder<'a, entry_state::SetNode<S>> { 585 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 586 + EntryBuilder { 587 + _phantom_state: ::core::marker::PhantomData, 588 + __unsafe_private_named: self.__unsafe_private_named, 589 + _phantom: ::core::marker::PhantomData, 590 + } 591 + } 592 + } 593 + 594 + impl<'a, S> EntryBuilder<'a, S> 595 + where 596 + S: entry_state::State, 597 + S::Name: entry_state::IsSet, 598 + S::Node: entry_state::IsSet, 599 + { 600 + /// Build the final struct 601 + pub fn build(self) -> Entry<'a> { 602 + Entry { 603 + name: self.__unsafe_private_named.0.unwrap(), 604 + node: self.__unsafe_private_named.1.unwrap(), 605 + extra_data: Default::default(), 606 + } 607 + } 608 + /// Build the final struct with custom extra_data 609 + pub fn build_with_data( 610 + self, 611 + extra_data: std::collections::BTreeMap< 612 + jacquard_common::smol_str::SmolStr, 613 + jacquard_common::types::value::Data<'a>, 614 + >, 615 + ) -> Entry<'a> { 616 + Entry { 617 + name: self.__unsafe_private_named.0.unwrap(), 618 + node: self.__unsafe_private_named.1.unwrap(), 619 + extra_data: Some(extra_data), 620 + } 621 + } 622 + } 623 + 624 + #[jacquard_derive::open_union] 625 + #[derive( 626 + serde::Serialize, 627 + serde::Deserialize, 628 + Debug, 629 + Clone, 630 + PartialEq, 631 + Eq, 632 + jacquard_derive::IntoStatic 633 + )] 634 + #[serde(tag = "$type")] 635 + #[serde(bound(deserialize = "'de: 'a"))] 636 + pub enum EntryNode<'a> { 637 + #[serde(rename = "place.wisp.fs#file")] 638 + File(Box<crate::place_wisp::fs::File<'a>>), 639 + #[serde(rename = "place.wisp.fs#directory")] 640 + Directory(Box<crate::place_wisp::fs::Directory<'a>>), 641 + } 642 + 643 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Entry<'a> { 644 + fn nsid() -> &'static str { 645 + "place.wisp.fs" 646 + } 647 + fn def_name() -> &'static str { 648 + "entry" 649 + } 650 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 651 + lexicon_doc_place_wisp_fs() 652 + } 653 + fn validate( 654 + &self, 655 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 656 + { 657 + let value = &self.name; 658 + #[allow(unused_comparisons)] 659 + if <str>::len(value.as_ref()) > 255usize { 660 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 661 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 662 + "name", 663 + ), 664 + max: 255usize, 665 + actual: <str>::len(value.as_ref()), 666 + }); 667 + } 668 + } 669 + Ok(()) 670 + } 671 + } 672 + 673 + #[jacquard_derive::lexicon] 674 + #[derive( 675 + serde::Serialize, 676 + serde::Deserialize, 677 + Debug, 678 + Clone, 679 + PartialEq, 680 + Eq, 681 + jacquard_derive::IntoStatic 682 + )] 683 + #[serde(rename_all = "camelCase")] 684 + pub struct File<'a> { 685 + /// True if blob content is base64-encoded (used to bypass PDS content sniffing) 686 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 687 + pub base64: Option<bool>, 688 + /// Content blob ref 689 + #[serde(borrow)] 690 + pub blob: jacquard_common::types::blob::BlobRef<'a>, 691 + /// Content encoding (e.g., gzip for compressed files) 692 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 693 + #[serde(borrow)] 694 + pub encoding: Option<jacquard_common::CowStr<'a>>, 695 + /// Original MIME type before compression 696 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 697 + #[serde(borrow)] 698 + pub mime_type: Option<jacquard_common::CowStr<'a>>, 699 + #[serde(borrow)] 700 + pub r#type: jacquard_common::CowStr<'a>, 701 + } 702 + 703 + pub mod file_state { 704 + 705 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 706 + #[allow(unused)] 707 + use ::core::marker::PhantomData; 708 + mod sealed { 709 + pub trait Sealed {} 710 + } 711 + /// State trait tracking which required fields have been set 712 + pub trait State: sealed::Sealed { 713 + type Type; 714 + type Blob; 715 + } 716 + /// Empty state - all required fields are unset 717 + pub struct Empty(()); 718 + impl sealed::Sealed for Empty {} 719 + impl State for Empty { 720 + type Type = Unset; 721 + type Blob = Unset; 722 + } 723 + ///State transition - sets the `type` field to Set 724 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 725 + impl<S: State> sealed::Sealed for SetType<S> {} 726 + impl<S: State> State for SetType<S> { 727 + type Type = Set<members::r#type>; 728 + type Blob = S::Blob; 729 + } 730 + ///State transition - sets the `blob` field to Set 731 + pub struct SetBlob<S: State = Empty>(PhantomData<fn() -> S>); 732 + impl<S: State> sealed::Sealed for SetBlob<S> {} 733 + impl<S: State> State for SetBlob<S> { 734 + type Type = S::Type; 735 + type Blob = Set<members::blob>; 736 + } 737 + /// Marker types for field names 738 + #[allow(non_camel_case_types)] 739 + pub mod members { 740 + ///Marker type for the `type` field 741 + pub struct r#type(()); 742 + ///Marker type for the `blob` field 743 + pub struct blob(()); 744 + } 745 + } 746 + 747 + /// Builder for constructing an instance of this type 748 + pub struct FileBuilder<'a, S: file_state::State> { 749 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 750 + __unsafe_private_named: ( 751 + ::core::option::Option<bool>, 752 + ::core::option::Option<jacquard_common::types::blob::BlobRef<'a>>, 753 + ::core::option::Option<jacquard_common::CowStr<'a>>, 754 + ::core::option::Option<jacquard_common::CowStr<'a>>, 755 + ::core::option::Option<jacquard_common::CowStr<'a>>, 756 + ), 757 + _phantom: ::core::marker::PhantomData<&'a ()>, 758 + } 759 + 760 + impl<'a> File<'a> { 761 + /// Create a new builder for this type 762 + pub fn new() -> FileBuilder<'a, file_state::Empty> { 763 + FileBuilder::new() 764 + } 765 + } 766 + 767 + impl<'a> FileBuilder<'a, file_state::Empty> { 768 + /// Create a new builder with all fields unset 769 + pub fn new() -> Self { 770 + FileBuilder { 771 + _phantom_state: ::core::marker::PhantomData, 772 + __unsafe_private_named: (None, None, None, None, None), 773 + _phantom: ::core::marker::PhantomData, 774 + } 775 + } 776 + } 777 + 778 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 779 + /// Set the `base64` field (optional) 780 + pub fn base64(mut self, value: impl Into<Option<bool>>) -> Self { 781 + self.__unsafe_private_named.0 = value.into(); 782 + self 783 + } 784 + /// Set the `base64` field to an Option value (optional) 785 + pub fn maybe_base64(mut self, value: Option<bool>) -> Self { 786 + self.__unsafe_private_named.0 = value; 787 + self 788 + } 789 + } 790 + 791 + impl<'a, S> FileBuilder<'a, S> 792 + where 793 + S: file_state::State, 794 + S::Blob: file_state::IsUnset, 795 + { 796 + /// Set the `blob` field (required) 797 + pub fn blob( 798 + mut self, 799 + value: impl Into<jacquard_common::types::blob::BlobRef<'a>>, 800 + ) -> FileBuilder<'a, file_state::SetBlob<S>> { 801 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 802 + FileBuilder { 803 + _phantom_state: ::core::marker::PhantomData, 804 + __unsafe_private_named: self.__unsafe_private_named, 805 + _phantom: ::core::marker::PhantomData, 806 + } 807 + } 808 + } 809 + 810 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 811 + /// Set the `encoding` field (optional) 812 + pub fn encoding( 813 + mut self, 814 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 815 + ) -> Self { 816 + self.__unsafe_private_named.2 = value.into(); 817 + self 818 + } 819 + /// Set the `encoding` field to an Option value (optional) 820 + pub fn maybe_encoding(mut self, value: Option<jacquard_common::CowStr<'a>>) -> Self { 821 + self.__unsafe_private_named.2 = value; 822 + self 823 + } 824 + } 825 + 826 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 827 + /// Set the `mimeType` field (optional) 828 + pub fn mime_type( 829 + mut self, 830 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 831 + ) -> Self { 832 + self.__unsafe_private_named.3 = value.into(); 833 + self 834 + } 835 + /// Set the `mimeType` field to an Option value (optional) 836 + pub fn maybe_mime_type( 837 + mut self, 838 + value: Option<jacquard_common::CowStr<'a>>, 839 + ) -> Self { 840 + self.__unsafe_private_named.3 = value; 841 + self 842 + } 843 + } 844 + 845 + impl<'a, S> FileBuilder<'a, S> 846 + where 847 + S: file_state::State, 848 + S::Type: file_state::IsUnset, 849 + { 850 + /// Set the `type` field (required) 851 + pub fn r#type( 852 + mut self, 853 + value: impl Into<jacquard_common::CowStr<'a>>, 854 + ) -> FileBuilder<'a, file_state::SetType<S>> { 855 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 856 + FileBuilder { 857 + _phantom_state: ::core::marker::PhantomData, 858 + __unsafe_private_named: self.__unsafe_private_named, 859 + _phantom: ::core::marker::PhantomData, 860 + } 861 + } 862 + } 863 + 864 + impl<'a, S> FileBuilder<'a, S> 865 + where 866 + S: file_state::State, 867 + S::Type: file_state::IsSet, 868 + S::Blob: file_state::IsSet, 869 + { 870 + /// Build the final struct 871 + pub fn build(self) -> File<'a> { 872 + File { 873 + base64: self.__unsafe_private_named.0, 874 + blob: self.__unsafe_private_named.1.unwrap(), 875 + encoding: self.__unsafe_private_named.2, 876 + mime_type: self.__unsafe_private_named.3, 877 + r#type: self.__unsafe_private_named.4.unwrap(), 878 + extra_data: Default::default(), 879 + } 880 + } 881 + /// Build the final struct with custom extra_data 882 + pub fn build_with_data( 883 + self, 884 + extra_data: std::collections::BTreeMap< 885 + jacquard_common::smol_str::SmolStr, 886 + jacquard_common::types::value::Data<'a>, 887 + >, 888 + ) -> File<'a> { 889 + File { 890 + base64: self.__unsafe_private_named.0, 891 + blob: self.__unsafe_private_named.1.unwrap(), 892 + encoding: self.__unsafe_private_named.2, 893 + mime_type: self.__unsafe_private_named.3, 894 + r#type: self.__unsafe_private_named.4.unwrap(), 895 + extra_data: Some(extra_data), 896 + } 897 + } 898 + } 899 + 900 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for File<'a> { 901 + fn nsid() -> &'static str { 902 + "place.wisp.fs" 903 + } 904 + fn def_name() -> &'static str { 905 + "file" 906 + } 907 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 908 + lexicon_doc_place_wisp_fs() 909 + } 910 + fn validate( 911 + &self, 912 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 913 + Ok(()) 914 + } 915 + } 916 + 917 + /// Virtual filesystem manifest for a Wisp site 918 + #[jacquard_derive::lexicon] 919 + #[derive( 920 + serde::Serialize, 921 + serde::Deserialize, 922 + Debug, 923 + Clone, 924 + PartialEq, 925 + Eq, 926 + jacquard_derive::IntoStatic 927 + )] 928 + #[serde(rename_all = "camelCase")] 929 + pub struct Fs<'a> { 930 + pub created_at: jacquard_common::types::string::Datetime, 931 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 932 + pub file_count: Option<i64>, 933 + #[serde(borrow)] 934 + pub root: crate::place_wisp::fs::Directory<'a>, 935 + #[serde(borrow)] 936 + pub site: jacquard_common::CowStr<'a>, 937 + } 938 + 939 + pub mod fs_state { 940 + 941 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 942 + #[allow(unused)] 943 + use ::core::marker::PhantomData; 944 + mod sealed { 945 + pub trait Sealed {} 946 + } 947 + /// State trait tracking which required fields have been set 948 + pub trait State: sealed::Sealed { 949 + type Site; 950 + type Root; 951 + type CreatedAt; 952 + } 953 + /// Empty state - all required fields are unset 954 + pub struct Empty(()); 955 + impl sealed::Sealed for Empty {} 956 + impl State for Empty { 957 + type Site = Unset; 958 + type Root = Unset; 959 + type CreatedAt = Unset; 960 + } 961 + ///State transition - sets the `site` field to Set 962 + pub struct SetSite<S: State = Empty>(PhantomData<fn() -> S>); 963 + impl<S: State> sealed::Sealed for SetSite<S> {} 964 + impl<S: State> State for SetSite<S> { 965 + type Site = Set<members::site>; 966 + type Root = S::Root; 967 + type CreatedAt = S::CreatedAt; 968 + } 969 + ///State transition - sets the `root` field to Set 970 + pub struct SetRoot<S: State = Empty>(PhantomData<fn() -> S>); 971 + impl<S: State> sealed::Sealed for SetRoot<S> {} 972 + impl<S: State> State for SetRoot<S> { 973 + type Site = S::Site; 974 + type Root = Set<members::root>; 975 + type CreatedAt = S::CreatedAt; 976 + } 977 + ///State transition - sets the `created_at` field to Set 978 + pub struct SetCreatedAt<S: State = Empty>(PhantomData<fn() -> S>); 979 + impl<S: State> sealed::Sealed for SetCreatedAt<S> {} 980 + impl<S: State> State for SetCreatedAt<S> { 981 + type Site = S::Site; 982 + type Root = S::Root; 983 + type CreatedAt = Set<members::created_at>; 984 + } 985 + /// Marker types for field names 986 + #[allow(non_camel_case_types)] 987 + pub mod members { 988 + ///Marker type for the `site` field 989 + pub struct site(()); 990 + ///Marker type for the `root` field 991 + pub struct root(()); 992 + ///Marker type for the `created_at` field 993 + pub struct created_at(()); 994 + } 995 + } 996 + 997 + /// Builder for constructing an instance of this type 998 + pub struct FsBuilder<'a, S: fs_state::State> { 999 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 1000 + __unsafe_private_named: ( 1001 + ::core::option::Option<jacquard_common::types::string::Datetime>, 1002 + ::core::option::Option<i64>, 1003 + ::core::option::Option<crate::place_wisp::fs::Directory<'a>>, 1004 + ::core::option::Option<jacquard_common::CowStr<'a>>, 1005 + ), 1006 + _phantom: ::core::marker::PhantomData<&'a ()>, 1007 + } 1008 + 1009 + impl<'a> Fs<'a> { 1010 + /// Create a new builder for this type 1011 + pub fn new() -> FsBuilder<'a, fs_state::Empty> { 1012 + FsBuilder::new() 1013 + } 1014 + } 1015 + 1016 + impl<'a> FsBuilder<'a, fs_state::Empty> { 1017 + /// Create a new builder with all fields unset 1018 + pub fn new() -> Self { 1019 + FsBuilder { 1020 + _phantom_state: ::core::marker::PhantomData, 1021 + __unsafe_private_named: (None, None, None, None), 1022 + _phantom: ::core::marker::PhantomData, 1023 + } 1024 + } 1025 + } 1026 + 1027 + impl<'a, S> FsBuilder<'a, S> 1028 + where 1029 + S: fs_state::State, 1030 + S::CreatedAt: fs_state::IsUnset, 1031 + { 1032 + /// Set the `createdAt` field (required) 1033 + pub fn created_at( 1034 + mut self, 1035 + value: impl Into<jacquard_common::types::string::Datetime>, 1036 + ) -> FsBuilder<'a, fs_state::SetCreatedAt<S>> { 1037 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 1038 + FsBuilder { 1039 + _phantom_state: ::core::marker::PhantomData, 1040 + __unsafe_private_named: self.__unsafe_private_named, 1041 + _phantom: ::core::marker::PhantomData, 1042 + } 1043 + } 1044 + } 1045 + 1046 + impl<'a, S: fs_state::State> FsBuilder<'a, S> { 1047 + /// Set the `fileCount` field (optional) 1048 + pub fn file_count(mut self, value: impl Into<Option<i64>>) -> Self { 1049 + self.__unsafe_private_named.1 = value.into(); 1050 + self 1051 + } 1052 + /// Set the `fileCount` field to an Option value (optional) 1053 + pub fn maybe_file_count(mut self, value: Option<i64>) -> Self { 1054 + self.__unsafe_private_named.1 = value; 1055 + self 1056 + } 1057 + } 1058 + 1059 + impl<'a, S> FsBuilder<'a, S> 1060 + where 1061 + S: fs_state::State, 1062 + S::Root: fs_state::IsUnset, 1063 + { 1064 + /// Set the `root` field (required) 1065 + pub fn root( 1066 + mut self, 1067 + value: impl Into<crate::place_wisp::fs::Directory<'a>>, 1068 + ) -> FsBuilder<'a, fs_state::SetRoot<S>> { 1069 + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); 1070 + FsBuilder { 1071 + _phantom_state: ::core::marker::PhantomData, 1072 + __unsafe_private_named: self.__unsafe_private_named, 1073 + _phantom: ::core::marker::PhantomData, 1074 + } 1075 + } 1076 + } 1077 + 1078 + impl<'a, S> FsBuilder<'a, S> 1079 + where 1080 + S: fs_state::State, 1081 + S::Site: fs_state::IsUnset, 1082 + { 1083 + /// Set the `site` field (required) 1084 + pub fn site( 1085 + mut self, 1086 + value: impl Into<jacquard_common::CowStr<'a>>, 1087 + ) -> FsBuilder<'a, fs_state::SetSite<S>> { 1088 + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 1089 + FsBuilder { 1090 + _phantom_state: ::core::marker::PhantomData, 1091 + __unsafe_private_named: self.__unsafe_private_named, 1092 + _phantom: ::core::marker::PhantomData, 1093 + } 1094 + } 1095 + } 1096 + 1097 + impl<'a, S> FsBuilder<'a, S> 1098 + where 1099 + S: fs_state::State, 1100 + S::Site: fs_state::IsSet, 1101 + S::Root: fs_state::IsSet, 1102 + S::CreatedAt: fs_state::IsSet, 1103 + { 1104 + /// Build the final struct 1105 + pub fn build(self) -> Fs<'a> { 1106 + Fs { 1107 + created_at: self.__unsafe_private_named.0.unwrap(), 1108 + file_count: self.__unsafe_private_named.1, 1109 + root: self.__unsafe_private_named.2.unwrap(), 1110 + site: self.__unsafe_private_named.3.unwrap(), 1111 + extra_data: Default::default(), 1112 + } 1113 + } 1114 + /// Build the final struct with custom extra_data 1115 + pub fn build_with_data( 1116 + self, 1117 + extra_data: std::collections::BTreeMap< 1118 + jacquard_common::smol_str::SmolStr, 1119 + jacquard_common::types::value::Data<'a>, 1120 + >, 1121 + ) -> Fs<'a> { 1122 + Fs { 1123 + created_at: self.__unsafe_private_named.0.unwrap(), 1124 + file_count: self.__unsafe_private_named.1, 1125 + root: self.__unsafe_private_named.2.unwrap(), 1126 + site: self.__unsafe_private_named.3.unwrap(), 1127 + extra_data: Some(extra_data), 1128 + } 1129 + } 1130 + } 1131 + 1132 + impl<'a> Fs<'a> { 1133 + pub fn uri( 1134 + uri: impl Into<jacquard_common::CowStr<'a>>, 1135 + ) -> Result< 1136 + jacquard_common::types::uri::RecordUri<'a, FsRecord>, 1137 + jacquard_common::types::uri::UriError, 1138 + > { 1139 + jacquard_common::types::uri::RecordUri::try_from_uri( 1140 + jacquard_common::types::string::AtUri::new_cow(uri.into())?, 1141 + ) 1142 + } 1143 + } 1144 + 1145 + /// Typed wrapper for GetRecord response with this collection's record type. 1146 + #[derive( 1147 + serde::Serialize, 1148 + serde::Deserialize, 1149 + Debug, 1150 + Clone, 1151 + PartialEq, 1152 + Eq, 1153 + jacquard_derive::IntoStatic 1154 + )] 1155 + #[serde(rename_all = "camelCase")] 1156 + pub struct FsGetRecordOutput<'a> { 1157 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1158 + #[serde(borrow)] 1159 + pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>, 1160 + #[serde(borrow)] 1161 + pub uri: jacquard_common::types::string::AtUri<'a>, 1162 + #[serde(borrow)] 1163 + pub value: Fs<'a>, 1164 + } 1165 + 1166 + impl From<FsGetRecordOutput<'_>> for Fs<'_> { 1167 + fn from(output: FsGetRecordOutput<'_>) -> Self { 1168 + use jacquard_common::IntoStatic; 1169 + output.value.into_static() 1170 + } 1171 + } 1172 + 1173 + impl jacquard_common::types::collection::Collection for Fs<'_> { 1174 + const NSID: &'static str = "place.wisp.fs"; 1175 + type Record = FsRecord; 1176 + } 1177 + 1178 + /// Marker type for deserializing records from this collection. 1179 + #[derive(Debug, serde::Serialize, serde::Deserialize)] 1180 + pub struct FsRecord; 1181 + impl jacquard_common::xrpc::XrpcResp for FsRecord { 1182 + const NSID: &'static str = "place.wisp.fs"; 1183 + const ENCODING: &'static str = "application/json"; 1184 + type Output<'de> = FsGetRecordOutput<'de>; 1185 + type Err<'de> = jacquard_common::types::collection::RecordError<'de>; 1186 + } 1187 + 1188 + impl jacquard_common::types::collection::Collection for FsRecord { 1189 + const NSID: &'static str = "place.wisp.fs"; 1190 + type Record = FsRecord; 1191 + } 1192 + 1193 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Fs<'a> { 1194 + fn nsid() -> &'static str { 1195 + "place.wisp.fs" 1196 + } 1197 + fn def_name() -> &'static str { 1198 + "main" 1199 + } 1200 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 1201 + lexicon_doc_place_wisp_fs() 1202 + } 1203 + fn validate( 1204 + &self, 1205 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 1206 + if let Some(ref value) = self.file_count { 1207 + if *value > 1000i64 { 1208 + return Err(::jacquard_lexicon::validation::ConstraintError::Maximum { 1209 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 1210 + "file_count", 1211 + ), 1212 + max: 1000i64, 1213 + actual: *value, 1214 + }); 1215 + } 1216 + } 1217 + if let Some(ref value) = self.file_count { 1218 + if *value < 0i64 { 1219 + return Err(::jacquard_lexicon::validation::ConstraintError::Minimum { 1220 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 1221 + "file_count", 1222 + ), 1223 + min: 0i64, 1224 + actual: *value, 1225 + }); 1226 + } 1227 + } 1228 + Ok(()) 1229 + } 1230 + }
+6
cli/src/place_wisp.rs
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // This file was automatically generated from Lexicon schemas. 4 + // Any manual changes will be overwritten on the next regeneration. 5 + 6 + pub mod fs;
+63
crates.nix
··· 1 + {...}: { 2 + perSystem = { 3 + pkgs, 4 + config, 5 + lib, 6 + inputs', 7 + ... 8 + }: { 9 + # declare projects 10 + nci.projects."wisp-place-cli" = { 11 + path = ./cli; 12 + export = false; 13 + }; 14 + nci.toolchains.mkBuild = _: 15 + with inputs'.fenix.packages; 16 + combine [ 17 + minimal.rustc 18 + minimal.cargo 19 + targets.x86_64-pc-windows-gnu.latest.rust-std 20 + targets.x86_64-unknown-linux-gnu.latest.rust-std 21 + targets.aarch64-apple-darwin.latest.rust-std 22 + ]; 23 + # configure crates 24 + nci.crates."wisp-cli" = { 25 + profiles = { 26 + dev.runTests = false; 27 + release.runTests = false; 28 + }; 29 + targets."x86_64-unknown-linux-gnu" = { 30 + default = true; 31 + }; 32 + targets."x86_64-pc-windows-gnu" = let 33 + targetPkgs = pkgs.pkgsCross.mingwW64; 34 + targetCC = targetPkgs.stdenv.cc; 35 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 36 + in rec { 37 + depsDrvConfig.mkDerivation = { 38 + nativeBuildInputs = [targetCC]; 39 + buildInputs = with targetPkgs; [windows.pthreads]; 40 + }; 41 + depsDrvConfig.env = rec { 42 + TARGET_CC = "${targetCC.targetPrefix}cc"; 43 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 44 + }; 45 + drvConfig = depsDrvConfig; 46 + }; 47 + targets."aarch64-apple-darwin" = let 48 + targetPkgs = pkgs.pkgsCross.aarch64-darwin; 49 + targetCC = targetPkgs.stdenv.cc; 50 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 51 + in rec { 52 + depsDrvConfig.mkDerivation = { 53 + nativeBuildInputs = [targetCC]; 54 + }; 55 + depsDrvConfig.env = rec { 56 + TARGET_CC = "${targetCC.targetPrefix}cc"; 57 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 58 + }; 59 + drvConfig = depsDrvConfig; 60 + }; 61 + }; 62 + }; 63 + }
+318
flake.lock
··· 1 + { 2 + "nodes": { 3 + "crane": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1758758545, 7 + "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", 8 + "owner": "ipetkov", 9 + "repo": "crane", 10 + "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "ipetkov", 15 + "ref": "v0.21.1", 16 + "repo": "crane", 17 + "type": "github" 18 + } 19 + }, 20 + "dream2nix": { 21 + "inputs": { 22 + "nixpkgs": [ 23 + "nci", 24 + "nixpkgs" 25 + ], 26 + "purescript-overlay": "purescript-overlay", 27 + "pyproject-nix": "pyproject-nix" 28 + }, 29 + "locked": { 30 + "lastModified": 1754978539, 31 + "narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=", 32 + "owner": "nix-community", 33 + "repo": "dream2nix", 34 + "rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214", 35 + "type": "github" 36 + }, 37 + "original": { 38 + "owner": "nix-community", 39 + "repo": "dream2nix", 40 + "type": "github" 41 + } 42 + }, 43 + "fenix": { 44 + "inputs": { 45 + "nixpkgs": [ 46 + "nixpkgs" 47 + ], 48 + "rust-analyzer-src": "rust-analyzer-src" 49 + }, 50 + "locked": { 51 + "lastModified": 1762584108, 52 + "narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=", 53 + "owner": "nix-community", 54 + "repo": "fenix", 55 + "rev": "32f3ad3b6c690061173e1ac16708874975ec6056", 56 + "type": "github" 57 + }, 58 + "original": { 59 + "owner": "nix-community", 60 + "repo": "fenix", 61 + "type": "github" 62 + } 63 + }, 64 + "flake-compat": { 65 + "flake": false, 66 + "locked": { 67 + "lastModified": 1696426674, 68 + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 69 + "owner": "edolstra", 70 + "repo": "flake-compat", 71 + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 72 + "type": "github" 73 + }, 74 + "original": { 75 + "owner": "edolstra", 76 + "repo": "flake-compat", 77 + "type": "github" 78 + } 79 + }, 80 + "mk-naked-shell": { 81 + "flake": false, 82 + "locked": { 83 + "lastModified": 1681286841, 84 + "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", 85 + "owner": "90-008", 86 + "repo": "mk-naked-shell", 87 + "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", 88 + "type": "github" 89 + }, 90 + "original": { 91 + "owner": "90-008", 92 + "repo": "mk-naked-shell", 93 + "type": "github" 94 + } 95 + }, 96 + "nci": { 97 + "inputs": { 98 + "crane": "crane", 99 + "dream2nix": "dream2nix", 100 + "mk-naked-shell": "mk-naked-shell", 101 + "nixpkgs": [ 102 + "nixpkgs" 103 + ], 104 + "parts": "parts", 105 + "rust-overlay": "rust-overlay", 106 + "treefmt": "treefmt" 107 + }, 108 + "locked": { 109 + "lastModified": 1762582646, 110 + "narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=", 111 + "owner": "90-008", 112 + "repo": "nix-cargo-integration", 113 + "rev": "0993c449377049fa8868a664e8290ac6658e0b9a", 114 + "type": "github" 115 + }, 116 + "original": { 117 + "owner": "90-008", 118 + "repo": "nix-cargo-integration", 119 + "type": "github" 120 + } 121 + }, 122 + "nixpkgs": { 123 + "locked": { 124 + "lastModified": 1762361079, 125 + "narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=", 126 + "owner": "nixos", 127 + "repo": "nixpkgs", 128 + "rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5", 129 + "type": "github" 130 + }, 131 + "original": { 132 + "owner": "nixos", 133 + "ref": "nixpkgs-unstable", 134 + "repo": "nixpkgs", 135 + "type": "github" 136 + } 137 + }, 138 + "parts": { 139 + "inputs": { 140 + "nixpkgs-lib": [ 141 + "nci", 142 + "nixpkgs" 143 + ] 144 + }, 145 + "locked": { 146 + "lastModified": 1762440070, 147 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 148 + "owner": "hercules-ci", 149 + "repo": "flake-parts", 150 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 151 + "type": "github" 152 + }, 153 + "original": { 154 + "owner": "hercules-ci", 155 + "repo": "flake-parts", 156 + "type": "github" 157 + } 158 + }, 159 + "parts_2": { 160 + "inputs": { 161 + "nixpkgs-lib": [ 162 + "nixpkgs" 163 + ] 164 + }, 165 + "locked": { 166 + "lastModified": 1762440070, 167 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 168 + "owner": "hercules-ci", 169 + "repo": "flake-parts", 170 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 171 + "type": "github" 172 + }, 173 + "original": { 174 + "owner": "hercules-ci", 175 + "repo": "flake-parts", 176 + "type": "github" 177 + } 178 + }, 179 + "purescript-overlay": { 180 + "inputs": { 181 + "flake-compat": "flake-compat", 182 + "nixpkgs": [ 183 + "nci", 184 + "dream2nix", 185 + "nixpkgs" 186 + ], 187 + "slimlock": "slimlock" 188 + }, 189 + "locked": { 190 + "lastModified": 1728546539, 191 + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", 192 + "owner": "thomashoneyman", 193 + "repo": "purescript-overlay", 194 + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", 195 + "type": "github" 196 + }, 197 + "original": { 198 + "owner": "thomashoneyman", 199 + "repo": "purescript-overlay", 200 + "type": "github" 201 + } 202 + }, 203 + "pyproject-nix": { 204 + "inputs": { 205 + "nixpkgs": [ 206 + "nci", 207 + "dream2nix", 208 + "nixpkgs" 209 + ] 210 + }, 211 + "locked": { 212 + "lastModified": 1752481895, 213 + "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 214 + "owner": "pyproject-nix", 215 + "repo": "pyproject.nix", 216 + "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 217 + "type": "github" 218 + }, 219 + "original": { 220 + "owner": "pyproject-nix", 221 + "repo": "pyproject.nix", 222 + "type": "github" 223 + } 224 + }, 225 + "root": { 226 + "inputs": { 227 + "fenix": "fenix", 228 + "nci": "nci", 229 + "nixpkgs": "nixpkgs", 230 + "parts": "parts_2" 231 + } 232 + }, 233 + "rust-analyzer-src": { 234 + "flake": false, 235 + "locked": { 236 + "lastModified": 1762438844, 237 + "narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=", 238 + "owner": "rust-lang", 239 + "repo": "rust-analyzer", 240 + "rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86", 241 + "type": "github" 242 + }, 243 + "original": { 244 + "owner": "rust-lang", 245 + "ref": "nightly", 246 + "repo": "rust-analyzer", 247 + "type": "github" 248 + } 249 + }, 250 + "rust-overlay": { 251 + "inputs": { 252 + "nixpkgs": [ 253 + "nci", 254 + "nixpkgs" 255 + ] 256 + }, 257 + "locked": { 258 + "lastModified": 1762569282, 259 + "narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=", 260 + "owner": "oxalica", 261 + "repo": "rust-overlay", 262 + "rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8", 263 + "type": "github" 264 + }, 265 + "original": { 266 + "owner": "oxalica", 267 + "repo": "rust-overlay", 268 + "type": "github" 269 + } 270 + }, 271 + "slimlock": { 272 + "inputs": { 273 + "nixpkgs": [ 274 + "nci", 275 + "dream2nix", 276 + "purescript-overlay", 277 + "nixpkgs" 278 + ] 279 + }, 280 + "locked": { 281 + "lastModified": 1688756706, 282 + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", 283 + "owner": "thomashoneyman", 284 + "repo": "slimlock", 285 + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", 286 + "type": "github" 287 + }, 288 + "original": { 289 + "owner": "thomashoneyman", 290 + "repo": "slimlock", 291 + "type": "github" 292 + } 293 + }, 294 + "treefmt": { 295 + "inputs": { 296 + "nixpkgs": [ 297 + "nci", 298 + "nixpkgs" 299 + ] 300 + }, 301 + "locked": { 302 + "lastModified": 1762410071, 303 + "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=", 304 + "owner": "numtide", 305 + "repo": "treefmt-nix", 306 + "rev": "97a30861b13c3731a84e09405414398fbf3e109f", 307 + "type": "github" 308 + }, 309 + "original": { 310 + "owner": "numtide", 311 + "repo": "treefmt-nix", 312 + "type": "github" 313 + } 314 + } 315 + }, 316 + "root": "root", 317 + "version": 7 318 + }
+36
flake.nix
··· 1 + { 2 + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 3 + inputs.nci.url = "github:90-008/nix-cargo-integration"; 4 + inputs.nci.inputs.nixpkgs.follows = "nixpkgs"; 5 + inputs.parts.url = "github:hercules-ci/flake-parts"; 6 + inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 7 + inputs.fenix = { 8 + url = "github:nix-community/fenix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + 12 + outputs = inputs @ { 13 + parts, 14 + nci, 15 + ... 16 + }: 17 + parts.lib.mkFlake {inherit inputs;} { 18 + systems = ["x86_64-linux" "aarch64-darwin"]; 19 + imports = [ 20 + nci.flakeModule 21 + ./crates.nix 22 + ]; 23 + perSystem = { 24 + pkgs, 25 + config, 26 + ... 27 + }: let 28 + crateOutputs = config.nci.outputs."wisp-cli"; 29 + in { 30 + devShells.default = crateOutputs.devShell; 31 + packages.default = crateOutputs.packages.release; 32 + packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release; 33 + packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release; 34 + }; 35 + }; 36 + }
+336 -21
hosting-service/bun.lock
··· 4 4 "": { 5 5 "name": "wisp-hosting-service", 6 6 "dependencies": { 7 - "@atproto/api": "^0.13.20", 8 - "@atproto/xrpc": "^0.6.4", 9 - "hono": "^4.6.14", 7 + "@atproto/api": "^0.17.4", 8 + "@atproto/identity": "^0.4.9", 9 + "@atproto/lexicon": "^0.5.1", 10 + "@atproto/sync": "^0.1.36", 11 + "@atproto/xrpc": "^0.7.5", 12 + "@hono/node-server": "^1.19.6", 13 + "hono": "^4.10.4", 14 + "mime-types": "^2.1.35", 15 + "multiformats": "^13.4.1", 10 16 "postgres": "^3.4.5", 11 17 }, 12 18 "devDependencies": { 13 - "@types/bun": "latest", 19 + "@types/bun": "^1.3.1", 20 + "@types/mime-types": "^2.1.4", 21 + "@types/node": "^22.10.5", 22 + "tsx": "^4.19.2", 14 23 }, 15 24 }, 16 25 }, 17 26 "packages": { 18 - "@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=="], 27 + "@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" } }, ""], 28 + 29 + "@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" } }, ""], 30 + 31 + "@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" } }, ""], 32 + 33 + "@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, ""], 34 + 35 + "@atproto/identity": ["@atproto/identity@0.4.9", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4" } }, ""], 36 + 37 + "@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" } }, ""], 38 + 39 + "@atproto/repo": ["@atproto/repo@0.8.10", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, ""], 40 + 41 + "@atproto/sync": ["@atproto/sync@0.1.36", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-HyF835Bmn8ps9BuXkmGjRrbgfv4K3fJdfEvXimEhTCntqIxQg0ttmOYDg/WBBmIRfkCB5ab+wS1PCGN8trr+FQ=="], 42 + 43 + "@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""], 44 + 45 + "@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, ""], 46 + 47 + "@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, ""], 48 + 49 + "@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""], 50 + 51 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], 52 + 53 + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], 54 + 55 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="], 56 + 57 + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="], 58 + 59 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="], 60 + 61 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="], 62 + 63 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="], 64 + 65 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="], 66 + 67 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="], 68 + 69 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="], 70 + 71 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="], 72 + 73 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="], 74 + 75 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="], 76 + 77 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="], 78 + 79 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="], 80 + 81 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="], 82 + 83 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="], 84 + 85 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="], 86 + 87 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="], 88 + 89 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="], 90 + 91 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="], 92 + 93 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="], 94 + 95 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="], 96 + 97 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="], 98 + 99 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="], 19 100 20 - "@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=="], 101 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="], 21 102 22 - "@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=="], 103 + "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], 23 104 24 - "@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="], 105 + "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""], 25 106 26 - "@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="], 107 + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""], 108 + 109 + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""], 27 110 28 111 "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 29 112 30 - "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], 113 + "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], 114 + 115 + "@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="], 31 116 32 117 "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 33 118 34 - "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 119 + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""], 120 + 121 + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""], 122 + 123 + "array-flatten": ["array-flatten@1.1.1", "", {}, ""], 124 + 125 + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""], 126 + 127 + "await-lock": ["await-lock@2.2.2", "", {}, ""], 128 + 129 + "base64-js": ["base64-js@1.5.1", "", {}, ""], 130 + 131 + "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, ""], 132 + 133 + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""], 35 134 36 135 "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 37 136 137 + "bytes": ["bytes@3.1.2", "", {}, ""], 138 + 139 + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""], 140 + 141 + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, ""], 142 + 143 + "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" } }, ""], 144 + 145 + "cbor-x": ["cbor-x@1.6.0", "", { "optionalDependencies": { "cbor-extract": "^2.2.0" } }, ""], 146 + 147 + "cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""], 148 + 149 + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""], 150 + 151 + "content-type": ["content-type@1.0.5", "", {}, ""], 152 + 153 + "cookie": ["cookie@0.7.1", "", {}, ""], 154 + 155 + "cookie-signature": ["cookie-signature@1.0.6", "", {}, ""], 156 + 38 157 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 39 158 40 - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 159 + "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""], 160 + 161 + "depd": ["depd@2.0.0", "", {}, ""], 162 + 163 + "destroy": ["destroy@1.2.0", "", {}, ""], 164 + 165 + "detect-libc": ["detect-libc@2.1.2", "", {}, ""], 166 + 167 + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, ""], 168 + 169 + "ee-first": ["ee-first@1.1.1", "", {}, ""], 170 + 171 + "encodeurl": ["encodeurl@2.0.0", "", {}, ""], 172 + 173 + "es-define-property": ["es-define-property@1.0.1", "", {}, ""], 174 + 175 + "es-errors": ["es-errors@1.3.0", "", {}, ""], 176 + 177 + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, ""], 178 + 179 + "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": "bin/esbuild" }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], 180 + 181 + "escape-html": ["escape-html@1.0.3", "", {}, ""], 182 + 183 + "etag": ["etag@1.8.1", "", {}, ""], 184 + 185 + "event-target-shim": ["event-target-shim@5.0.1", "", {}, ""], 186 + 187 + "eventemitter3": ["eventemitter3@4.0.7", "", {}, ""], 188 + 189 + "events": ["events@3.3.0", "", {}, ""], 190 + 191 + "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, ""], 192 + 193 + "fast-redact": ["fast-redact@3.5.0", "", {}, ""], 194 + 195 + "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, ""], 196 + 197 + "forwarded": ["forwarded@0.2.0", "", {}, ""], 198 + 199 + "fresh": ["fresh@0.5.2", "", {}, ""], 200 + 201 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 202 + 203 + "function-bind": ["function-bind@1.1.2", "", {}, ""], 204 + 205 + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, ""], 206 + 207 + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""], 208 + 209 + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], 210 + 211 + "gopd": ["gopd@1.2.0", "", {}, ""], 212 + 213 + "graphemer": ["graphemer@1.4.0", "", {}, ""], 214 + 215 + "has-symbols": ["has-symbols@1.1.0", "", {}, ""], 216 + 217 + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""], 218 + 219 + "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], 220 + 221 + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, ""], 222 + 223 + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, ""], 224 + 225 + "ieee754": ["ieee754@1.2.1", "", {}, ""], 226 + 227 + "inherits": ["inherits@2.0.4", "", {}, ""], 228 + 229 + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""], 230 + 231 + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""], 232 + 233 + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""], 234 + 235 + "media-typer": ["media-typer@0.3.0", "", {}, ""], 236 + 237 + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""], 238 + 239 + "methods": ["methods@1.1.2", "", {}, ""], 240 + 241 + "mime": ["mime@1.6.0", "", { "bin": "cli.js" }, ""], 41 242 42 - "hono": ["hono@4.10.2", "", {}, "sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg=="], 243 + "mime-db": ["mime-db@1.52.0", "", {}, ""], 43 244 44 - "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 245 + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], 45 246 46 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 247 + "ms": ["ms@2.0.0", "", {}, ""], 47 248 48 - "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], 249 + "multiformats": ["multiformats@13.4.1", "", {}, ""], 49 250 50 - "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 251 + "negotiator": ["negotiator@0.6.3", "", {}, ""], 51 252 52 - "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 253 + "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" } }, ""], 254 + 255 + "object-inspect": ["object-inspect@1.13.4", "", {}, ""], 256 + 257 + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""], 53 258 54 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 259 + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""], 55 260 56 - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 261 + "p-finally": ["p-finally@1.0.0", "", {}, ""], 57 262 58 - "@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="], 263 + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""], 264 + 265 + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, ""], 266 + 267 + "parseurl": ["parseurl@1.3.3", "", {}, ""], 268 + 269 + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""], 270 + 271 + "pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": "bin.js" }, ""], 272 + 273 + "pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, ""], 274 + 275 + "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, ""], 276 + 277 + "postgres": ["postgres@3.4.7", "", {}, ""], 278 + 279 + "process": ["process@0.11.10", "", {}, ""], 280 + 281 + "process-warning": ["process-warning@3.0.0", "", {}, ""], 282 + 283 + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""], 284 + 285 + "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, ""], 286 + 287 + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, ""], 288 + 289 + "range-parser": ["range-parser@1.2.1", "", {}, ""], 290 + 291 + "rate-limiter-flexible": ["rate-limiter-flexible@2.4.2", "", {}, ""], 292 + 293 + "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" } }, ""], 294 + 295 + "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" } }, ""], 296 + 297 + "real-require": ["real-require@0.2.0", "", {}, ""], 298 + 299 + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 300 + 301 + "safe-buffer": ["safe-buffer@5.2.1", "", {}, ""], 302 + 303 + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, ""], 304 + 305 + "safer-buffer": ["safer-buffer@2.1.2", "", {}, ""], 306 + 307 + "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" } }, ""], 308 + 309 + "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" } }, ""], 310 + 311 + "setprototypeof": ["setprototypeof@1.2.0", "", {}, ""], 312 + 313 + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""], 314 + 315 + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""], 316 + 317 + "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" } }, ""], 318 + 319 + "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" } }, ""], 320 + 321 + "sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, ""], 322 + 323 + "split2": ["split2@4.2.0", "", {}, ""], 324 + 325 + "statuses": ["statuses@2.0.1", "", {}, ""], 326 + 327 + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""], 328 + 329 + "thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""], 330 + 331 + "tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""], 332 + 333 + "toidentifier": ["toidentifier@1.0.1", "", {}, ""], 334 + 335 + "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], 336 + 337 + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""], 338 + 339 + "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""], 340 + 341 + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 342 + 343 + "unpipe": ["unpipe@1.0.0", "", {}, ""], 344 + 345 + "utils-merge": ["utils-merge@1.0.1", "", {}, ""], 346 + 347 + "varint": ["varint@6.0.0", "", {}, ""], 348 + 349 + "vary": ["vary@1.1.2", "", {}, ""], 350 + 351 + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""], 352 + 353 + "zod": ["zod@3.25.76", "", {}, ""], 354 + 355 + "@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""], 356 + 357 + "@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, ""], 358 + 359 + "@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, ""], 360 + 361 + "@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, ""], 362 + 363 + "@atproto/repo/multiformats": ["multiformats@9.9.0", "", {}, ""], 364 + 365 + "@atproto/sync/multiformats": ["multiformats@9.9.0", "", {}, ""], 366 + 367 + "@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""], 368 + 369 + "send/encodeurl": ["encodeurl@1.0.2", "", {}, ""], 370 + 371 + "send/ms": ["ms@2.1.3", "", {}, ""], 372 + 373 + "uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""], 59 374 } 60 375 }
+17 -6
hosting-service/package.json
··· 3 3 "version": "1.0.0", 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "bun --watch src/index.ts", 7 - "start": "bun src/index.ts" 6 + "dev": "tsx --env-file=.env watch src/index.ts", 7 + "build": "tsc", 8 + "start": "tsx --env-file=.env src/index.ts", 9 + "backfill": "tsx --env-file=.env src/index.ts --backfill" 8 10 }, 9 11 "dependencies": { 10 - "hono": "^4.6.14", 11 - "@atproto/api": "^0.13.20", 12 - "@atproto/xrpc": "^0.6.4", 12 + "@atproto/api": "^0.17.4", 13 + "@atproto/identity": "^0.4.9", 14 + "@atproto/lexicon": "^0.5.1", 15 + "@atproto/sync": "^0.1.36", 16 + "@atproto/xrpc": "^0.7.5", 17 + "@hono/node-server": "^1.19.6", 18 + "hono": "^4.10.4", 19 + "mime-types": "^2.1.35", 20 + "multiformats": "^13.4.1", 13 21 "postgres": "^3.4.5" 14 22 }, 15 23 "devDependencies": { 16 - "@types/bun": "latest" 24 + "@types/bun": "^1.3.1", 25 + "@types/mime-types": "^2.1.4", 26 + "@types/node": "^22.10.5", 27 + "tsx": "^4.19.2" 17 28 } 18 29 }
+31 -43
hosting-service/src/index.ts
··· 1 - import { serve } from 'bun'; 2 1 import app from './server'; 2 + import { serve } from '@hono/node-server'; 3 3 import { FirehoseWorker } from './lib/firehose'; 4 - import { DNSVerificationWorker } from './lib/dns-verification-worker'; 4 + import { logger } from './lib/observability'; 5 5 import { mkdirSync, existsSync } from 'fs'; 6 + import { backfillCache } from './lib/backfill'; 6 7 7 - const PORT = process.env.PORT || 3001; 8 - const CACHE_DIR = './cache/sites'; 8 + const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 9 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 10 + 11 + // Parse CLI arguments 12 + const args = process.argv.slice(2); 13 + const hasBackfillFlag = args.includes('--backfill'); 14 + const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true'; 9 15 10 16 // Ensure cache directory exists 11 17 if (!existsSync(CACHE_DIR)) { ··· 13 19 console.log('Created cache directory:', CACHE_DIR); 14 20 } 15 21 16 - // Start firehose worker 22 + // Start firehose worker with observability logger 17 23 const firehose = new FirehoseWorker((msg, data) => { 18 - console.log(msg, data); 24 + logger.info(msg, data); 19 25 }); 20 26 21 27 firehose.start(); 22 28 23 - // Start DNS verification worker (runs every hour) 24 - const dnsVerifier = new DNSVerificationWorker( 25 - 60 * 60 * 1000, // 1 hour 26 - (msg, data) => { 27 - console.log('[DNS Verifier]', msg, data || ''); 28 - } 29 - ); 30 - 31 - dnsVerifier.start(); 29 + // Run backfill if requested 30 + if (backfillOnStartup) { 31 + console.log('๐Ÿ”„ Backfill requested, starting cache backfill...'); 32 + backfillCache({ 33 + skipExisting: true, 34 + concurrency: 3, 35 + }).then((stats) => { 36 + console.log('โœ… Cache backfill completed'); 37 + }).catch((err) => { 38 + console.error('โŒ Cache backfill error:', err); 39 + }); 40 + } 32 41 33 42 // Add health check endpoint 34 43 app.get('/health', (c) => { 35 44 const firehoseHealth = firehose.getHealth(); 36 - const dnsVerifierHealth = dnsVerifier.getHealth(); 37 45 return c.json({ 38 46 status: 'ok', 39 47 firehose: firehoseHealth, 40 - dnsVerifier: dnsVerifierHealth, 41 48 }); 42 49 }); 43 50 44 - // Add manual DNS verification trigger (for testing/admin) 45 - app.post('/admin/verify-dns', async (c) => { 46 - try { 47 - await dnsVerifier.trigger(); 48 - return c.json({ 49 - success: true, 50 - message: 'DNS verification triggered', 51 - }); 52 - } catch (error) { 53 - return c.json({ 54 - success: false, 55 - error: error instanceof Error ? error.message : String(error), 56 - }, 500); 57 - } 58 - }); 59 - 60 - // Start HTTP server 51 + // Start HTTP server with Node.js adapter 61 52 const server = serve({ 62 - port: PORT, 63 53 fetch: app.fetch, 54 + port: PORT, 64 55 }); 65 56 66 57 console.log(` ··· 69 60 Server: http://localhost:${PORT} 70 61 Health: http://localhost:${PORT}/health 71 62 Cache: ${CACHE_DIR} 72 - Firehose: Connected to Jetstream 73 - DNS Verifier: Checking every hour 63 + Firehose: Connected to Firehose 74 64 `); 75 65 76 66 // Graceful shutdown 77 - process.on('SIGINT', () => { 67 + process.on('SIGINT', async () => { 78 68 console.log('\n๐Ÿ›‘ Shutting down...'); 79 69 firehose.stop(); 80 - dnsVerifier.stop(); 81 - server.stop(); 70 + server.close(); 82 71 process.exit(0); 83 72 }); 84 73 85 - process.on('SIGTERM', () => { 74 + process.on('SIGTERM', async () => { 86 75 console.log('\n๐Ÿ›‘ Shutting down...'); 87 76 firehose.stop(); 88 - dnsVerifier.stop(); 89 - server.stop(); 77 + server.close(); 90 78 process.exit(0); 91 79 });
+44
hosting-service/src/lexicon/index.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type Auth, 6 + type Options as XrpcOptions, 7 + Server as XrpcServer, 8 + type StreamConfigOrHandler, 9 + type MethodConfigOrHandler, 10 + createServer as createXrpcServer, 11 + } from '@atproto/xrpc-server' 12 + import { schemas } from './lexicons.js' 13 + 14 + export function createServer(options?: XrpcOptions): Server { 15 + return new Server(options) 16 + } 17 + 18 + export class Server { 19 + xrpc: XrpcServer 20 + place: PlaceNS 21 + 22 + constructor(options?: XrpcOptions) { 23 + this.xrpc = createXrpcServer(schemas, options) 24 + this.place = new PlaceNS(this) 25 + } 26 + } 27 + 28 + export class PlaceNS { 29 + _server: Server 30 + wisp: PlaceWispNS 31 + 32 + constructor(server: Server) { 33 + this._server = server 34 + this.wisp = new PlaceWispNS(server) 35 + } 36 + } 37 + 38 + export class PlaceWispNS { 39 + _server: Server 40 + 41 + constructor(server: Server) { 42 + this._server = server 43 + } 44 + }
+141
hosting-service/src/lexicon/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + PlaceWispFs: { 14 + lexicon: 1, 15 + id: 'place.wisp.fs', 16 + defs: { 17 + main: { 18 + type: 'record', 19 + description: 'Virtual filesystem manifest for a Wisp site', 20 + record: { 21 + type: 'object', 22 + required: ['site', 'root', 'createdAt'], 23 + properties: { 24 + site: { 25 + type: 'string', 26 + }, 27 + root: { 28 + type: 'ref', 29 + ref: 'lex:place.wisp.fs#directory', 30 + }, 31 + fileCount: { 32 + type: 'integer', 33 + minimum: 0, 34 + maximum: 1000, 35 + }, 36 + createdAt: { 37 + type: 'string', 38 + format: 'datetime', 39 + }, 40 + }, 41 + }, 42 + }, 43 + file: { 44 + type: 'object', 45 + required: ['type', 'blob'], 46 + properties: { 47 + type: { 48 + type: 'string', 49 + const: 'file', 50 + }, 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 56 + }, 57 + encoding: { 58 + type: 'string', 59 + enum: ['gzip'], 60 + description: 'Content encoding (e.g., gzip for compressed files)', 61 + }, 62 + mimeType: { 63 + type: 'string', 64 + description: 'Original MIME type before compression', 65 + }, 66 + base64: { 67 + type: 'boolean', 68 + description: 69 + 'True if blob content is base64-encoded (used to bypass PDS content sniffing)', 70 + }, 71 + }, 72 + }, 73 + directory: { 74 + type: 'object', 75 + required: ['type', 'entries'], 76 + properties: { 77 + type: { 78 + type: 'string', 79 + const: 'directory', 80 + }, 81 + entries: { 82 + type: 'array', 83 + maxLength: 500, 84 + items: { 85 + type: 'ref', 86 + ref: 'lex:place.wisp.fs#entry', 87 + }, 88 + }, 89 + }, 90 + }, 91 + entry: { 92 + type: 'object', 93 + required: ['name', 'node'], 94 + properties: { 95 + name: { 96 + type: 'string', 97 + maxLength: 255, 98 + }, 99 + node: { 100 + type: 'union', 101 + refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 102 + }, 103 + }, 104 + }, 105 + }, 106 + }, 107 + } as const satisfies Record<string, LexiconDoc> 108 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 109 + export const lexicons: Lexicons = new Lexicons(schemas) 110 + 111 + export function validate<T extends { $type: string }>( 112 + v: unknown, 113 + id: string, 114 + hash: string, 115 + requiredType: true, 116 + ): ValidationResult<T> 117 + export function validate<T extends { $type?: string }>( 118 + v: unknown, 119 + id: string, 120 + hash: string, 121 + requiredType?: false, 122 + ): ValidationResult<T> 123 + export function validate( 124 + v: unknown, 125 + id: string, 126 + hash: string, 127 + requiredType?: boolean, 128 + ): ValidationResult { 129 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 130 + ? lexicons.validate(`${id}#${hash}`, v) 131 + : { 132 + success: false, 133 + error: new ValidationError( 134 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 135 + ), 136 + } 137 + } 138 + 139 + export const ids = { 140 + PlaceWispFs: 'place.wisp.fs', 141 + } as const
+85
hosting-service/src/lexicon/types/place/wisp/fs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.fs' 12 + 13 + export interface Record { 14 + $type: 'place.wisp.fs' 15 + site: string 16 + root: Directory 17 + fileCount?: number 18 + createdAt: string 19 + [k: string]: unknown 20 + } 21 + 22 + const hashRecord = 'main' 23 + 24 + export function isRecord<V>(v: V) { 25 + return is$typed(v, id, hashRecord) 26 + } 27 + 28 + export function validateRecord<V>(v: V) { 29 + return validate<Record & V>(v, id, hashRecord, true) 30 + } 31 + 32 + export interface File { 33 + $type?: 'place.wisp.fs#file' 34 + type: 'file' 35 + /** Content blob ref */ 36 + blob: BlobRef 37 + /** Content encoding (e.g., gzip for compressed files) */ 38 + encoding?: 'gzip' 39 + /** Original MIME type before compression */ 40 + mimeType?: string 41 + /** True if blob content is base64-encoded (used to bypass PDS content sniffing) */ 42 + base64?: boolean 43 + } 44 + 45 + const hashFile = 'file' 46 + 47 + export function isFile<V>(v: V) { 48 + return is$typed(v, id, hashFile) 49 + } 50 + 51 + export function validateFile<V>(v: V) { 52 + return validate<File & V>(v, id, hashFile) 53 + } 54 + 55 + export interface Directory { 56 + $type?: 'place.wisp.fs#directory' 57 + type: 'directory' 58 + entries: Entry[] 59 + } 60 + 61 + const hashDirectory = 'directory' 62 + 63 + export function isDirectory<V>(v: V) { 64 + return is$typed(v, id, hashDirectory) 65 + } 66 + 67 + export function validateDirectory<V>(v: V) { 68 + return validate<Directory & V>(v, id, hashDirectory) 69 + } 70 + 71 + export interface Entry { 72 + $type?: 'place.wisp.fs#entry' 73 + name: string 74 + node: $Typed<File> | $Typed<Directory> | { $type: string } 75 + } 76 + 77 + const hashEntry = 'entry' 78 + 79 + export function isEntry<V>(v: V) { 80 + return is$typed(v, id, hashEntry) 81 + } 82 + 83 + export function validateEntry<V>(v: V) { 84 + return validate<Entry & V>(v, id, hashEntry) 85 + }
+82
hosting-service/src/lexicon/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+136
hosting-service/src/lib/backfill.ts
··· 1 + import { getAllSites } from './db'; 2 + import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils'; 3 + import { logger } from './observability'; 4 + 5 + export interface BackfillOptions { 6 + skipExisting?: boolean; // Skip sites already in cache 7 + concurrency?: number; // Number of sites to cache concurrently 8 + maxSites?: number; // Maximum number of sites to backfill (for testing) 9 + } 10 + 11 + export interface BackfillStats { 12 + total: number; 13 + cached: number; 14 + skipped: number; 15 + failed: number; 16 + duration: number; 17 + } 18 + 19 + /** 20 + * Backfill all sites from the database into the local cache 21 + */ 22 + export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> { 23 + const { 24 + skipExisting = true, 25 + concurrency = 3, 26 + maxSites, 27 + } = options; 28 + 29 + const startTime = Date.now(); 30 + const stats: BackfillStats = { 31 + total: 0, 32 + cached: 0, 33 + skipped: 0, 34 + failed: 0, 35 + duration: 0, 36 + }; 37 + 38 + logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites }); 39 + console.log(` 40 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 41 + โ•‘ CACHE BACKFILL STARTING โ•‘ 42 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 43 + `); 44 + 45 + try { 46 + // Get all sites from database 47 + let sites = await getAllSites(); 48 + stats.total = sites.length; 49 + 50 + logger.info(`Found ${sites.length} sites in database`); 51 + console.log(`๐Ÿ“Š Found ${sites.length} sites in database`); 52 + 53 + // Limit if specified 54 + if (maxSites && maxSites > 0) { 55 + sites = sites.slice(0, maxSites); 56 + console.log(`โš™๏ธ Limited to ${maxSites} sites for backfill`); 57 + } 58 + 59 + // Process sites in batches 60 + const batches: typeof sites[] = []; 61 + for (let i = 0; i < sites.length; i += concurrency) { 62 + batches.push(sites.slice(i, i + concurrency)); 63 + } 64 + 65 + let processed = 0; 66 + for (const batch of batches) { 67 + await Promise.all( 68 + batch.map(async (site) => { 69 + try { 70 + // Check if already cached 71 + if (skipExisting && isCached(site.did, site.rkey)) { 72 + stats.skipped++; 73 + processed++; 74 + logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey }); 75 + console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`); 76 + return; 77 + } 78 + 79 + // Fetch site record 80 + const siteData = await fetchSiteRecord(site.did, site.rkey); 81 + if (!siteData) { 82 + stats.failed++; 83 + processed++; 84 + logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey }); 85 + console.log(`โŒ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`); 86 + return; 87 + } 88 + 89 + // Get PDS endpoint 90 + const pdsEndpoint = await getPdsForDid(site.did); 91 + if (!pdsEndpoint) { 92 + stats.failed++; 93 + processed++; 94 + logger.error('PDS not found during backfill', null, { did: site.did }); 95 + console.log(`โŒ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`); 96 + return; 97 + } 98 + 99 + // Download and cache site 100 + await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid); 101 + stats.cached++; 102 + processed++; 103 + logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey }); 104 + console.log(`โœ… [${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`); 105 + } catch (err) { 106 + stats.failed++; 107 + processed++; 108 + logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey }); 109 + console.log(`โŒ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`); 110 + } 111 + }) 112 + ); 113 + } 114 + 115 + stats.duration = Date.now() - startTime; 116 + 117 + console.log(` 118 + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 119 + โ•‘ CACHE BACKFILL COMPLETED โ•‘ 120 + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 121 + 122 + ๐Ÿ“Š Total Sites: ${stats.total} 123 + โœ… Cached: ${stats.cached} 124 + โญ๏ธ Skipped: ${stats.skipped} 125 + โŒ Failed: ${stats.failed} 126 + โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s 127 + `); 128 + 129 + logger.info('Cache backfill completed', stats); 130 + } catch (err) { 131 + logger.error('Cache backfill failed', err); 132 + console.error('โŒ Cache backfill failed:', err); 133 + } 134 + 135 + return stats; 136 + }
+83 -6
hosting-service/src/lib/db.ts
··· 1 1 import postgres from 'postgres'; 2 + import { createHash } from 'crypto'; 2 3 3 4 const sql = postgres( 4 5 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', ··· 17 18 id: string; 18 19 domain: string; 19 20 did: string; 20 - rkey: string; 21 + rkey: string | null; 21 22 verified: boolean; 22 23 } 23 24 25 + 26 + 24 27 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 28 + const key = domain.toLowerCase(); 29 + 30 + // Query database 25 31 const result = await sql<DomainLookup[]>` 26 - SELECT did, rkey FROM domains WHERE domain = ${domain.toLowerCase()} LIMIT 1 32 + SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 27 33 `; 28 - return result[0] || null; 34 + const data = result[0] || null; 35 + 36 + return data; 29 37 } 30 38 31 39 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 40 + const key = domain.toLowerCase(); 41 + 42 + // Query database 32 43 const result = await sql<CustomDomainLookup[]>` 33 44 SELECT id, domain, did, rkey, verified FROM custom_domains 34 - WHERE domain = ${domain.toLowerCase()} AND verified = true LIMIT 1 45 + WHERE domain = ${key} AND verified = true LIMIT 1 35 46 `; 36 - return result[0] || null; 47 + const data = result[0] || null; 48 + 49 + return data; 37 50 } 38 51 39 52 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 53 + // Query database 40 54 const result = await sql<CustomDomainLookup[]>` 41 55 SELECT id, domain, did, rkey, verified FROM custom_domains 42 56 WHERE id = ${hash} AND verified = true LIMIT 1 43 57 `; 44 - return result[0] || null; 58 + const data = result[0] || null; 59 + 60 + return data; 45 61 } 46 62 47 63 export async function upsertSite(did: string, rkey: string, displayName?: string) { ··· 62 78 `; 63 79 } catch (err) { 64 80 console.error('Failed to upsert site', err); 81 + } 82 + } 83 + 84 + export interface SiteRecord { 85 + did: string; 86 + rkey: string; 87 + display_name?: string; 88 + } 89 + 90 + export async function getAllSites(): Promise<SiteRecord[]> { 91 + try { 92 + const result = await sql<SiteRecord[]>` 93 + SELECT did, rkey, display_name FROM sites 94 + ORDER BY created_at DESC 95 + `; 96 + return result; 97 + } catch (err) { 98 + console.error('Failed to get all sites', err); 99 + return []; 100 + } 101 + } 102 + 103 + /** 104 + * Generate a numeric lock ID from a string key 105 + * PostgreSQL advisory locks use bigint (64-bit signed integer) 106 + */ 107 + function stringToLockId(key: string): bigint { 108 + const hash = createHash('sha256').update(key).digest('hex'); 109 + // Take first 16 hex characters (64 bits) and convert to bigint 110 + const hashNum = BigInt('0x' + hash.substring(0, 16)); 111 + // Keep within signed int64 range 112 + return hashNum & 0x7FFFFFFFFFFFFFFFn; 113 + } 114 + 115 + /** 116 + * Acquire a distributed lock using PostgreSQL advisory locks 117 + * Returns true if lock was acquired, false if already held by another instance 118 + * Lock is automatically released when the transaction ends or connection closes 119 + */ 120 + export async function tryAcquireLock(key: string): Promise<boolean> { 121 + const lockId = stringToLockId(key); 122 + 123 + try { 124 + const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`; 125 + return result[0]?.acquired === true; 126 + } catch (err) { 127 + console.error('Failed to acquire lock', { key, error: err }); 128 + return false; 129 + } 130 + } 131 + 132 + /** 133 + * Release a distributed lock 134 + */ 135 + export async function releaseLock(key: string): Promise<void> { 136 + const lockId = stringToLockId(key); 137 + 138 + try { 139 + await sql`SELECT pg_advisory_unlock(${Number(lockId)})`; 140 + } catch (err) { 141 + console.error('Failed to release lock', { key, error: err }); 65 142 } 66 143 } 67 144
-170
hosting-service/src/lib/dns-verification-worker.ts
··· 1 - import { verifyCustomDomain } from '../../../src/lib/dns-verify'; 2 - import { db } from '../../../src/lib/db'; 3 - 4 - interface VerificationStats { 5 - totalChecked: number; 6 - verified: number; 7 - failed: number; 8 - errors: number; 9 - } 10 - 11 - export class DNSVerificationWorker { 12 - private interval: Timer | null = null; 13 - private isRunning = false; 14 - private lastRunTime: number | null = null; 15 - private stats: VerificationStats = { 16 - totalChecked: 0, 17 - verified: 0, 18 - failed: 0, 19 - errors: 0, 20 - }; 21 - 22 - constructor( 23 - private checkIntervalMs: number = 60 * 60 * 1000, // 1 hour default 24 - private onLog?: (message: string, data?: any) => void 25 - ) {} 26 - 27 - private log(message: string, data?: any) { 28 - if (this.onLog) { 29 - this.onLog(message, data); 30 - } 31 - } 32 - 33 - async start() { 34 - if (this.isRunning) { 35 - this.log('DNS verification worker already running'); 36 - return; 37 - } 38 - 39 - this.isRunning = true; 40 - this.log('Starting DNS verification worker', { 41 - intervalMinutes: this.checkIntervalMs / 60000, 42 - }); 43 - 44 - // Run immediately on start 45 - await this.verifyAllDomains(); 46 - 47 - // Then run on interval 48 - this.interval = setInterval(() => { 49 - this.verifyAllDomains(); 50 - }, this.checkIntervalMs); 51 - } 52 - 53 - stop() { 54 - if (this.interval) { 55 - clearInterval(this.interval); 56 - this.interval = null; 57 - } 58 - this.isRunning = false; 59 - this.log('DNS verification worker stopped'); 60 - } 61 - 62 - private async verifyAllDomains() { 63 - this.log('Starting DNS verification check'); 64 - const startTime = Date.now(); 65 - 66 - const runStats: VerificationStats = { 67 - totalChecked: 0, 68 - verified: 0, 69 - failed: 0, 70 - errors: 0, 71 - }; 72 - 73 - try { 74 - // Get all verified custom domains 75 - const domains = await db` 76 - SELECT id, domain, did FROM custom_domains WHERE verified = true 77 - `; 78 - 79 - if (!domains || domains.length === 0) { 80 - this.log('No verified custom domains to check'); 81 - this.lastRunTime = Date.now(); 82 - return; 83 - } 84 - 85 - this.log(`Checking ${domains.length} verified custom domains`); 86 - 87 - // Verify each domain 88 - for (const row of domains) { 89 - runStats.totalChecked++; 90 - const { id, domain, did } = row; 91 - 92 - try { 93 - // Extract hash from id (SHA256 of did:domain) 94 - const expectedHash = id.substring(0, 16); 95 - 96 - // Verify DNS records 97 - const result = await verifyCustomDomain(domain, did, expectedHash); 98 - 99 - if (result.verified) { 100 - // Update last_verified_at timestamp 101 - await db` 102 - UPDATE custom_domains 103 - SET last_verified_at = EXTRACT(EPOCH FROM NOW()) 104 - WHERE id = ${id} 105 - `; 106 - runStats.verified++; 107 - this.log(`Domain verified: ${domain}`, { did }); 108 - } else { 109 - // Mark domain as unverified 110 - await db` 111 - UPDATE custom_domains 112 - SET verified = false, 113 - last_verified_at = EXTRACT(EPOCH FROM NOW()) 114 - WHERE id = ${id} 115 - `; 116 - runStats.failed++; 117 - this.log(`Domain verification failed: ${domain}`, { 118 - did, 119 - error: result.error, 120 - found: result.found, 121 - }); 122 - } 123 - } catch (error) { 124 - runStats.errors++; 125 - this.log(`Error verifying domain: ${domain}`, { 126 - did, 127 - error: error instanceof Error ? error.message : String(error), 128 - }); 129 - } 130 - } 131 - 132 - // Update cumulative stats 133 - this.stats.totalChecked += runStats.totalChecked; 134 - this.stats.verified += runStats.verified; 135 - this.stats.failed += runStats.failed; 136 - this.stats.errors += runStats.errors; 137 - 138 - const duration = Date.now() - startTime; 139 - this.lastRunTime = Date.now(); 140 - 141 - this.log('DNS verification check completed', { 142 - duration: `${duration}ms`, 143 - ...runStats, 144 - }); 145 - } catch (error) { 146 - this.log('Fatal error in DNS verification worker', { 147 - error: error instanceof Error ? error.message : String(error), 148 - }); 149 - } 150 - } 151 - 152 - getHealth() { 153 - return { 154 - isRunning: this.isRunning, 155 - lastRunTime: this.lastRunTime, 156 - intervalMs: this.checkIntervalMs, 157 - stats: this.stats, 158 - healthy: this.isRunning && ( 159 - this.lastRunTime === null || 160 - Date.now() - this.lastRunTime < this.checkIntervalMs * 2 161 - ), 162 - }; 163 - } 164 - 165 - // Manual trigger for testing 166 - async trigger() { 167 - this.log('Manual DNS verification triggered'); 168 - await this.verifyAllDomains(); 169 - } 170 - }
+259 -286
hosting-service/src/lib/firehose.ts
··· 1 - import { existsSync, rmSync } from 'fs'; 2 - import type { WispFsRecord } from './types'; 3 - import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; 4 - import { upsertSite } from './db'; 5 - import { safeFetch } from './safe-fetch'; 1 + import { existsSync, rmSync } from 'fs' 2 + import { 3 + getPdsForDid, 4 + downloadAndCacheSite, 5 + extractBlobCid, 6 + fetchSiteRecord 7 + } from './utils' 8 + import { upsertSite, tryAcquireLock, releaseLock } from './db' 9 + import { safeFetch } from './safe-fetch' 10 + import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 + import { Firehose } from '@atproto/sync' 12 + import { IdResolver } from '@atproto/identity' 6 13 7 - const CACHE_DIR = './cache/sites'; 8 - const JETSTREAM_URL = 'wss://jetstream2.us-west.bsky.network/subscribe'; 9 - const RECONNECT_DELAY = 5000; // 5 seconds 10 - const MAX_RECONNECT_DELAY = 60000; // 1 minute 11 - 12 - interface JetstreamCommitEvent { 13 - did: string; 14 - time_us: number; 15 - type: 'com' | 'identity' | 'account'; 16 - kind: 'commit'; 17 - commit: { 18 - rev: string; 19 - operation: 'create' | 'update' | 'delete'; 20 - collection: string; 21 - rkey: string; 22 - record?: any; 23 - cid?: string; 24 - }; 25 - } 26 - 27 - interface JetstreamIdentityEvent { 28 - did: string; 29 - time_us: number; 30 - type: 'identity'; 31 - kind: 'update'; 32 - identity: { 33 - did: string; 34 - handle: string; 35 - seq: number; 36 - time: string; 37 - }; 38 - } 39 - 40 - interface JetstreamAccountEvent { 41 - did: string; 42 - time_us: number; 43 - type: 'account'; 44 - kind: 'update' | 'delete'; 45 - account: { 46 - active: boolean; 47 - did: string; 48 - seq: number; 49 - time: string; 50 - }; 51 - } 52 - 53 - type JetstreamEvent = 54 - | JetstreamCommitEvent 55 - | JetstreamIdentityEvent 56 - | JetstreamAccountEvent; 14 + const CACHE_DIR = './cache/sites' 57 15 58 16 export class FirehoseWorker { 59 - private ws: WebSocket | null = null; 60 - private reconnectAttempts = 0; 61 - private reconnectTimeout: Timer | null = null; 62 - private isShuttingDown = false; 63 - private lastEventTime = Date.now(); 17 + private firehose: Firehose | null = null 18 + private idResolver: IdResolver 19 + private isShuttingDown = false 20 + private lastEventTime = Date.now() 64 21 65 - constructor( 66 - private logger?: (msg: string, data?: Record<string, unknown>) => void, 67 - ) {} 22 + constructor( 23 + private logger?: (msg: string, data?: Record<string, unknown>) => void 24 + ) { 25 + this.idResolver = new IdResolver() 26 + } 68 27 69 - private log(msg: string, data?: Record<string, unknown>) { 70 - const log = this.logger || console.log; 71 - log(`[FirehoseWorker] ${msg}`, data || {}); 72 - } 28 + private log(msg: string, data?: Record<string, unknown>) { 29 + const log = this.logger || console.log 30 + log(`[FirehoseWorker] ${msg}`, data || {}) 31 + } 73 32 74 - start() { 75 - this.log('Starting firehose worker'); 76 - this.connect(); 77 - } 33 + start() { 34 + this.log('Starting firehose worker') 35 + this.connect() 36 + } 78 37 79 - stop() { 80 - this.log('Stopping firehose worker'); 81 - this.isShuttingDown = true; 38 + stop() { 39 + this.log('Stopping firehose worker') 40 + this.isShuttingDown = true 82 41 83 - if (this.reconnectTimeout) { 84 - clearTimeout(this.reconnectTimeout); 85 - this.reconnectTimeout = null; 86 - } 42 + if (this.firehose) { 43 + this.firehose.destroy() 44 + this.firehose = null 45 + } 46 + } 87 47 88 - if (this.ws) { 89 - this.ws.close(); 90 - this.ws = null; 91 - } 92 - } 48 + private connect() { 49 + if (this.isShuttingDown) return 93 50 94 - private connect() { 95 - if (this.isShuttingDown) return; 51 + this.log('Connecting to AT Protocol firehose') 96 52 97 - const url = new URL(JETSTREAM_URL); 98 - url.searchParams.set('wantedCollections', 'place.wisp.fs'); 53 + this.firehose = new Firehose({ 54 + idResolver: this.idResolver, 55 + service: 'wss://bsky.network', 56 + filterCollections: ['place.wisp.fs'], 57 + handleEvent: async (evt: any) => { 58 + this.lastEventTime = Date.now() 99 59 100 - this.log('Connecting to Jetstream', { url: url.toString() }); 60 + // Watch for write events 61 + if (evt.event === 'create' || evt.event === 'update') { 62 + const record = evt.record 101 63 102 - try { 103 - this.ws = new WebSocket(url.toString()); 64 + // If the write is a valid place.wisp.fs record 65 + if ( 66 + evt.collection === 'place.wisp.fs' && 67 + isRecord(record) && 68 + validateRecord(record).success 69 + ) { 70 + this.log('Received place.wisp.fs event', { 71 + did: evt.did, 72 + event: evt.event, 73 + rkey: evt.rkey 74 + }) 104 75 105 - this.ws.onopen = () => { 106 - this.log('Connected to Jetstream'); 107 - this.reconnectAttempts = 0; 108 - this.lastEventTime = Date.now(); 109 - }; 76 + try { 77 + await this.handleCreateOrUpdate( 78 + evt.did, 79 + evt.rkey, 80 + record, 81 + evt.cid?.toString() 82 + ) 83 + } catch (err) { 84 + this.log('Error handling event', { 85 + did: evt.did, 86 + event: evt.event, 87 + rkey: evt.rkey, 88 + error: 89 + err instanceof Error 90 + ? err.message 91 + : String(err) 92 + }) 93 + } 94 + } 95 + } else if ( 96 + evt.event === 'delete' && 97 + evt.collection === 'place.wisp.fs' 98 + ) { 99 + this.log('Received delete event', { 100 + did: evt.did, 101 + rkey: evt.rkey 102 + }) 110 103 111 - this.ws.onmessage = async (event) => { 112 - this.lastEventTime = Date.now(); 104 + try { 105 + await this.handleDelete(evt.did, evt.rkey) 106 + } catch (err) { 107 + this.log('Error handling delete', { 108 + did: evt.did, 109 + rkey: evt.rkey, 110 + error: 111 + err instanceof Error ? err.message : String(err) 112 + }) 113 + } 114 + } 115 + }, 116 + onError: (err: any) => { 117 + this.log('Firehose error', { 118 + error: err instanceof Error ? err.message : String(err), 119 + stack: err instanceof Error ? err.stack : undefined, 120 + fullError: err 121 + }) 122 + console.error('Full firehose error:', err) 123 + } 124 + }) 113 125 114 - try { 115 - const data = JSON.parse(event.data as string) as JetstreamEvent; 116 - await this.handleEvent(data); 117 - } catch (err) { 118 - this.log('Error processing event', { 119 - error: err instanceof Error ? err.message : String(err), 120 - }); 121 - } 122 - }; 126 + this.firehose.start() 127 + this.log('Firehose started') 128 + } 123 129 124 - this.ws.onerror = (error) => { 125 - this.log('WebSocket error', { error: String(error) }); 126 - }; 130 + private async handleCreateOrUpdate( 131 + did: string, 132 + site: string, 133 + record: any, 134 + eventCid?: string 135 + ) { 136 + this.log('Processing create/update', { did, site }) 127 137 128 - this.ws.onclose = () => { 129 - this.log('WebSocket closed'); 130 - this.ws = null; 138 + // Record is already validated in handleEvent 139 + const fsRecord = record 131 140 132 - if (!this.isShuttingDown) { 133 - this.scheduleReconnect(); 134 - } 135 - }; 136 - } catch (err) { 137 - this.log('Failed to create WebSocket', { 138 - error: err instanceof Error ? err.message : String(err), 139 - }); 140 - this.scheduleReconnect(); 141 - } 142 - } 141 + const pdsEndpoint = await getPdsForDid(did) 142 + if (!pdsEndpoint) { 143 + this.log('Could not resolve PDS for DID', { did }) 144 + return 145 + } 143 146 144 - private scheduleReconnect() { 145 - if (this.isShuttingDown) return; 147 + this.log('Resolved PDS', { did, pdsEndpoint }) 146 148 147 - this.reconnectAttempts++; 148 - const delay = Math.min( 149 - RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), 150 - MAX_RECONNECT_DELAY, 151 - ); 149 + // Verify record exists on PDS and fetch its CID 150 + let verifiedCid: string 151 + try { 152 + const result = await fetchSiteRecord(did, site) 152 153 153 - this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`, { 154 - delay: `${delay}ms`, 155 - }); 154 + if (!result) { 155 + this.log('Record not found on PDS, skipping cache', { 156 + did, 157 + site 158 + }) 159 + return 160 + } 156 161 157 - this.reconnectTimeout = setTimeout(() => { 158 - this.connect(); 159 - }, delay); 160 - } 162 + verifiedCid = result.cid 161 163 162 - private async handleEvent(event: JetstreamEvent) { 163 - if (event.kind !== 'commit') return; 164 - 165 - const commitEvent = event as JetstreamCommitEvent; 166 - const { commit, did } = commitEvent; 167 - 168 - if (commit.collection !== 'place.wisp.fs') return; 169 - 170 - this.log('Received place.wisp.fs event', { 171 - did, 172 - operation: commit.operation, 173 - rkey: commit.rkey, 174 - }); 175 - 176 - try { 177 - if (commit.operation === 'create' || commit.operation === 'update') { 178 - await this.handleCreateOrUpdate(did, commit.rkey, commit.record); 179 - } else if (commit.operation === 'delete') { 180 - await this.handleDelete(did, commit.rkey); 181 - } 182 - } catch (err) { 183 - this.log('Error handling event', { 184 - did, 185 - operation: commit.operation, 186 - rkey: commit.rkey, 187 - error: err instanceof Error ? err.message : String(err), 188 - }); 189 - } 190 - } 191 - 192 - private async handleCreateOrUpdate(did: string, site: string, record: any) { 193 - this.log('Processing create/update', { did, site }); 194 - 195 - if (!this.validateRecord(record)) { 196 - this.log('Invalid record structure, skipping', { did, site }); 197 - return; 198 - } 199 - 200 - const fsRecord = record as WispFsRecord; 201 - 202 - const pdsEndpoint = await getPdsForDid(did); 203 - if (!pdsEndpoint) { 204 - this.log('Could not resolve PDS for DID', { did }); 205 - return; 206 - } 207 - 208 - this.log('Resolved PDS', { did, pdsEndpoint }); 209 - 210 - // Verify record exists on PDS 211 - try { 212 - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; 213 - const recordRes = await safeFetch(recordUrl); 214 - 215 - if (!recordRes.ok) { 216 - this.log('Record not found on PDS, skipping cache', { 217 - did, 218 - site, 219 - status: recordRes.status, 220 - }); 221 - return; 222 - } 164 + // Verify event CID matches PDS CID (prevent cache poisoning) 165 + if (eventCid && eventCid !== verifiedCid) { 166 + this.log('CID mismatch detected - potential spoofed event', { 167 + did, 168 + site, 169 + eventCid, 170 + verifiedCid 171 + }) 172 + return 173 + } 223 174 224 - this.log('Record verified on PDS', { did, site }); 225 - } catch (err) { 226 - this.log('Failed to verify record on PDS', { 227 - did, 228 - site, 229 - error: err instanceof Error ? err.message : String(err), 230 - }); 231 - return; 232 - } 175 + this.log('Record verified on PDS', { did, site, cid: verifiedCid }) 176 + } catch (err) { 177 + this.log('Failed to verify record on PDS', { 178 + did, 179 + site, 180 + error: err instanceof Error ? err.message : String(err) 181 + }) 182 + return 183 + } 233 184 234 - // Cache the record 235 - await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint); 185 + // Cache the record with verified CID (uses atomic swap internally) 186 + // All instances cache locally for edge serving 187 + await downloadAndCacheSite( 188 + did, 189 + site, 190 + fsRecord, 191 + pdsEndpoint, 192 + verifiedCid 193 + ) 236 194 237 - // Upsert site to database 238 - await upsertSite(did, site, fsRecord.site); 195 + // Acquire distributed lock only for database write to prevent duplicate writes 196 + const lockKey = `db:upsert:${did}:${site}` 197 + const lockAcquired = await tryAcquireLock(lockKey) 239 198 240 - this.log('Successfully processed create/update', { did, site }); 241 - } 199 + if (!lockAcquired) { 200 + this.log('Another instance is writing to DB, skipping upsert', { 201 + did, 202 + site 203 + }) 204 + this.log('Successfully processed create/update (cached locally)', { 205 + did, 206 + site 207 + }) 208 + return 209 + } 242 210 243 - private async handleDelete(did: string, site: string) { 244 - this.log('Processing delete', { did, site }); 211 + try { 212 + // Upsert site to database (only one instance does this) 213 + await upsertSite(did, site, fsRecord.site) 214 + this.log( 215 + 'Successfully processed create/update (cached + DB updated)', 216 + { did, site } 217 + ) 218 + } finally { 219 + // Always release lock, even if DB write fails 220 + await releaseLock(lockKey) 221 + } 222 + } 245 223 246 - const pdsEndpoint = await getPdsForDid(did); 247 - if (!pdsEndpoint) { 248 - this.log('Could not resolve PDS for DID', { did }); 249 - return; 250 - } 224 + private async handleDelete(did: string, site: string) { 225 + this.log('Processing delete', { did, site }) 251 226 252 - // Verify record is actually deleted from PDS 253 - try { 254 - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; 255 - const recordRes = await safeFetch(recordUrl); 227 + // All instances should delete their local cache (no lock needed) 228 + const pdsEndpoint = await getPdsForDid(did) 229 + if (!pdsEndpoint) { 230 + this.log('Could not resolve PDS for DID', { did }) 231 + return 232 + } 256 233 257 - if (recordRes.ok) { 258 - this.log('Record still exists on PDS, not deleting cache', { 259 - did, 260 - site, 261 - }); 262 - return; 263 - } 234 + // Verify record is actually deleted from PDS 235 + try { 236 + const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}` 237 + const recordRes = await safeFetch(recordUrl) 264 238 265 - this.log('Verified record is deleted from PDS', { 266 - did, 267 - site, 268 - status: recordRes.status, 269 - }); 270 - } catch (err) { 271 - this.log('Error verifying deletion on PDS', { 272 - did, 273 - site, 274 - error: err instanceof Error ? err.message : String(err), 275 - }); 276 - } 239 + if (recordRes.ok) { 240 + this.log('Record still exists on PDS, not deleting cache', { 241 + did, 242 + site 243 + }) 244 + return 245 + } 277 246 278 - // Delete cache 279 - this.deleteCache(did, site); 247 + this.log('Verified record is deleted from PDS', { 248 + did, 249 + site, 250 + status: recordRes.status 251 + }) 252 + } catch (err) { 253 + this.log('Error verifying deletion on PDS', { 254 + did, 255 + site, 256 + error: err instanceof Error ? err.message : String(err) 257 + }) 258 + } 280 259 281 - this.log('Successfully processed delete', { did, site }); 282 - } 260 + // Delete cache 261 + this.deleteCache(did, site) 283 262 284 - private validateRecord(record: any): boolean { 285 - if (!record || typeof record !== 'object') return false; 286 - if (record.$type !== 'place.wisp.fs') return false; 287 - if (!record.root || typeof record.root !== 'object') return false; 288 - if (!record.site || typeof record.site !== 'string') return false; 289 - return true; 290 - } 263 + this.log('Successfully processed delete', { did, site }) 264 + } 291 265 292 - private deleteCache(did: string, site: string) { 293 - const cacheDir = `${CACHE_DIR}/${did}/${site}`; 266 + private deleteCache(did: string, site: string) { 267 + const cacheDir = `${CACHE_DIR}/${did}/${site}` 294 268 295 - if (!existsSync(cacheDir)) { 296 - this.log('Cache directory does not exist, nothing to delete', { 297 - did, 298 - site, 299 - }); 300 - return; 301 - } 269 + if (!existsSync(cacheDir)) { 270 + this.log('Cache directory does not exist, nothing to delete', { 271 + did, 272 + site 273 + }) 274 + return 275 + } 302 276 303 - try { 304 - rmSync(cacheDir, { recursive: true, force: true }); 305 - this.log('Cache deleted', { did, site, path: cacheDir }); 306 - } catch (err) { 307 - this.log('Failed to delete cache', { 308 - did, 309 - site, 310 - path: cacheDir, 311 - error: err instanceof Error ? err.message : String(err), 312 - }); 313 - } 314 - } 277 + try { 278 + rmSync(cacheDir, { recursive: true, force: true }) 279 + this.log('Cache deleted', { did, site, path: cacheDir }) 280 + } catch (err) { 281 + this.log('Failed to delete cache', { 282 + did, 283 + site, 284 + path: cacheDir, 285 + error: err instanceof Error ? err.message : String(err) 286 + }) 287 + } 288 + } 315 289 316 - getHealth() { 317 - const isConnected = this.ws !== null && this.ws.readyState === WebSocket.OPEN; 318 - const timeSinceLastEvent = Date.now() - this.lastEventTime; 290 + getHealth() { 291 + const isConnected = this.firehose !== null 292 + const timeSinceLastEvent = Date.now() - this.lastEventTime 319 293 320 - return { 321 - connected: isConnected, 322 - reconnectAttempts: this.reconnectAttempts, 323 - lastEventTime: this.lastEventTime, 324 - timeSinceLastEvent, 325 - healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes 326 - }; 327 - } 294 + return { 295 + connected: isConnected, 296 + lastEventTime: this.lastEventTime, 297 + timeSinceLastEvent, 298 + healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes 299 + } 300 + } 328 301 }
+434 -84
hosting-service/src/lib/html-rewriter.test.ts
··· 1 - /** 2 - * Simple tests for HTML path rewriter 3 - * Run with: bun test 4 - */ 1 + import { describe, test, expect } from 'bun:test' 2 + import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter' 3 + 4 + describe('rewriteHtmlPaths', () => { 5 + const basePath = '/identifier/site/' 6 + 7 + describe('absolute paths', () => { 8 + test('rewrites absolute paths with leading slash', () => { 9 + const html = '<img src="/image.png">' 10 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 11 + expect(result).toBe('<img src="/identifier/site/image.png">') 12 + }) 13 + 14 + test('rewrites nested absolute paths', () => { 15 + const html = '<link href="/css/style.css">' 16 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 17 + expect(result).toBe('<link href="/identifier/site/css/style.css">') 18 + }) 19 + }) 20 + 21 + describe('relative paths from root document', () => { 22 + test('rewrites relative paths with ./ prefix', () => { 23 + const html = '<img src="./image.png">' 24 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 25 + expect(result).toBe('<img src="/identifier/site/image.png">') 26 + }) 27 + 28 + test('rewrites relative paths without prefix', () => { 29 + const html = '<img src="image.png">' 30 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 31 + expect(result).toBe('<img src="/identifier/site/image.png">') 32 + }) 33 + 34 + test('rewrites relative paths with ../ (should stay at root)', () => { 35 + const html = '<img src="../image.png">' 36 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 37 + expect(result).toBe('<img src="/identifier/site/image.png">') 38 + }) 39 + }) 40 + 41 + describe('relative paths from nested documents', () => { 42 + test('rewrites relative path from nested document', () => { 43 + const html = '<img src="./photo.jpg">' 44 + const result = rewriteHtmlPaths( 45 + html, 46 + basePath, 47 + 'folder1/folder2/index.html' 48 + ) 49 + expect(result).toBe( 50 + '<img src="/identifier/site/folder1/folder2/photo.jpg">' 51 + ) 52 + }) 53 + 54 + test('rewrites plain filename from nested document', () => { 55 + const html = '<script src="app.js"></script>' 56 + const result = rewriteHtmlPaths( 57 + html, 58 + basePath, 59 + 'folder1/folder2/index.html' 60 + ) 61 + expect(result).toBe( 62 + '<script src="/identifier/site/folder1/folder2/app.js"></script>' 63 + ) 64 + }) 65 + 66 + test('rewrites ../ to go up one level', () => { 67 + const html = '<img src="../image.png">' 68 + const result = rewriteHtmlPaths( 69 + html, 70 + basePath, 71 + 'folder1/folder2/folder3/index.html' 72 + ) 73 + expect(result).toBe( 74 + '<img src="/identifier/site/folder1/folder2/image.png">' 75 + ) 76 + }) 77 + 78 + test('rewrites multiple ../ to go up multiple levels', () => { 79 + const html = '<link href="../../css/style.css">' 80 + const result = rewriteHtmlPaths( 81 + html, 82 + basePath, 83 + 'folder1/folder2/folder3/index.html' 84 + ) 85 + expect(result).toBe( 86 + '<link href="/identifier/site/folder1/css/style.css">' 87 + ) 88 + }) 89 + 90 + test('rewrites ../ with additional path segments', () => { 91 + const html = '<img src="../assets/logo.png">' 92 + const result = rewriteHtmlPaths( 93 + html, 94 + basePath, 95 + 'pages/about/index.html' 96 + ) 97 + expect(result).toBe( 98 + '<img src="/identifier/site/pages/assets/logo.png">' 99 + ) 100 + }) 101 + 102 + test('handles complex nested relative paths', () => { 103 + const html = '<script src="../../lib/vendor/jquery.js"></script>' 104 + const result = rewriteHtmlPaths( 105 + html, 106 + basePath, 107 + 'pages/blog/post/index.html' 108 + ) 109 + expect(result).toBe( 110 + '<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>' 111 + ) 112 + }) 113 + 114 + test('handles ../ going past root (stays at root)', () => { 115 + const html = '<img src="../../../image.png">' 116 + const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html') 117 + expect(result).toBe('<img src="/identifier/site/image.png">') 118 + }) 119 + }) 120 + 121 + describe('external URLs and special schemes', () => { 122 + test('does not rewrite http URLs', () => { 123 + const html = '<img src="http://example.com/image.png">' 124 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 125 + expect(result).toBe('<img src="http://example.com/image.png">') 126 + }) 5 127 6 - import { test, expect } from 'bun:test'; 7 - import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'; 128 + test('does not rewrite https URLs', () => { 129 + const html = '<link href="https://cdn.example.com/style.css">' 130 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 131 + expect(result).toBe( 132 + '<link href="https://cdn.example.com/style.css">' 133 + ) 134 + }) 8 135 9 - test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => { 10 - const html = '<img src="/logo.png">'; 11 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 12 - expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">'); 13 - }); 136 + test('does not rewrite protocol-relative URLs', () => { 137 + const html = '<script src="//cdn.example.com/script.js"></script>' 138 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 139 + expect(result).toBe( 140 + '<script src="//cdn.example.com/script.js"></script>' 141 + ) 142 + }) 14 143 15 - test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => { 16 - const html = '<link rel="stylesheet" href="/style.css">'; 17 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 18 - expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">'); 19 - }); 144 + test('does not rewrite data URIs', () => { 145 + const html = 146 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 147 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 148 + expect(result).toBe( 149 + '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">' 150 + ) 151 + }) 20 152 21 - test('rewriteHtmlPaths - preserves external URLs', () => { 22 - const html = '<img src="https://example.com/logo.png">'; 23 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 24 - expect(result).toBe('<img src="https://example.com/logo.png">'); 25 - }); 153 + test('does not rewrite mailto links', () => { 154 + const html = '<a href="mailto:test@example.com">Email</a>' 155 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 156 + expect(result).toBe('<a href="mailto:test@example.com">Email</a>') 157 + }) 26 158 27 - test('rewriteHtmlPaths - preserves protocol-relative URLs', () => { 28 - const html = '<script src="//cdn.example.com/script.js"></script>'; 29 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 30 - expect(result).toBe('<script src="//cdn.example.com/script.js"></script>'); 31 - }); 159 + test('does not rewrite tel links', () => { 160 + const html = '<a href="tel:+1234567890">Call</a>' 161 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 162 + expect(result).toBe('<a href="tel:+1234567890">Call</a>') 163 + }) 164 + }) 32 165 33 - test('rewriteHtmlPaths - preserves data URIs', () => { 34 - const html = '<img src="data:image/png;base64,abc123">'; 35 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 36 - expect(result).toBe('<img src="data:image/png;base64,abc123">'); 37 - }); 166 + describe('different HTML attributes', () => { 167 + test('rewrites src attribute', () => { 168 + const html = '<img src="/image.png">' 169 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 170 + expect(result).toBe('<img src="/identifier/site/image.png">') 171 + }) 38 172 39 - test('rewriteHtmlPaths - preserves anchors', () => { 40 - const html = '<a href="/#section">Jump</a>'; 41 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 42 - expect(result).toBe('<a href="/#section">Jump</a>'); 43 - }); 173 + test('rewrites href attribute', () => { 174 + const html = '<a href="/page.html">Link</a>' 175 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 176 + expect(result).toBe('<a href="/identifier/site/page.html">Link</a>') 177 + }) 44 178 45 - test('rewriteHtmlPaths - preserves relative paths', () => { 46 - const html = '<img src="./logo.png">'; 47 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 48 - expect(result).toBe('<img src="./logo.png">'); 49 - }); 179 + test('rewrites action attribute', () => { 180 + const html = '<form action="/submit"></form>' 181 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 182 + expect(result).toBe('<form action="/identifier/site/submit"></form>') 183 + }) 50 184 51 - test('rewriteHtmlPaths - handles single quotes', () => { 52 - const html = "<img src='/logo.png'>"; 53 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 54 - expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>"); 55 - }); 185 + test('rewrites data attribute', () => { 186 + const html = '<object data="/document.pdf"></object>' 187 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 188 + expect(result).toBe( 189 + '<object data="/identifier/site/document.pdf"></object>' 190 + ) 191 + }) 56 192 57 - test('rewriteHtmlPaths - handles srcset', () => { 58 - const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">'; 59 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 60 - expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">'); 61 - }); 193 + test('rewrites poster attribute', () => { 194 + const html = '<video poster="/thumbnail.jpg"></video>' 195 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 196 + expect(result).toBe( 197 + '<video poster="/identifier/site/thumbnail.jpg"></video>' 198 + ) 199 + }) 62 200 63 - test('rewriteHtmlPaths - handles form actions', () => { 64 - const html = '<form action="/submit"></form>'; 65 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 66 - expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>'); 67 - }); 201 + test('rewrites srcset attribute with single URL', () => { 202 + const html = '<img srcset="/image.png 1x">' 203 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 204 + expect(result).toBe( 205 + '<img srcset="/identifier/site/image.png 1x">' 206 + ) 207 + }) 68 208 69 - test('rewriteHtmlPaths - handles complex HTML', () => { 70 - const html = ` 209 + test('rewrites srcset attribute with multiple URLs', () => { 210 + const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">' 211 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 212 + expect(result).toBe( 213 + '<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">' 214 + ) 215 + }) 216 + 217 + test('rewrites srcset with width descriptors', () => { 218 + const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">' 219 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 220 + expect(result).toBe( 221 + '<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">' 222 + ) 223 + }) 224 + 225 + test('rewrites srcset with relative paths from nested document', () => { 226 + const html = '<img srcset="../img1.png 1x, ../img2.png 2x">' 227 + const result = rewriteHtmlPaths( 228 + html, 229 + basePath, 230 + 'folder1/folder2/index.html' 231 + ) 232 + expect(result).toBe( 233 + '<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">' 234 + ) 235 + }) 236 + }) 237 + 238 + describe('quote handling', () => { 239 + test('handles double quotes', () => { 240 + const html = '<img src="/image.png">' 241 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 242 + expect(result).toBe('<img src="/identifier/site/image.png">') 243 + }) 244 + 245 + test('handles single quotes', () => { 246 + const html = "<img src='/image.png'>" 247 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 248 + expect(result).toBe("<img src='/identifier/site/image.png'>") 249 + }) 250 + 251 + test('handles mixed quotes in same document', () => { 252 + const html = '<img src="/img1.png"><link href=\'/style.css\'>' 253 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 254 + expect(result).toBe( 255 + '<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>' 256 + ) 257 + }) 258 + }) 259 + 260 + describe('multiple rewrites in same document', () => { 261 + test('rewrites multiple attributes in complex HTML', () => { 262 + const html = ` 71 263 <!DOCTYPE html> 72 264 <html> 73 265 <head> 74 - <link rel="stylesheet" href="/style.css"> 75 - <script src="/app.js"></script> 266 + <link href="/css/style.css" rel="stylesheet"> 267 + <script src="/js/app.js"></script> 268 + </head> 269 + <body> 270 + <img src="/images/logo.png" alt="Logo"> 271 + <a href="/about.html">About</a> 272 + <form action="/submit"> 273 + <button type="submit">Submit</button> 274 + </form> 275 + </body> 276 + </html> 277 + ` 278 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 279 + expect(result).toContain('href="/identifier/site/css/style.css"') 280 + expect(result).toContain('src="/identifier/site/js/app.js"') 281 + expect(result).toContain('src="/identifier/site/images/logo.png"') 282 + expect(result).toContain('href="/identifier/site/about.html"') 283 + expect(result).toContain('action="/identifier/site/submit"') 284 + }) 285 + 286 + test('handles mix of relative and absolute paths', () => { 287 + const html = ` 288 + <img src="/abs/image.png"> 289 + <img src="./rel/image.png"> 290 + <img src="../parent/image.png"> 291 + <img src="https://external.com/image.png"> 292 + ` 293 + const result = rewriteHtmlPaths( 294 + html, 295 + basePath, 296 + 'folder1/folder2/page.html' 297 + ) 298 + expect(result).toContain('src="/identifier/site/abs/image.png"') 299 + expect(result).toContain( 300 + 'src="/identifier/site/folder1/folder2/rel/image.png"' 301 + ) 302 + expect(result).toContain( 303 + 'src="/identifier/site/folder1/parent/image.png"' 304 + ) 305 + expect(result).toContain('src="https://external.com/image.png"') 306 + }) 307 + }) 308 + 309 + describe('edge cases', () => { 310 + test('handles empty src attribute', () => { 311 + const html = '<img src="">' 312 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 313 + expect(result).toBe('<img src="">') 314 + }) 315 + 316 + test('handles basePath without trailing slash', () => { 317 + const html = '<img src="/image.png">' 318 + const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html') 319 + expect(result).toBe('<img src="/identifier/site/image.png">') 320 + }) 321 + 322 + test('handles basePath with trailing slash', () => { 323 + const html = '<img src="/image.png">' 324 + const result = rewriteHtmlPaths( 325 + html, 326 + '/identifier/site/', 327 + 'index.html' 328 + ) 329 + expect(result).toBe('<img src="/identifier/site/image.png">') 330 + }) 331 + 332 + test('handles whitespace around equals sign', () => { 333 + const html = '<img src = "/image.png">' 334 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 335 + expect(result).toBe('<img src="/identifier/site/image.png">') 336 + }) 337 + 338 + test('preserves query strings in URLs', () => { 339 + const html = '<img src="/image.png?v=123">' 340 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 341 + expect(result).toBe('<img src="/identifier/site/image.png?v=123">') 342 + }) 343 + 344 + test('preserves hash fragments in URLs', () => { 345 + const html = '<a href="/page.html#section">Link</a>' 346 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 347 + expect(result).toBe( 348 + '<a href="/identifier/site/page.html#section">Link</a>' 349 + ) 350 + }) 351 + 352 + test('handles paths with special characters', () => { 353 + const html = '<img src="/folder-name/file_name.png">' 354 + const result = rewriteHtmlPaths(html, basePath, 'index.html') 355 + expect(result).toBe( 356 + '<img src="/identifier/site/folder-name/file_name.png">' 357 + ) 358 + }) 359 + }) 360 + 361 + describe('real-world scenario', () => { 362 + test('handles the example from the bug report', () => { 363 + // HTML file at: /folder1/folder2/folder3/index.html 364 + // Image at: /folder1/folder2/img.png 365 + // Reference: src="../img.png" 366 + const html = '<img src="../img.png">' 367 + const result = rewriteHtmlPaths( 368 + html, 369 + basePath, 370 + 'folder1/folder2/folder3/index.html' 371 + ) 372 + expect(result).toBe( 373 + '<img src="/identifier/site/folder1/folder2/img.png">' 374 + ) 375 + }) 376 + 377 + test('handles deeply nested static site structure', () => { 378 + // A typical static site with nested pages and shared assets 379 + const html = ` 380 + <!DOCTYPE html> 381 + <html> 382 + <head> 383 + <link href="../../css/style.css" rel="stylesheet"> 384 + <link href="../../css/theme.css" rel="stylesheet"> 385 + <script src="../../js/main.js"></script> 76 386 </head> 77 387 <body> 78 - <img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x"> 79 - <a href="/about">About</a> 80 - <a href="https://example.com">External</a> 81 - <a href="#section">Anchor</a> 388 + <img src="../../images/logo.png" alt="Logo"> 389 + <img src="./post-image.jpg" alt="Post"> 390 + <a href="../index.html">Back to Blog</a> 391 + <a href="../../index.html">Home</a> 82 392 </body> 83 393 </html> 84 - `.trim(); 394 + ` 395 + const result = rewriteHtmlPaths( 396 + html, 397 + basePath, 398 + 'blog/posts/my-post.html' 399 + ) 400 + 401 + // Assets two levels up 402 + expect(result).toContain('href="/identifier/site/css/style.css"') 403 + expect(result).toContain('href="/identifier/site/css/theme.css"') 404 + expect(result).toContain('src="/identifier/site/js/main.js"') 405 + expect(result).toContain('src="/identifier/site/images/logo.png"') 406 + 407 + // Same directory 408 + expect(result).toContain( 409 + 'src="/identifier/site/blog/posts/post-image.jpg"' 410 + ) 85 411 86 - const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/'); 412 + // One level up 413 + expect(result).toContain('href="/identifier/site/blog/index.html"') 87 414 88 - expect(result).toContain('href="/did:plc:123/mysite/style.css"'); 89 - expect(result).toContain('src="/did:plc:123/mysite/app.js"'); 90 - expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"'); 91 - expect(result).toContain('href="/did:plc:123/mysite/about"'); 92 - expect(result).toContain('href="https://example.com"'); // External preserved 93 - expect(result).toContain('href="#section"'); // Anchor preserved 94 - }); 415 + // Two levels up 416 + expect(result).toContain('href="/identifier/site/index.html"') 417 + }) 418 + }) 419 + }) 95 420 96 - test('isHtmlContent - detects HTML by extension', () => { 97 - expect(isHtmlContent('index.html')).toBe(true); 98 - expect(isHtmlContent('page.htm')).toBe(true); 99 - expect(isHtmlContent('style.css')).toBe(false); 100 - expect(isHtmlContent('script.js')).toBe(false); 101 - }); 421 + describe('isHtmlContent', () => { 422 + test('identifies HTML by content type', () => { 423 + expect(isHtmlContent('file.txt', 'text/html')).toBe(true) 424 + expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe( 425 + true 426 + ) 427 + }) 102 428 103 - test('isHtmlContent - detects HTML by content type', () => { 104 - expect(isHtmlContent('index', 'text/html')).toBe(true); 105 - expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true); 106 - expect(isHtmlContent('index', 'application/json')).toBe(false); 107 - }); 429 + test('identifies HTML by .html extension', () => { 430 + expect(isHtmlContent('index.html')).toBe(true) 431 + expect(isHtmlContent('page.html', undefined)).toBe(true) 432 + expect(isHtmlContent('/path/to/file.html')).toBe(true) 433 + }) 434 + 435 + test('identifies HTML by .htm extension', () => { 436 + expect(isHtmlContent('index.htm')).toBe(true) 437 + expect(isHtmlContent('page.htm', undefined)).toBe(true) 438 + }) 439 + 440 + test('handles case-insensitive extensions', () => { 441 + expect(isHtmlContent('INDEX.HTML')).toBe(true) 442 + expect(isHtmlContent('page.HTM')).toBe(true) 443 + expect(isHtmlContent('File.HtMl')).toBe(true) 444 + }) 445 + 446 + test('returns false for non-HTML files', () => { 447 + expect(isHtmlContent('script.js')).toBe(false) 448 + expect(isHtmlContent('style.css')).toBe(false) 449 + expect(isHtmlContent('image.png')).toBe(false) 450 + expect(isHtmlContent('data.json')).toBe(false) 451 + }) 452 + 453 + test('returns false for files with no extension', () => { 454 + expect(isHtmlContent('README')).toBe(false) 455 + expect(isHtmlContent('Makefile')).toBe(false) 456 + }) 457 + })
+180 -84
hosting-service/src/lib/html-rewriter.ts
··· 4 4 */ 5 5 6 6 const REWRITABLE_ATTRIBUTES = [ 7 - 'src', 8 - 'href', 9 - 'action', 10 - 'data', 11 - 'poster', 12 - 'srcset', 13 - ] as const; 7 + 'src', 8 + 'href', 9 + 'action', 10 + 'data', 11 + 'poster', 12 + 'srcset' 13 + ] as const 14 14 15 15 /** 16 16 * Check if a path should be rewritten 17 17 */ 18 18 function shouldRewritePath(path: string): boolean { 19 - // Must start with / 20 - if (!path.startsWith('/')) return false; 19 + // Don't rewrite empty paths 20 + if (!path) return false 21 21 22 - // Don't rewrite protocol-relative URLs 23 - if (path.startsWith('//')) return false; 22 + // Don't rewrite external URLs (http://, https://, //) 23 + if ( 24 + path.startsWith('http://') || 25 + path.startsWith('https://') || 26 + path.startsWith('//') 27 + ) { 28 + return false 29 + } 24 30 25 - // Don't rewrite anchors 26 - if (path.startsWith('/#')) return false; 31 + // Don't rewrite data URIs or other schemes (except file paths) 32 + if ( 33 + path.includes(':') && 34 + !path.startsWith('./') && 35 + !path.startsWith('../') 36 + ) { 37 + return false 38 + } 39 + 40 + // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) 41 + return true 42 + } 27 43 28 - // Don't rewrite data URIs or other schemes 29 - if (path.includes(':')) return false; 44 + /** 45 + * Normalize a path by resolving . and .. segments 46 + */ 47 + function normalizePath(path: string): string { 48 + const parts = path.split('/') 49 + const result: string[] = [] 30 50 31 - return true; 51 + for (const part of parts) { 52 + if (part === '.' || part === '') { 53 + // Skip current directory and empty parts (but keep leading empty for absolute paths) 54 + if (part === '' && result.length === 0) { 55 + result.push(part) 56 + } 57 + continue 58 + } 59 + if (part === '..') { 60 + // Go up one directory (but not past root) 61 + if (result.length > 0 && result[result.length - 1] !== '..') { 62 + result.pop() 63 + } 64 + continue 65 + } 66 + result.push(part) 67 + } 68 + 69 + return result.join('/') 70 + } 71 + 72 + /** 73 + * Get the directory path from a file path 74 + * e.g., "folder1/folder2/file.html" -> "folder1/folder2/" 75 + */ 76 + function getDirectory(filepath: string): string { 77 + const lastSlash = filepath.lastIndexOf('/') 78 + if (lastSlash === -1) { 79 + return '' 80 + } 81 + return filepath.substring(0, lastSlash + 1) 32 82 } 33 83 34 84 /** 35 85 * Rewrite a single path 36 86 */ 37 - function rewritePath(path: string, basePath: string): string { 38 - if (!shouldRewritePath(path)) { 39 - return path; 40 - } 87 + function rewritePath( 88 + path: string, 89 + basePath: string, 90 + documentPath: string 91 + ): string { 92 + if (!shouldRewritePath(path)) { 93 + return path 94 + } 41 95 42 - // Remove leading slash and prepend base path 43 - return basePath + path.slice(1); 96 + // Handle absolute paths: /file.js -> /base/file.js 97 + if (path.startsWith('/')) { 98 + return basePath + path.slice(1) 99 + } 100 + 101 + // Handle relative paths by resolving against document directory 102 + const documentDir = getDirectory(documentPath) 103 + let resolvedPath: string 104 + 105 + if (path.startsWith('./')) { 106 + // ./file.js relative to current directory 107 + resolvedPath = documentDir + path.slice(2) 108 + } else if (path.startsWith('../')) { 109 + // ../file.js relative to parent directory 110 + resolvedPath = documentDir + path 111 + } else { 112 + // file.js (no prefix) - treat as relative to current directory 113 + resolvedPath = documentDir + path 114 + } 115 + 116 + // Normalize the path to resolve .. and . 117 + resolvedPath = normalizePath(resolvedPath) 118 + 119 + return basePath + resolvedPath 44 120 } 45 121 46 122 /** 47 123 * Rewrite srcset attribute (can contain multiple URLs) 48 124 * Format: "url1 1x, url2 2x" or "url1 100w, url2 200w" 49 125 */ 50 - function rewriteSrcset(srcset: string, basePath: string): string { 51 - return srcset 52 - .split(',') 53 - .map(part => { 54 - const trimmed = part.trim(); 55 - const spaceIndex = trimmed.indexOf(' '); 126 + function rewriteSrcset( 127 + srcset: string, 128 + basePath: string, 129 + documentPath: string 130 + ): string { 131 + return srcset 132 + .split(',') 133 + .map((part) => { 134 + const trimmed = part.trim() 135 + const spaceIndex = trimmed.indexOf(' ') 56 136 57 - if (spaceIndex === -1) { 58 - // No descriptor, just URL 59 - return rewritePath(trimmed, basePath); 60 - } 137 + if (spaceIndex === -1) { 138 + // No descriptor, just URL 139 + return rewritePath(trimmed, basePath, documentPath) 140 + } 61 141 62 - const url = trimmed.substring(0, spaceIndex); 63 - const descriptor = trimmed.substring(spaceIndex); 64 - return rewritePath(url, basePath) + descriptor; 65 - }) 66 - .join(', '); 142 + const url = trimmed.substring(0, spaceIndex) 143 + const descriptor = trimmed.substring(spaceIndex) 144 + return rewritePath(url, basePath, documentPath) + descriptor 145 + }) 146 + .join(', ') 67 147 } 68 148 69 149 /** 70 - * Rewrite absolute paths in HTML content 150 + * Rewrite absolute and relative paths in HTML content 71 151 * Uses simple regex matching for safety (no full HTML parsing) 72 152 */ 73 - export function rewriteHtmlPaths(html: string, basePath: string): string { 74 - // Ensure base path ends with / 75 - const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'; 153 + export function rewriteHtmlPaths( 154 + html: string, 155 + basePath: string, 156 + documentPath: string 157 + ): string { 158 + // Ensure base path ends with / 159 + const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/' 76 160 77 - let rewritten = html; 161 + let rewritten = html 78 162 79 - // Rewrite each attribute type 80 - for (const attr of REWRITABLE_ATTRIBUTES) { 81 - if (attr === 'srcset') { 82 - // Special handling for srcset 83 - const srcsetRegex = new RegExp( 84 - `\\b${attr}\\s*=\\s*"([^"]*)"`, 85 - 'gi' 86 - ); 87 - rewritten = rewritten.replace(srcsetRegex, (match, value) => { 88 - const rewrittenValue = rewriteSrcset(value, normalizedBase); 89 - return `${attr}="${rewrittenValue}"`; 90 - }); 91 - } else { 92 - // Regular attributes with quoted values 93 - const doubleQuoteRegex = new RegExp( 94 - `\\b${attr}\\s*=\\s*"([^"]*)"`, 95 - 'gi' 96 - ); 97 - const singleQuoteRegex = new RegExp( 98 - `\\b${attr}\\s*=\\s*'([^']*)'`, 99 - 'gi' 100 - ); 163 + // Rewrite each attribute type 164 + // Use more specific patterns to prevent ReDoS attacks 165 + for (const attr of REWRITABLE_ATTRIBUTES) { 166 + if (attr === 'srcset') { 167 + // Special handling for srcset - use possessive quantifiers via atomic grouping simulation 168 + // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS 169 + const srcsetRegex = new RegExp( 170 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 171 + 'gi' 172 + ) 173 + rewritten = rewritten.replace(srcsetRegex, (match, value) => { 174 + const rewrittenValue = rewriteSrcset( 175 + value, 176 + normalizedBase, 177 + documentPath 178 + ) 179 + return `${attr}="${rewrittenValue}"` 180 + }) 181 + } else { 182 + // Regular attributes with quoted values 183 + // Limit whitespace to prevent catastrophic backtracking 184 + const doubleQuoteRegex = new RegExp( 185 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, 186 + 'gi' 187 + ) 188 + const singleQuoteRegex = new RegExp( 189 + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, 190 + 'gi' 191 + ) 101 192 102 - rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 103 - const rewrittenValue = rewritePath(value, normalizedBase); 104 - return `${attr}="${rewrittenValue}"`; 105 - }); 193 + rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { 194 + const rewrittenValue = rewritePath( 195 + value, 196 + normalizedBase, 197 + documentPath 198 + ) 199 + return `${attr}="${rewrittenValue}"` 200 + }) 106 201 107 - rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 108 - const rewrittenValue = rewritePath(value, normalizedBase); 109 - return `${attr}='${rewrittenValue}'`; 110 - }); 111 - } 112 - } 202 + rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { 203 + const rewrittenValue = rewritePath( 204 + value, 205 + normalizedBase, 206 + documentPath 207 + ) 208 + return `${attr}='${rewrittenValue}'` 209 + }) 210 + } 211 + } 113 212 114 - return rewritten; 213 + return rewritten 115 214 } 116 215 117 216 /** 118 217 * Check if content is HTML based on content or filename 119 218 */ 120 - export function isHtmlContent( 121 - filepath: string, 122 - contentType?: string 123 - ): boolean { 124 - if (contentType && contentType.includes('text/html')) { 125 - return true; 126 - } 219 + export function isHtmlContent(filepath: string, contentType?: string): boolean { 220 + if (contentType && contentType.includes('text/html')) { 221 + return true 222 + } 127 223 128 - const ext = filepath.toLowerCase().split('.').pop(); 129 - return ext === 'html' || ext === 'htm'; 224 + const ext = filepath.toLowerCase().split('.').pop() 225 + return ext === 'html' || ext === 'htm' 130 226 }
+326
hosting-service/src/lib/observability.ts
··· 1 + // DIY Observability for Hosting Service 2 + import type { Context } from 'hono' 3 + 4 + // Types 5 + export interface LogEntry { 6 + id: string 7 + timestamp: Date 8 + level: 'info' | 'warn' | 'error' | 'debug' 9 + message: string 10 + service: string 11 + context?: Record<string, any> 12 + traceId?: string 13 + eventType?: string 14 + } 15 + 16 + export interface ErrorEntry { 17 + id: string 18 + timestamp: Date 19 + message: string 20 + stack?: string 21 + service: string 22 + context?: Record<string, any> 23 + count: number 24 + lastSeen: Date 25 + } 26 + 27 + export interface MetricEntry { 28 + timestamp: Date 29 + path: string 30 + method: string 31 + statusCode: number 32 + duration: number 33 + service: string 34 + } 35 + 36 + // In-memory storage with rotation 37 + const MAX_LOGS = 5000 38 + const MAX_ERRORS = 500 39 + const MAX_METRICS = 10000 40 + 41 + const logs: LogEntry[] = [] 42 + const errors: Map<string, ErrorEntry> = new Map() 43 + const metrics: MetricEntry[] = [] 44 + 45 + // Helper to generate unique IDs 46 + let logCounter = 0 47 + let errorCounter = 0 48 + 49 + function generateId(prefix: string, counter: number): string { 50 + return `${prefix}-${Date.now()}-${counter}` 51 + } 52 + 53 + // Helper to extract event type from message 54 + function extractEventType(message: string): string | undefined { 55 + const match = message.match(/^\[([^\]]+)\]/) 56 + return match ? match[1] : undefined 57 + } 58 + 59 + // Log collector 60 + export const logCollector = { 61 + log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) { 62 + const entry: LogEntry = { 63 + id: generateId('log', logCounter++), 64 + timestamp: new Date(), 65 + level, 66 + message, 67 + service, 68 + context, 69 + traceId, 70 + eventType: extractEventType(message) 71 + } 72 + 73 + logs.unshift(entry) 74 + 75 + // Rotate if needed 76 + if (logs.length > MAX_LOGS) { 77 + logs.splice(MAX_LOGS) 78 + } 79 + 80 + // Also log to console for compatibility 81 + const contextStr = context ? ` ${JSON.stringify(context)}` : '' 82 + const traceStr = traceId ? ` [trace:${traceId}]` : '' 83 + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) 84 + }, 85 + 86 + info(message: string, service: string, context?: Record<string, any>, traceId?: string) { 87 + this.log('info', message, service, context, traceId) 88 + }, 89 + 90 + warn(message: string, service: string, context?: Record<string, any>, traceId?: string) { 91 + this.log('warn', message, service, context, traceId) 92 + }, 93 + 94 + error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) { 95 + const ctx = { ...context } 96 + if (error instanceof Error) { 97 + ctx.error = error.message 98 + ctx.stack = error.stack 99 + } else if (error) { 100 + ctx.error = String(error) 101 + } 102 + this.log('error', message, service, ctx, traceId) 103 + 104 + // Also track in errors 105 + errorTracker.track(message, service, error, context) 106 + }, 107 + 108 + debug(message: string, service: string, context?: Record<string, any>, traceId?: string) { 109 + if (process.env.NODE_ENV !== 'production') { 110 + this.log('debug', message, service, context, traceId) 111 + } 112 + }, 113 + 114 + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { 115 + let filtered = [...logs] 116 + 117 + if (filter?.level) { 118 + filtered = filtered.filter(log => log.level === filter.level) 119 + } 120 + 121 + if (filter?.service) { 122 + filtered = filtered.filter(log => log.service === filter.service) 123 + } 124 + 125 + if (filter?.eventType) { 126 + filtered = filtered.filter(log => log.eventType === filter.eventType) 127 + } 128 + 129 + if (filter?.search) { 130 + const search = filter.search.toLowerCase() 131 + filtered = filtered.filter(log => 132 + log.message.toLowerCase().includes(search) || 133 + JSON.stringify(log.context).toLowerCase().includes(search) 134 + ) 135 + } 136 + 137 + const limit = filter?.limit || 100 138 + return filtered.slice(0, limit) 139 + }, 140 + 141 + clear() { 142 + logs.length = 0 143 + } 144 + } 145 + 146 + // Error tracker with deduplication 147 + export const errorTracker = { 148 + track(message: string, service: string, error?: any, context?: Record<string, any>) { 149 + const key = `${service}:${message}` 150 + 151 + const existing = errors.get(key) 152 + if (existing) { 153 + existing.count++ 154 + existing.lastSeen = new Date() 155 + if (context) { 156 + existing.context = { ...existing.context, ...context } 157 + } 158 + } else { 159 + const entry: ErrorEntry = { 160 + id: generateId('error', errorCounter++), 161 + timestamp: new Date(), 162 + message, 163 + service, 164 + context, 165 + count: 1, 166 + lastSeen: new Date() 167 + } 168 + 169 + if (error instanceof Error) { 170 + entry.stack = error.stack 171 + } 172 + 173 + errors.set(key, entry) 174 + 175 + // Rotate if needed 176 + if (errors.size > MAX_ERRORS) { 177 + const oldest = Array.from(errors.keys())[0] 178 + if (oldest !== undefined) { 179 + errors.delete(oldest) 180 + } 181 + } 182 + } 183 + }, 184 + 185 + getErrors(filter?: { service?: string; limit?: number }) { 186 + let filtered = Array.from(errors.values()) 187 + 188 + if (filter?.service) { 189 + filtered = filtered.filter(err => err.service === filter.service) 190 + } 191 + 192 + // Sort by last seen (most recent first) 193 + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) 194 + 195 + const limit = filter?.limit || 100 196 + return filtered.slice(0, limit) 197 + }, 198 + 199 + clear() { 200 + errors.clear() 201 + } 202 + } 203 + 204 + // Metrics collector 205 + export const metricsCollector = { 206 + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { 207 + const entry: MetricEntry = { 208 + timestamp: new Date(), 209 + path, 210 + method, 211 + statusCode, 212 + duration, 213 + service 214 + } 215 + 216 + metrics.unshift(entry) 217 + 218 + // Rotate if needed 219 + if (metrics.length > MAX_METRICS) { 220 + metrics.splice(MAX_METRICS) 221 + } 222 + }, 223 + 224 + getMetrics(filter?: { service?: string; timeWindow?: number }) { 225 + let filtered = [...metrics] 226 + 227 + if (filter?.service) { 228 + filtered = filtered.filter(m => m.service === filter.service) 229 + } 230 + 231 + if (filter?.timeWindow) { 232 + const cutoff = Date.now() - filter.timeWindow 233 + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) 234 + } 235 + 236 + return filtered 237 + }, 238 + 239 + getStats(service?: string, timeWindow: number = 3600000) { 240 + const filtered = this.getMetrics({ service, timeWindow }) 241 + 242 + if (filtered.length === 0) { 243 + return { 244 + totalRequests: 0, 245 + avgDuration: 0, 246 + p50Duration: 0, 247 + p95Duration: 0, 248 + p99Duration: 0, 249 + errorRate: 0, 250 + requestsPerMinute: 0 251 + } 252 + } 253 + 254 + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) 255 + const totalDuration = durations.reduce((sum, d) => sum + d, 0) 256 + const errors = filtered.filter(m => m.statusCode >= 400).length 257 + 258 + const p50 = durations[Math.floor(durations.length * 0.5)] 259 + const p95 = durations[Math.floor(durations.length * 0.95)] 260 + const p99 = durations[Math.floor(durations.length * 0.99)] 261 + 262 + const timeWindowMinutes = timeWindow / 60000 263 + 264 + return { 265 + totalRequests: filtered.length, 266 + avgDuration: Math.round(totalDuration / filtered.length), 267 + p50Duration: Math.round(p50 ?? 0), 268 + p95Duration: Math.round(p95 ?? 0), 269 + p99Duration: Math.round(p99 ?? 0), 270 + errorRate: (errors / filtered.length) * 100, 271 + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 272 + } 273 + }, 274 + 275 + clear() { 276 + metrics.length = 0 277 + } 278 + } 279 + 280 + // Hono middleware for request timing 281 + export function observabilityMiddleware(service: string) { 282 + return async (c: Context, next: () => Promise<void>) => { 283 + const startTime = Date.now() 284 + 285 + await next() 286 + 287 + const duration = Date.now() - startTime 288 + const { pathname } = new URL(c.req.url) 289 + 290 + metricsCollector.recordRequest( 291 + pathname, 292 + c.req.method, 293 + c.res.status, 294 + duration, 295 + service 296 + ) 297 + } 298 + } 299 + 300 + // Hono error handler 301 + export function observabilityErrorHandler(service: string) { 302 + return (err: Error, c: Context) => { 303 + const { pathname } = new URL(c.req.url) 304 + 305 + logCollector.error( 306 + `Request failed: ${c.req.method} ${pathname}`, 307 + service, 308 + err, 309 + { statusCode: c.res.status || 500 } 310 + ) 311 + 312 + return c.text('Internal Server Error', 500) 313 + } 314 + } 315 + 316 + // Export singleton logger for easy access 317 + export const logger = { 318 + info: (message: string, context?: Record<string, any>) => 319 + logCollector.info(message, 'hosting-service', context), 320 + warn: (message: string, context?: Record<string, any>) => 321 + logCollector.warn(message, 'hosting-service', context), 322 + error: (message: string, error?: any, context?: Record<string, any>) => 323 + logCollector.error(message, 'hosting-service', error, context), 324 + debug: (message: string, context?: Record<string, any>) => 325 + logCollector.debug(message, 'hosting-service', context) 326 + }
+10 -4
hosting-service/src/lib/safe-fetch.ts
··· 21 21 '169.254.169.254', 22 22 ]; 23 23 24 - const FETCH_TIMEOUT = 5000; // 5 seconds 24 + const FETCH_TIMEOUT = 120000; // 120 seconds 25 + const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 25 26 const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27 + const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28 + const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB 29 + const MAX_REDIRECTS = 10; 26 30 27 31 function isBlockedHost(hostname: string): boolean { 28 32 const lowerHost = hostname.toLowerCase(); ··· 71 75 const response = await fetch(url, { 72 76 ...options, 73 77 signal: controller.signal, 78 + redirect: 'follow', 74 79 }); 75 80 76 81 const contentLength = response.headers.get('content-length'); ··· 93 98 url: string, 94 99 options?: RequestInit & { maxSize?: number; timeout?: number } 95 100 ): Promise<T> { 96 - const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON 101 + const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE; 97 102 const response = await safeFetch(url, { ...options, maxSize: maxJsonSize }); 98 103 99 104 if (!response.ok) { ··· 139 144 url: string, 140 145 options?: RequestInit & { maxSize?: number; timeout?: number } 141 146 ): Promise<Uint8Array> { 142 - const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE; 143 - const response = await safeFetch(url, { ...options, maxSize: maxBlobSize }); 147 + const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE; 148 + const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB; 149 + const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs }); 144 150 145 151 if (!response.ok) { 146 152 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { sanitizePath, extractBlobCid } from './utils' 3 + import { CID } from 'multiformats' 4 + 5 + describe('sanitizePath', () => { 6 + test('allows normal file paths', () => { 7 + expect(sanitizePath('index.html')).toBe('index.html') 8 + expect(sanitizePath('css/styles.css')).toBe('css/styles.css') 9 + expect(sanitizePath('images/logo.png')).toBe('images/logo.png') 10 + expect(sanitizePath('js/app.js')).toBe('js/app.js') 11 + }) 12 + 13 + test('allows deeply nested paths', () => { 14 + expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico') 15 + expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt') 16 + }) 17 + 18 + test('removes leading slashes', () => { 19 + expect(sanitizePath('/index.html')).toBe('index.html') 20 + expect(sanitizePath('//index.html')).toBe('index.html') 21 + expect(sanitizePath('///index.html')).toBe('index.html') 22 + expect(sanitizePath('/css/styles.css')).toBe('css/styles.css') 23 + }) 24 + 25 + test('blocks parent directory traversal', () => { 26 + expect(sanitizePath('../etc/passwd')).toBe('etc/passwd') 27 + expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd') 28 + expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd') 29 + expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd') 30 + }) 31 + 32 + test('blocks directory traversal in middle of path', () => { 33 + expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd') 34 + // Note: sanitizePath only filters out ".." segments, doesn't resolve paths 35 + expect(sanitizePath('a/b/../c')).toBe('a/b/c') 36 + expect(sanitizePath('a/../b/../c')).toBe('a/b/c') 37 + }) 38 + 39 + test('removes current directory references', () => { 40 + expect(sanitizePath('./index.html')).toBe('index.html') 41 + expect(sanitizePath('././index.html')).toBe('index.html') 42 + expect(sanitizePath('css/./styles.css')).toBe('css/styles.css') 43 + expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css') 44 + }) 45 + 46 + test('removes empty path segments', () => { 47 + expect(sanitizePath('css//styles.css')).toBe('css/styles.css') 48 + expect(sanitizePath('css///styles.css')).toBe('css/styles.css') 49 + expect(sanitizePath('a//b//c')).toBe('a/b/c') 50 + }) 51 + 52 + test('blocks null bytes', () => { 53 + // Null bytes cause the entire segment to be filtered out 54 + expect(sanitizePath('index.html\0.txt')).toBe('') 55 + expect(sanitizePath('test\0')).toBe('') 56 + // Null byte in middle segment 57 + expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css') 58 + }) 59 + 60 + test('handles mixed attacks', () => { 61 + expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd') 62 + expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd') 63 + expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd') 64 + }) 65 + 66 + test('handles edge cases', () => { 67 + expect(sanitizePath('')).toBe('') 68 + expect(sanitizePath('/')).toBe('') 69 + expect(sanitizePath('//')).toBe('') 70 + expect(sanitizePath('.')).toBe('') 71 + expect(sanitizePath('..')).toBe('') 72 + expect(sanitizePath('../..')).toBe('') 73 + }) 74 + 75 + test('preserves valid special characters in filenames', () => { 76 + expect(sanitizePath('file-name.html')).toBe('file-name.html') 77 + expect(sanitizePath('file_name.html')).toBe('file_name.html') 78 + expect(sanitizePath('file.name.html')).toBe('file.name.html') 79 + expect(sanitizePath('file (1).html')).toBe('file (1).html') 80 + expect(sanitizePath('file@2x.png')).toBe('file@2x.png') 81 + }) 82 + 83 + test('handles Unicode characters', () => { 84 + expect(sanitizePath('ๆ–‡ไปถ.html')).toBe('ๆ–‡ไปถ.html') 85 + expect(sanitizePath('ั„ะฐะนะป.html')).toBe('ั„ะฐะนะป.html') 86 + expect(sanitizePath('ใƒ•ใ‚กใ‚คใƒซ.html')).toBe('ใƒ•ใ‚กใ‚คใƒซ.html') 87 + }) 88 + }) 89 + 90 + describe('extractBlobCid', () => { 91 + const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 92 + 93 + test('extracts CID from IPLD link', () => { 94 + const blobRef = { $link: TEST_CID } 95 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 96 + }) 97 + 98 + test('extracts CID from typed BlobRef with CID object', () => { 99 + const cid = CID.parse(TEST_CID) 100 + const blobRef = { ref: cid } 101 + const result = extractBlobCid(blobRef) 102 + expect(result).toBe(TEST_CID) 103 + }) 104 + 105 + test('extracts CID from typed BlobRef with IPLD link', () => { 106 + const blobRef = { 107 + ref: { $link: TEST_CID } 108 + } 109 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 110 + }) 111 + 112 + test('extracts CID from untyped BlobRef', () => { 113 + const blobRef = { cid: TEST_CID } 114 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 115 + }) 116 + 117 + test('returns null for invalid blob ref', () => { 118 + expect(extractBlobCid(null)).toBe(null) 119 + expect(extractBlobCid(undefined)).toBe(null) 120 + expect(extractBlobCid({})).toBe(null) 121 + expect(extractBlobCid('not-an-object')).toBe(null) 122 + expect(extractBlobCid(123)).toBe(null) 123 + }) 124 + 125 + test('returns null for malformed objects', () => { 126 + expect(extractBlobCid({ wrongKey: 'value' })).toBe(null) 127 + expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null) 128 + expect(extractBlobCid({ ref: {} })).toBe(null) 129 + }) 130 + 131 + test('handles nested structures from AT Proto API', () => { 132 + // Real structure from AT Proto 133 + const blobRef = { 134 + $type: 'blob', 135 + ref: CID.parse(TEST_CID), 136 + mimeType: 'text/html', 137 + size: 1234 138 + } 139 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 140 + }) 141 + 142 + test('handles BlobRef with additional properties', () => { 143 + const blobRef = { 144 + ref: { $link: TEST_CID }, 145 + mimeType: 'image/png', 146 + size: 5678, 147 + someOtherField: 'value' 148 + } 149 + expect(extractBlobCid(blobRef)).toBe(TEST_CID) 150 + }) 151 + 152 + test('prioritizes checking IPLD link first', () => { 153 + // Direct $link takes precedence 154 + const directLink = { $link: TEST_CID } 155 + expect(extractBlobCid(directLink)).toBe(TEST_CID) 156 + }) 157 + 158 + test('handles CID v0 format', () => { 159 + const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx' 160 + const blobRef = { $link: cidV0 } 161 + expect(extractBlobCid(blobRef)).toBe(cidV0) 162 + }) 163 + 164 + test('handles CID v1 format', () => { 165 + const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' 166 + const blobRef = { $link: cidV1 } 167 + expect(extractBlobCid(blobRef)).toBe(cidV1) 168 + }) 169 + })
+256 -38
hosting-service/src/lib/utils.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 - import type { WispFsRecord, Directory, Entry, File } from './types'; 3 - import { existsSync, mkdirSync } from 'fs'; 4 - import { writeFile } from 'fs/promises'; 2 + import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs'; 3 + import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 4 + import { writeFile, readFile, rename } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; 6 - import { CID } from 'multiformats/cid'; 6 + import { CID } from 'multiformats'; 7 + 8 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 9 + const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL 7 10 8 - const CACHE_DIR = './cache/sites'; 11 + interface CacheMetadata { 12 + recordCid: string; 13 + cachedAt: number; 14 + did: string; 15 + rkey: string; 16 + } 9 17 10 - // Type guards for different blob reference formats 18 + /** 19 + * Determines if a MIME type should benefit from gzip compression. 20 + * Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG). 21 + * Returns false for already-compressed formats (images, video, audio, PDFs). 22 + * 23 + */ 24 + export function shouldCompressMimeType(mimeType: string | undefined): boolean { 25 + if (!mimeType) return false; 26 + 27 + const mime = mimeType.toLowerCase(); 28 + 29 + // Text-based web assets that benefit from compression 30 + const compressibleTypes = [ 31 + 'text/html', 32 + 'text/css', 33 + 'text/javascript', 34 + 'application/javascript', 35 + 'application/x-javascript', 36 + 'text/xml', 37 + 'application/xml', 38 + 'application/json', 39 + 'text/plain', 40 + 'image/svg+xml', 41 + ]; 42 + 43 + if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) { 44 + return true; 45 + } 46 + 47 + // Already-compressed formats that should NOT be double-compressed 48 + const alreadyCompressedPrefixes = [ 49 + 'video/', 50 + 'audio/', 51 + 'image/', 52 + 'application/pdf', 53 + 'application/zip', 54 + 'application/gzip', 55 + ]; 56 + 57 + if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) { 58 + return false; 59 + } 60 + 61 + // Default to not compressing for unknown types 62 + return false; 63 + } 64 + 11 65 interface IpldLink { 12 66 $link: string; 13 67 } ··· 54 108 let doc; 55 109 56 110 if (did.startsWith('did:plc:')) { 57 - // Resolve did:plc from plc.directory 58 111 doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`); 59 112 } else if (did.startsWith('did:web:')) { 60 - // Resolve did:web from the domain 61 113 const didUrl = didWebToHttps(did); 62 114 doc = await safeFetchJson(didUrl); 63 115 } else { ··· 76 128 } 77 129 78 130 function didWebToHttps(did: string): string { 79 - // did:web:example.com -> https://example.com/.well-known/did.json 80 - // did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json 81 - 82 131 const didParts = did.split(':'); 83 132 if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') { 84 133 throw new Error('Invalid did:web format'); ··· 88 137 const pathParts = didParts.slice(3); 89 138 90 139 if (pathParts.length === 0) { 91 - // No path, use .well-known 92 140 return `https://${domain}/.well-known/did.json`; 93 141 } else { 94 - // Has path 95 142 const path = pathParts.join('/'); 96 143 return `https://${domain}/${path}/did.json`; 97 144 } 98 145 } 99 146 100 - export async function fetchSiteRecord(did: string, rkey: string): Promise<WispFsRecord | null> { 147 + export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> { 101 148 try { 102 149 const pdsEndpoint = await getPdsForDid(did); 103 150 if (!pdsEndpoint) return null; 104 151 105 152 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`; 106 153 const data = await safeFetchJson(url); 107 - return data.value as WispFsRecord; 154 + 155 + return { 156 + record: data.value as WispFsRecord, 157 + cid: data.cid || '' 158 + }; 108 159 } catch (err) { 109 160 console.error('Failed to fetch site record', did, rkey, err); 110 161 return null; ··· 112 163 } 113 164 114 165 export function extractBlobCid(blobRef: unknown): string | null { 115 - // Check if it's a direct IPLD link 116 166 if (isIpldLink(blobRef)) { 117 167 return blobRef.$link; 118 168 } 119 169 120 - // Check if it's a typed blob ref with a ref property 121 170 if (isTypedBlobRef(blobRef)) { 122 171 const ref = blobRef.ref; 123 172 124 - // Check if ref is a CID object 125 - if (CID.isCID(ref)) { 126 - return ref.toString(); 173 + const cid = CID.asCID(ref); 174 + if (cid) { 175 + return cid.toString(); 127 176 } 128 177 129 - // Check if ref is an IPLD link object 130 178 if (isIpldLink(ref)) { 131 179 return ref.$link; 132 180 } 133 181 } 134 182 135 - // Check if it's an untyped blob ref with a cid string 136 183 if (isUntypedBlobRef(blobRef)) { 137 184 return blobRef.cid; 138 185 } ··· 140 187 return null; 141 188 } 142 189 143 - export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string): Promise<void> { 190 + export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> { 144 191 console.log('Caching site', did, rkey); 145 192 146 - // Validate record structure 147 193 if (!record.root) { 148 194 console.error('Record missing root directory:', JSON.stringify(record, null, 2)); 149 195 throw new Error('Invalid record structure: missing root directory'); ··· 154 200 throw new Error('Invalid record structure: root missing entries array'); 155 201 } 156 202 157 - await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, ''); 203 + // Use a temporary directory with timestamp to avoid collisions 204 + const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 205 + const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`; 206 + const finalDir = `${CACHE_DIR}/${did}/${rkey}`; 207 + 208 + try { 209 + // Download to temporary directory 210 + await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix); 211 + await saveCacheMetadata(did, rkey, recordCid, tempSuffix); 212 + 213 + // Atomically replace old cache with new cache 214 + // On POSIX systems (Linux/macOS), rename is atomic 215 + if (existsSync(finalDir)) { 216 + // Rename old directory to backup 217 + const backupDir = `${finalDir}.old-${Date.now()}`; 218 + await rename(finalDir, backupDir); 219 + 220 + try { 221 + // Rename new directory to final location 222 + await rename(tempDir, finalDir); 223 + 224 + // Clean up old backup 225 + rmSync(backupDir, { recursive: true, force: true }); 226 + } catch (err) { 227 + // If rename failed, restore backup 228 + if (existsSync(backupDir) && !existsSync(finalDir)) { 229 + await rename(backupDir, finalDir); 230 + } 231 + throw err; 232 + } 233 + } else { 234 + // No existing cache, just rename temp to final 235 + await rename(tempDir, finalDir); 236 + } 237 + 238 + console.log('Successfully cached site atomically', did, rkey); 239 + } catch (err) { 240 + // Clean up temp directory on failure 241 + if (existsSync(tempDir)) { 242 + rmSync(tempDir, { recursive: true, force: true }); 243 + } 244 + throw err; 245 + } 158 246 } 159 247 160 248 async function cacheFiles( ··· 162 250 site: string, 163 251 entries: Entry[], 164 252 pdsEndpoint: string, 165 - pathPrefix: string 253 + pathPrefix: string, 254 + dirSuffix: string = '' 166 255 ): Promise<void> { 167 - for (const entry of entries) { 168 - const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 169 - const node = entry.node; 256 + // Collect all file blob download tasks first 257 + const downloadTasks: Array<() => Promise<void>> = []; 258 + 259 + function collectFileTasks( 260 + entries: Entry[], 261 + currentPathPrefix: string 262 + ) { 263 + for (const entry of entries) { 264 + const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name; 265 + const node = entry.node; 170 266 171 - if ('type' in node && node.type === 'directory' && 'entries' in node) { 172 - await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath); 173 - } else if ('type' in node && node.type === 'file' && 'blob' in node) { 174 - await cacheFileBlob(did, site, currentPath, node.blob, pdsEndpoint); 267 + if ('type' in node && node.type === 'directory' && 'entries' in node) { 268 + collectFileTasks(node.entries, currentPath); 269 + } else if ('type' in node && node.type === 'file' && 'blob' in node) { 270 + const fileNode = node as File; 271 + downloadTasks.push(() => cacheFileBlob( 272 + did, 273 + site, 274 + currentPath, 275 + fileNode.blob, 276 + pdsEndpoint, 277 + fileNode.encoding, 278 + fileNode.mimeType, 279 + fileNode.base64, 280 + dirSuffix 281 + )); 282 + } 175 283 } 176 284 } 285 + 286 + collectFileTasks(entries, pathPrefix); 287 + 288 + // Execute downloads concurrently with a limit of 3 at a time 289 + const concurrencyLimit = 3; 290 + for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) { 291 + const batch = downloadTasks.slice(i, i + concurrencyLimit); 292 + await Promise.all(batch.map(task => task())); 293 + } 177 294 } 178 295 179 296 async function cacheFileBlob( ··· 181 298 site: string, 182 299 filePath: string, 183 300 blobRef: any, 184 - pdsEndpoint: string 301 + pdsEndpoint: string, 302 + encoding?: 'gzip', 303 + mimeType?: string, 304 + base64?: boolean, 305 + dirSuffix: string = '' 185 306 ): Promise<void> { 186 307 const cid = extractBlobCid(blobRef); 187 308 if (!cid) { ··· 191 312 192 313 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 193 314 194 - // Allow up to 100MB per file blob 195 - const content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024 }); 315 + // Allow up to 100MB per file blob, with 2 minute timeout 316 + let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 }); 317 + 318 + console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`); 319 + 320 + // If content is base64-encoded, decode it back to raw binary (gzipped or not) 321 + if (base64) { 322 + const originalSize = content.length; 323 + // Decode base64 directly from raw bytes - no string conversion 324 + // The blob contains base64-encoded text as raw bytes, decode it in-place 325 + const textDecoder = new TextDecoder(); 326 + const base64String = textDecoder.decode(content); 327 + content = Buffer.from(base64String, 'base64'); 328 + console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`); 329 + 330 + // Check if it's actually gzipped by looking at magic bytes 331 + if (content.length >= 2) { 332 + const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 333 + console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`); 334 + } 335 + } 196 336 197 - const cacheFile = `${CACHE_DIR}/${did}/${site}/${filePath}`; 337 + const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`; 198 338 const fileDir = cacheFile.substring(0, cacheFile.lastIndexOf('/')); 199 339 200 340 if (fileDir && !existsSync(fileDir)) { 201 341 mkdirSync(fileDir, { recursive: true }); 202 342 } 203 343 344 + // Use the shared function to determine if this should remain compressed 345 + const shouldStayCompressed = shouldCompressMimeType(mimeType); 346 + 347 + // Decompress files that shouldn't be stored compressed 348 + if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 && 349 + content[0] === 0x1f && content[1] === 0x8b) { 350 + console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`); 351 + try { 352 + const { gunzipSync } = await import('zlib'); 353 + const decompressed = gunzipSync(content); 354 + console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 355 + content = decompressed; 356 + // Clear the encoding flag since we're storing decompressed 357 + encoding = undefined; 358 + } catch (error) { 359 + console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error); 360 + } 361 + } 362 + 204 363 await writeFile(cacheFile, content); 205 - console.log('Cached file', filePath, content.length, 'bytes'); 364 + 365 + // Store metadata only if file is still compressed 366 + if (encoding === 'gzip' && mimeType) { 367 + const metaFile = `${cacheFile}.meta`; 368 + await writeFile(metaFile, JSON.stringify({ encoding, mimeType })); 369 + console.log('Cached file', filePath, content.length, 'bytes (gzipped,', mimeType + ')'); 370 + } else { 371 + console.log('Cached file', filePath, content.length, 'bytes'); 372 + } 206 373 } 207 374 208 375 /** ··· 236 403 export function isCached(did: string, site: string): boolean { 237 404 return existsSync(`${CACHE_DIR}/${did}/${site}`); 238 405 } 406 + 407 + async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> { 408 + const metadata: CacheMetadata = { 409 + recordCid, 410 + cachedAt: Date.now(), 411 + did, 412 + rkey 413 + }; 414 + 415 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`; 416 + const metadataDir = metadataPath.substring(0, metadataPath.lastIndexOf('/')); 417 + 418 + if (!existsSync(metadataDir)) { 419 + mkdirSync(metadataDir, { recursive: true }); 420 + } 421 + 422 + await writeFile(metadataPath, JSON.stringify(metadata, null, 2)); 423 + } 424 + 425 + async function getCacheMetadata(did: string, rkey: string): Promise<CacheMetadata | null> { 426 + try { 427 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`; 428 + if (!existsSync(metadataPath)) return null; 429 + 430 + const content = await readFile(metadataPath, 'utf-8'); 431 + return JSON.parse(content) as CacheMetadata; 432 + } catch (err) { 433 + console.error('Failed to read cache metadata', err); 434 + return null; 435 + } 436 + } 437 + 438 + export async function isCacheValid(did: string, rkey: string, currentRecordCid?: string): Promise<boolean> { 439 + const metadata = await getCacheMetadata(did, rkey); 440 + if (!metadata) return false; 441 + 442 + // Check if cache has expired (14 days TTL) 443 + const cacheAge = Date.now() - metadata.cachedAt; 444 + if (cacheAge > CACHE_TTL) { 445 + console.log('[Cache] Cache expired for', did, rkey); 446 + return false; 447 + } 448 + 449 + // If current CID is provided, verify it matches 450 + if (currentRecordCid && metadata.recordCid !== currentRecordCid) { 451 + console.log('[Cache] CID mismatch for', did, rkey, 'cached:', metadata.recordCid, 'current:', currentRecordCid); 452 + return false; 453 + } 454 + 455 + return true; 456 + }
+230 -48
hosting-service/src/server.ts
··· 1 1 import { Hono } from 'hono'; 2 - import { serveStatic } from 'hono/bun'; 3 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4 - import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 3 + import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 5 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6 - import { existsSync } from 'fs'; 7 - 8 - const app = new Hono(); 5 + import { existsSync, readFileSync } from 'fs'; 6 + import { lookup } from 'mime-types'; 7 + import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 9 8 10 9 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 11 10 ··· 33 32 const cachedFile = getCachedFilePath(did, rkey, requestPath); 34 33 35 34 if (existsSync(cachedFile)) { 36 - const file = Bun.file(cachedFile); 37 - return new Response(file, { 35 + const content = readFileSync(cachedFile); 36 + const metaFile = `${cachedFile}.meta`; 37 + 38 + console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`); 39 + 40 + // Check if file has compression metadata 41 + if (existsSync(metaFile)) { 42 + const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 43 + console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`); 44 + 45 + // Check actual content for gzip magic bytes 46 + if (content.length >= 2) { 47 + const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 48 + console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`); 49 + } 50 + 51 + if (meta.encoding === 'gzip' && meta.mimeType) { 52 + // Use shared function to determine if this should be served compressed 53 + const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 54 + 55 + if (!shouldServeCompressed) { 56 + // This shouldn't happen if caching is working correctly, but handle it gracefully 57 + console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`); 58 + const { gunzipSync } = await import('zlib'); 59 + const decompressed = gunzipSync(content); 60 + console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 61 + return new Response(decompressed, { 62 + headers: { 63 + 'Content-Type': meta.mimeType, 64 + }, 65 + }); 66 + } 67 + 68 + // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.) 69 + console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`); 70 + return new Response(content, { 71 + headers: { 72 + 'Content-Type': meta.mimeType, 73 + 'Content-Encoding': 'gzip', 74 + }, 75 + }); 76 + } 77 + } 78 + 79 + // Serve non-compressed files normally 80 + const mimeType = lookup(cachedFile) || 'application/octet-stream'; 81 + return new Response(content, { 38 82 headers: { 39 - 'Content-Type': file.type || 'application/octet-stream', 83 + 'Content-Type': mimeType, 40 84 }, 41 85 }); 42 86 } ··· 45 89 if (!requestPath.includes('.')) { 46 90 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 47 91 if (existsSync(indexFile)) { 48 - const file = Bun.file(indexFile); 49 - return new Response(file, { 92 + const content = readFileSync(indexFile); 93 + const metaFile = `${indexFile}.meta`; 94 + 95 + // Check if file has compression metadata 96 + if (existsSync(metaFile)) { 97 + const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 98 + if (meta.encoding === 'gzip' && meta.mimeType) { 99 + return new Response(content, { 100 + headers: { 101 + 'Content-Type': meta.mimeType, 102 + 'Content-Encoding': 'gzip', 103 + }, 104 + }); 105 + } 106 + } 107 + 108 + return new Response(content, { 50 109 headers: { 51 110 'Content-Type': 'text/html; charset=utf-8', 52 111 }, ··· 73 132 const cachedFile = getCachedFilePath(did, rkey, requestPath); 74 133 75 134 if (existsSync(cachedFile)) { 76 - const file = Bun.file(cachedFile); 135 + const metaFile = `${cachedFile}.meta`; 136 + let mimeType = lookup(cachedFile) || 'application/octet-stream'; 137 + let isGzipped = false; 138 + 139 + // Check if file has compression metadata 140 + if (existsSync(metaFile)) { 141 + const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 142 + if (meta.encoding === 'gzip' && meta.mimeType) { 143 + mimeType = meta.mimeType; 144 + isGzipped = true; 145 + } 146 + } 77 147 78 148 // Check if this is HTML content that needs rewriting 79 - if (isHtmlContent(requestPath, file.type)) { 80 - const content = await file.text(); 81 - const rewritten = rewriteHtmlPaths(content, basePath); 82 - return new Response(rewritten, { 149 + // We decompress, rewrite paths, then recompress for efficient delivery 150 + if (isHtmlContent(requestPath, mimeType)) { 151 + let content: string; 152 + if (isGzipped) { 153 + const { gunzipSync } = await import('zlib'); 154 + const compressed = readFileSync(cachedFile); 155 + content = gunzipSync(compressed).toString('utf-8'); 156 + } else { 157 + content = readFileSync(cachedFile, 'utf-8'); 158 + } 159 + const rewritten = rewriteHtmlPaths(content, basePath, requestPath); 160 + 161 + // Recompress the HTML for efficient delivery 162 + const { gzipSync } = await import('zlib'); 163 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 164 + 165 + return new Response(recompressed, { 83 166 headers: { 84 167 'Content-Type': 'text/html; charset=utf-8', 168 + 'Content-Encoding': 'gzip', 85 169 }, 86 170 }); 87 171 } 88 172 89 - // Non-HTML files served with proper MIME type 90 - return new Response(file, { 173 + // Non-HTML files: serve gzipped content as-is with proper headers 174 + const content = readFileSync(cachedFile); 175 + if (isGzipped) { 176 + // Use shared function to determine if this should be served compressed 177 + const shouldServeCompressed = shouldCompressMimeType(mimeType); 178 + 179 + if (!shouldServeCompressed) { 180 + // This shouldn't happen if caching is working correctly, but handle it gracefully 181 + const { gunzipSync } = await import('zlib'); 182 + const decompressed = gunzipSync(content); 183 + return new Response(decompressed, { 184 + headers: { 185 + 'Content-Type': mimeType, 186 + }, 187 + }); 188 + } 189 + 190 + return new Response(content, { 191 + headers: { 192 + 'Content-Type': mimeType, 193 + 'Content-Encoding': 'gzip', 194 + }, 195 + }); 196 + } 197 + return new Response(content, { 91 198 headers: { 92 - 'Content-Type': file.type || 'application/octet-stream', 199 + 'Content-Type': mimeType, 93 200 }, 94 201 }); 95 202 } ··· 98 205 if (!requestPath.includes('.')) { 99 206 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 100 207 if (existsSync(indexFile)) { 101 - const file = Bun.file(indexFile); 102 - const content = await file.text(); 103 - const rewritten = rewriteHtmlPaths(content, basePath); 104 - return new Response(rewritten, { 208 + const metaFile = `${indexFile}.meta`; 209 + let isGzipped = false; 210 + 211 + if (existsSync(metaFile)) { 212 + const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 213 + if (meta.encoding === 'gzip') { 214 + isGzipped = true; 215 + } 216 + } 217 + 218 + // HTML needs path rewriting, decompress, rewrite, then recompress 219 + let content: string; 220 + if (isGzipped) { 221 + const { gunzipSync } = await import('zlib'); 222 + const compressed = readFileSync(indexFile); 223 + content = gunzipSync(compressed).toString('utf-8'); 224 + } else { 225 + content = readFileSync(indexFile, 'utf-8'); 226 + } 227 + const indexPath = `${requestPath}/index.html`; 228 + const rewritten = rewriteHtmlPaths(content, basePath, indexPath); 229 + 230 + // Recompress the HTML for efficient delivery 231 + const { gzipSync } = await import('zlib'); 232 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 233 + 234 + return new Response(recompressed, { 105 235 headers: { 106 236 'Content-Type': 'text/html; charset=utf-8', 237 + 'Content-Encoding': 'gzip', 107 238 }, 108 239 }); 109 240 } ··· 119 250 } 120 251 121 252 // Fetch and cache the site 122 - const record = await fetchSiteRecord(did, rkey); 123 - if (!record) { 124 - console.error('Site record not found', did, rkey); 253 + const siteData = await fetchSiteRecord(did, rkey); 254 + if (!siteData) { 255 + logger.error('Site record not found', null, { did, rkey }); 125 256 return false; 126 257 } 127 258 128 259 const pdsEndpoint = await getPdsForDid(did); 129 260 if (!pdsEndpoint) { 130 - console.error('PDS not found for DID', did); 261 + logger.error('PDS not found for DID', null, { did }); 131 262 return false; 132 263 } 133 264 134 265 try { 135 - await downloadAndCacheSite(did, rkey, record, pdsEndpoint); 266 + await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 267 + logger.info('Site cached successfully', { did, rkey }); 136 268 return true; 137 269 } catch (err) { 138 - console.error('Failed to cache site', did, rkey, err); 270 + logger.error('Failed to cache site', err, { did, rkey }); 139 271 return false; 140 272 } 141 273 } 142 274 143 - // Route 4: Direct file serving (no DB) - sites.wisp.place/:identifier/:site/* 144 - // This route is now handled in the catch-all route below 275 + const app = new Hono(); 276 + 277 + // Add observability middleware 278 + app.use('*', observabilityMiddleware('hosting-service')); 279 + 280 + // Error handler 281 + app.onError(observabilityErrorHandler('hosting-service')); 145 282 146 - // Route 3: DNS routing for custom domains - /hash.dns.wisp.place/* 283 + // Main site serving route 147 284 app.get('/*', async (c) => { 285 + const url = new URL(c.req.url); 148 286 const hostname = c.req.header('host') || ''; 149 - const rawPath = c.req.path.replace(/^\//, ''); 287 + const rawPath = url.pathname.replace(/^\//, ''); 150 288 const path = sanitizePath(rawPath); 151 289 152 - console.log('[Request]', { hostname, path }); 153 - 154 290 // Check if this is sites.wisp.place subdomain 155 291 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 156 - // Extract identifier and site from path: /did:plc:123abc/sitename/file.html 157 - const pathParts = rawPath.split('/'); 292 + // Sanitize the path FIRST to prevent path traversal 293 + const sanitizedFullPath = sanitizePath(rawPath); 294 + 295 + // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 296 + const pathParts = sanitizedFullPath.split('/'); 158 297 if (pathParts.length < 2) { 159 298 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 160 299 } 161 300 162 301 const identifier = pathParts[0]; 163 302 const site = pathParts[1]; 164 - const filePath = sanitizePath(pathParts.slice(2).join('/')); 303 + const filePath = pathParts.slice(2).join('/'); 165 304 166 - console.log('[Sites] Serving', { identifier, site, filePath }); 305 + // Additional validation: identifier must be a valid DID or handle format 306 + if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 307 + return c.text('Invalid identifier', 400); 308 + } 309 + 310 + // Validate site parameter exists 311 + if (!site) { 312 + return c.text('Site name required', 400); 313 + } 167 314 168 315 // Validate site name (rkey) 169 316 if (!isValidRkey(site)) { ··· 193 340 const hash = dnsMatch[1]; 194 341 const baseDomain = dnsMatch[2]; 195 342 196 - console.log('[DNS Hash] Looking up', { hash, baseDomain }); 343 + if (!hash) { 344 + return c.text('Invalid DNS hash', 400); 345 + } 197 346 198 347 if (baseDomain !== BASE_HOST) { 199 348 return c.text('Invalid base domain', 400); ··· 204 353 return c.text('Custom domain not found or not verified', 404); 205 354 } 206 355 207 - const rkey = customDomain.rkey || 'self'; 356 + if (!customDomain.rkey) { 357 + return c.text('Domain not mapped to a site', 404); 358 + } 359 + 360 + const rkey = customDomain.rkey; 208 361 if (!isValidRkey(rkey)) { 209 362 return c.text('Invalid site configuration', 500); 210 363 } ··· 219 372 220 373 // Route 2: Registered subdomains - /*.wisp.place/* 221 374 if (hostname.endsWith(`.${BASE_HOST}`)) { 222 - const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 223 - 224 - console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname }); 225 - 226 375 const domainInfo = await getWispDomain(hostname); 227 376 if (!domainInfo) { 228 377 return c.text('Subdomain not registered', 404); 229 378 } 230 379 231 - const rkey = domainInfo.rkey || 'self'; 380 + if (!domainInfo.rkey) { 381 + return c.text('Domain not mapped to a site', 404); 382 + } 383 + 384 + const rkey = domainInfo.rkey; 232 385 if (!isValidRkey(rkey)) { 233 386 return c.text('Invalid site configuration', 500); 234 387 } ··· 242 395 } 243 396 244 397 // Route 1: Custom domains - /* 245 - console.log('[Custom Domain] Looking up', { hostname }); 246 - 247 398 const customDomain = await getCustomDomain(hostname); 248 399 if (!customDomain) { 249 400 return c.text('Custom domain not found or not verified', 404); 250 401 } 251 402 252 - const rkey = customDomain.rkey || 'self'; 403 + if (!customDomain.rkey) { 404 + return c.text('Domain not mapped to a site', 404); 405 + } 406 + 407 + const rkey = customDomain.rkey; 253 408 if (!isValidRkey(rkey)) { 254 409 return c.text('Invalid site configuration', 500); 255 410 } ··· 260 415 } 261 416 262 417 return serveFromCache(customDomain.did, rkey, path); 418 + }); 419 + 420 + // Internal observability endpoints (for admin panel) 421 + app.get('/__internal__/observability/logs', (c) => { 422 + const query = c.req.query(); 423 + const filter: any = {}; 424 + if (query.level) filter.level = query.level; 425 + if (query.service) filter.service = query.service; 426 + if (query.search) filter.search = query.search; 427 + if (query.eventType) filter.eventType = query.eventType; 428 + if (query.limit) filter.limit = parseInt(query.limit as string); 429 + return c.json({ logs: logCollector.getLogs(filter) }); 430 + }); 431 + 432 + app.get('/__internal__/observability/errors', (c) => { 433 + const query = c.req.query(); 434 + const filter: any = {}; 435 + if (query.service) filter.service = query.service; 436 + if (query.limit) filter.limit = parseInt(query.limit as string); 437 + return c.json({ errors: errorTracker.getErrors(filter) }); 438 + }); 439 + 440 + app.get('/__internal__/observability/metrics', (c) => { 441 + const query = c.req.query(); 442 + const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 443 + const stats = metricsCollector.getStats('hosting-service', timeWindow); 444 + return c.json({ stats, timeWindow }); 263 445 }); 264 446 265 447 export default app;
+28
hosting-service/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + /* Base Options */ 4 + "esModuleInterop": true, 5 + "skipLibCheck": true, 6 + "target": "es2022", 7 + "allowJs": true, 8 + "resolveJsonModule": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "verbatimModuleSyntax": true, 12 + 13 + /* Strictness */ 14 + "strict": true, 15 + "noUncheckedIndexedAccess": true, 16 + "noImplicitOverride": true, 17 + "forceConsistentCasingInFileNames": true, 18 + 19 + /* Transpiling with TypeScript */ 20 + "module": "ESNext", 21 + "moduleResolution": "bundler", 22 + "outDir": "dist", 23 + "sourceMap": true, 24 + 25 + /* Code doesn't run in DOM */ 26 + "lib": ["es2022"], 27 + } 28 + }
+4 -1
lexicons/fs.json
··· 21 21 "required": ["type", "blob"], 22 22 "properties": { 23 23 "type": { "type": "string", "const": "file" }, 24 - "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" } 24 + "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" }, 25 + "encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" }, 26 + "mimeType": { "type": "string", "description": "Original MIME type before compression" }, 27 + "base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" } 25 28 } 26 29 }, 27 30 "directory": {
+11 -3
package.json
··· 2 2 "name": "elysia-static", 3 3 "version": "1.0.50", 4 4 "scripts": { 5 - "test": "echo \"Error: no test specified\" && exit 1", 5 + "test": "bun test", 6 6 "dev": "bun run --watch src/index.ts", 7 + "start": "bun run src/index.ts", 7 8 "build": "bun build --compile --target bun --outfile server src/index.ts" 8 9 }, 9 10 "dependencies": { ··· 14 15 "@elysiajs/cors": "^1.4.0", 15 16 "@elysiajs/eden": "^1.4.3", 16 17 "@elysiajs/openapi": "^1.4.11", 18 + "@elysiajs/opentelemetry": "^1.4.6", 17 19 "@elysiajs/static": "^1.4.2", 18 20 "@radix-ui/react-dialog": "^1.1.15", 19 21 "@radix-ui/react-label": "^2.1.7", ··· 28 30 "lucide-react": "^0.546.0", 29 31 "react": "^19.2.0", 30 32 "react-dom": "^19.2.0", 33 + "react-shiki": "^0.9.0", 31 34 "tailwind-merge": "^3.3.1", 32 35 "tailwindcss": "4", 33 36 "tw-animate-css": "^1.4.0", 34 - "typescript": "^5.9.3" 37 + "typescript": "^5.9.3", 38 + "zlib": "^1.0.5" 35 39 }, 36 40 "devDependencies": { 37 41 "@types/react": "^19.2.2", ··· 39 43 "bun-plugin-tailwind": "^0.1.2", 40 44 "bun-types": "latest" 41 45 }, 42 - "module": "src/index.js" 46 + "module": "src/index.js", 47 + "trustedDependencies": [ 48 + "core-js", 49 + "protobufjs" 50 + ] 43 51 }
+820
public/admin/admin.tsx
··· 1 + import { StrictMode, useState, useEffect } from 'react' 2 + import { createRoot } from 'react-dom/client' 3 + import './styles.css' 4 + 5 + // Types 6 + interface LogEntry { 7 + id: string 8 + timestamp: string 9 + level: 'info' | 'warn' | 'error' | 'debug' 10 + message: string 11 + service: string 12 + context?: Record<string, any> 13 + eventType?: string 14 + } 15 + 16 + interface ErrorEntry { 17 + id: string 18 + timestamp: string 19 + message: string 20 + stack?: string 21 + service: string 22 + count: number 23 + lastSeen: string 24 + } 25 + 26 + interface MetricsStats { 27 + totalRequests: number 28 + avgDuration: number 29 + p50Duration: number 30 + p95Duration: number 31 + p99Duration: number 32 + errorRate: number 33 + requestsPerMinute: number 34 + } 35 + 36 + // Helper function to format Unix timestamp from database 37 + function formatDbDate(timestamp: number | string): Date { 38 + const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp 39 + return new Date(num * 1000) // Convert seconds to milliseconds 40 + } 41 + 42 + // Login Component 43 + function Login({ onLogin }: { onLogin: () => void }) { 44 + const [username, setUsername] = useState('') 45 + const [password, setPassword] = useState('') 46 + const [error, setError] = useState('') 47 + const [loading, setLoading] = useState(false) 48 + 49 + const handleSubmit = async (e: React.FormEvent) => { 50 + e.preventDefault() 51 + setError('') 52 + setLoading(true) 53 + 54 + try { 55 + const res = await fetch('/api/admin/login', { 56 + method: 'POST', 57 + headers: { 'Content-Type': 'application/json' }, 58 + body: JSON.stringify({ username, password }), 59 + credentials: 'include' 60 + }) 61 + 62 + if (res.ok) { 63 + onLogin() 64 + } else { 65 + setError('Invalid credentials') 66 + } 67 + } catch (err) { 68 + setError('Failed to login') 69 + } finally { 70 + setLoading(false) 71 + } 72 + } 73 + 74 + return ( 75 + <div className="min-h-screen bg-gray-950 flex items-center justify-center p-4"> 76 + <div className="w-full max-w-md"> 77 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl"> 78 + <h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1> 79 + <form onSubmit={handleSubmit} className="space-y-4"> 80 + <div> 81 + <label className="block text-sm font-medium text-gray-300 mb-2"> 82 + Username 83 + </label> 84 + <input 85 + type="text" 86 + value={username} 87 + onChange={(e) => setUsername(e.target.value)} 88 + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 89 + required 90 + /> 91 + </div> 92 + <div> 93 + <label className="block text-sm font-medium text-gray-300 mb-2"> 94 + Password 95 + </label> 96 + <input 97 + type="password" 98 + value={password} 99 + onChange={(e) => setPassword(e.target.value)} 100 + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 101 + required 102 + /> 103 + </div> 104 + {error && ( 105 + <div className="text-red-400 text-sm">{error}</div> 106 + )} 107 + <button 108 + type="submit" 109 + disabled={loading} 110 + 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" 111 + > 112 + {loading ? 'Logging in...' : 'Login'} 113 + </button> 114 + </form> 115 + </div> 116 + </div> 117 + </div> 118 + ) 119 + } 120 + 121 + // Dashboard Component 122 + function Dashboard() { 123 + const [tab, setTab] = useState('overview') 124 + const [logs, setLogs] = useState<LogEntry[]>([]) 125 + const [errors, setErrors] = useState<ErrorEntry[]>([]) 126 + const [metrics, setMetrics] = useState<any>(null) 127 + const [database, setDatabase] = useState<any>(null) 128 + const [sites, setSites] = useState<any>(null) 129 + const [health, setHealth] = useState<any>(null) 130 + const [autoRefresh, setAutoRefresh] = useState(true) 131 + 132 + // Filters 133 + const [logLevel, setLogLevel] = useState('') 134 + const [logService, setLogService] = useState('') 135 + const [logSearch, setLogSearch] = useState('') 136 + const [logEventType, setLogEventType] = useState('') 137 + 138 + const fetchLogs = async () => { 139 + const params = new URLSearchParams() 140 + if (logLevel) params.append('level', logLevel) 141 + if (logService) params.append('service', logService) 142 + if (logSearch) params.append('search', logSearch) 143 + if (logEventType) params.append('eventType', logEventType) 144 + params.append('limit', '100') 145 + 146 + const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' }) 147 + if (res.ok) { 148 + const data = await res.json() 149 + setLogs(data.logs) 150 + } 151 + } 152 + 153 + const fetchErrors = async () => { 154 + const res = await fetch('/api/admin/errors', { credentials: 'include' }) 155 + if (res.ok) { 156 + const data = await res.json() 157 + setErrors(data.errors) 158 + } 159 + } 160 + 161 + const fetchMetrics = async () => { 162 + const res = await fetch('/api/admin/metrics', { credentials: 'include' }) 163 + if (res.ok) { 164 + const data = await res.json() 165 + setMetrics(data) 166 + } 167 + } 168 + 169 + const fetchDatabase = async () => { 170 + const res = await fetch('/api/admin/database', { credentials: 'include' }) 171 + if (res.ok) { 172 + const data = await res.json() 173 + setDatabase(data) 174 + } 175 + } 176 + 177 + const fetchSites = async () => { 178 + const res = await fetch('/api/admin/sites', { credentials: 'include' }) 179 + if (res.ok) { 180 + const data = await res.json() 181 + setSites(data) 182 + } 183 + } 184 + 185 + const fetchHealth = async () => { 186 + const res = await fetch('/api/admin/health', { credentials: 'include' }) 187 + if (res.ok) { 188 + const data = await res.json() 189 + setHealth(data) 190 + } 191 + } 192 + 193 + const logout = async () => { 194 + await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) 195 + window.location.reload() 196 + } 197 + 198 + useEffect(() => { 199 + fetchMetrics() 200 + fetchDatabase() 201 + fetchHealth() 202 + fetchLogs() 203 + fetchErrors() 204 + fetchSites() 205 + }, []) 206 + 207 + useEffect(() => { 208 + fetchLogs() 209 + }, [logLevel, logService, logSearch]) 210 + 211 + useEffect(() => { 212 + if (!autoRefresh) return 213 + 214 + const interval = setInterval(() => { 215 + if (tab === 'overview') { 216 + fetchMetrics() 217 + fetchHealth() 218 + } else if (tab === 'logs') { 219 + fetchLogs() 220 + } else if (tab === 'errors') { 221 + fetchErrors() 222 + } else if (tab === 'database') { 223 + fetchDatabase() 224 + } else if (tab === 'sites') { 225 + fetchSites() 226 + } 227 + }, 5000) 228 + 229 + return () => clearInterval(interval) 230 + }, [tab, autoRefresh, logLevel, logService, logSearch]) 231 + 232 + const formatDuration = (ms: number) => { 233 + if (ms < 1000) return `${ms}ms` 234 + return `${(ms / 1000).toFixed(2)}s` 235 + } 236 + 237 + const formatUptime = (seconds: number) => { 238 + const hours = Math.floor(seconds / 3600) 239 + const minutes = Math.floor((seconds % 3600) / 60) 240 + return `${hours}h ${minutes}m` 241 + } 242 + 243 + return ( 244 + <div className="min-h-screen bg-gray-950 text-white"> 245 + {/* Header */} 246 + <div className="bg-gray-900 border-b border-gray-800 px-6 py-4"> 247 + <div className="flex items-center justify-between"> 248 + <h1 className="text-2xl font-bold">Wisp.place Admin</h1> 249 + <div className="flex items-center gap-4"> 250 + <label className="flex items-center gap-2 text-sm text-gray-400"> 251 + <input 252 + type="checkbox" 253 + checked={autoRefresh} 254 + onChange={(e) => setAutoRefresh(e.target.checked)} 255 + className="rounded" 256 + /> 257 + Auto-refresh 258 + </label> 259 + <button 260 + onClick={logout} 261 + className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm" 262 + > 263 + Logout 264 + </button> 265 + </div> 266 + </div> 267 + </div> 268 + 269 + {/* Tabs */} 270 + <div className="bg-gray-900 border-b border-gray-800 px-6"> 271 + <div className="flex gap-1"> 272 + {['overview', 'logs', 'errors', 'database', 'sites'].map((t) => ( 273 + <button 274 + key={t} 275 + onClick={() => setTab(t)} 276 + className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${ 277 + tab === t 278 + ? 'text-white border-b-2 border-blue-500' 279 + : 'text-gray-400 hover:text-white' 280 + }`} 281 + > 282 + {t} 283 + </button> 284 + ))} 285 + </div> 286 + </div> 287 + 288 + {/* Content */} 289 + <div className="p-6"> 290 + {tab === 'overview' && ( 291 + <div className="space-y-6"> 292 + {/* Health */} 293 + {health && ( 294 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 295 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 296 + <div className="text-sm text-gray-400 mb-1">Uptime</div> 297 + <div className="text-2xl font-bold">{formatUptime(health.uptime)}</div> 298 + </div> 299 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 300 + <div className="text-sm text-gray-400 mb-1">Memory Used</div> 301 + <div className="text-2xl font-bold">{health.memory.heapUsed} MB</div> 302 + </div> 303 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 304 + <div className="text-sm text-gray-400 mb-1">RSS</div> 305 + <div className="text-2xl font-bold">{health.memory.rss} MB</div> 306 + </div> 307 + </div> 308 + )} 309 + 310 + {/* Metrics */} 311 + {metrics && ( 312 + <div> 313 + <h2 className="text-xl font-bold mb-4">Performance Metrics</h2> 314 + <div className="space-y-4"> 315 + {/* Overall */} 316 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 317 + <h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3> 318 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 319 + <div> 320 + <div className="text-sm text-gray-400">Total Requests</div> 321 + <div className="text-xl font-bold">{metrics.overall.totalRequests}</div> 322 + </div> 323 + <div> 324 + <div className="text-sm text-gray-400">Avg Duration</div> 325 + <div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div> 326 + </div> 327 + <div> 328 + <div className="text-sm text-gray-400">P95 Duration</div> 329 + <div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div> 330 + </div> 331 + <div> 332 + <div className="text-sm text-gray-400">Error Rate</div> 333 + <div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div> 334 + </div> 335 + </div> 336 + </div> 337 + 338 + {/* Main App */} 339 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 340 + <h3 className="text-lg font-semibold mb-3">Main App</h3> 341 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 342 + <div> 343 + <div className="text-sm text-gray-400">Requests</div> 344 + <div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div> 345 + </div> 346 + <div> 347 + <div className="text-sm text-gray-400">Avg</div> 348 + <div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div> 349 + </div> 350 + <div> 351 + <div className="text-sm text-gray-400">P95</div> 352 + <div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div> 353 + </div> 354 + <div> 355 + <div className="text-sm text-gray-400">Req/min</div> 356 + <div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div> 357 + </div> 358 + </div> 359 + </div> 360 + 361 + {/* Hosting Service */} 362 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 363 + <h3 className="text-lg font-semibold mb-3">Hosting Service</h3> 364 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 365 + <div> 366 + <div className="text-sm text-gray-400">Requests</div> 367 + <div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div> 368 + </div> 369 + <div> 370 + <div className="text-sm text-gray-400">Avg</div> 371 + <div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div> 372 + </div> 373 + <div> 374 + <div className="text-sm text-gray-400">P95</div> 375 + <div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div> 376 + </div> 377 + <div> 378 + <div className="text-sm text-gray-400">Req/min</div> 379 + <div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div> 380 + </div> 381 + </div> 382 + </div> 383 + </div> 384 + </div> 385 + )} 386 + </div> 387 + )} 388 + 389 + {tab === 'logs' && ( 390 + <div className="space-y-4"> 391 + <div className="flex gap-4"> 392 + <select 393 + value={logLevel} 394 + onChange={(e) => setLogLevel(e.target.value)} 395 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 396 + > 397 + <option value="">All Levels</option> 398 + <option value="info">Info</option> 399 + <option value="warn">Warn</option> 400 + <option value="error">Error</option> 401 + <option value="debug">Debug</option> 402 + </select> 403 + <select 404 + value={logService} 405 + onChange={(e) => setLogService(e.target.value)} 406 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 407 + > 408 + <option value="">All Services</option> 409 + <option value="main-app">Main App</option> 410 + <option value="hosting-service">Hosting Service</option> 411 + </select> 412 + <select 413 + value={logEventType} 414 + onChange={(e) => setLogEventType(e.target.value)} 415 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 416 + > 417 + <option value="">All Event Types</option> 418 + <option value="DNS Verifier">DNS Verifier</option> 419 + <option value="Auth">Auth</option> 420 + <option value="User">User</option> 421 + <option value="Domain">Domain</option> 422 + <option value="Site">Site</option> 423 + <option value="File Upload">File Upload</option> 424 + <option value="Sync">Sync</option> 425 + <option value="Maintenance">Maintenance</option> 426 + <option value="KeyRotation">Key Rotation</option> 427 + <option value="Cleanup">Cleanup</option> 428 + <option value="Cache">Cache</option> 429 + <option value="FirehoseWorker">Firehose Worker</option> 430 + </select> 431 + <input 432 + type="text" 433 + value={logSearch} 434 + onChange={(e) => setLogSearch(e.target.value)} 435 + placeholder="Search logs..." 436 + className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 437 + /> 438 + </div> 439 + 440 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 441 + <div className="max-h-[600px] overflow-y-auto"> 442 + <table className="w-full text-sm"> 443 + <thead className="bg-gray-800 sticky top-0"> 444 + <tr> 445 + <th className="px-4 py-2 text-left">Time</th> 446 + <th className="px-4 py-2 text-left">Level</th> 447 + <th className="px-4 py-2 text-left">Service</th> 448 + <th className="px-4 py-2 text-left">Event Type</th> 449 + <th className="px-4 py-2 text-left">Message</th> 450 + </tr> 451 + </thead> 452 + <tbody> 453 + {logs.map((log) => ( 454 + <tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800"> 455 + <td className="px-4 py-2 text-gray-400 whitespace-nowrap"> 456 + {new Date(log.timestamp).toLocaleTimeString()} 457 + </td> 458 + <td className="px-4 py-2"> 459 + <span 460 + className={`px-2 py-1 rounded text-xs font-medium ${ 461 + log.level === 'error' 462 + ? 'bg-red-900 text-red-200' 463 + : log.level === 'warn' 464 + ? 'bg-yellow-900 text-yellow-200' 465 + : log.level === 'info' 466 + ? 'bg-blue-900 text-blue-200' 467 + : 'bg-gray-700 text-gray-300' 468 + }`} 469 + > 470 + {log.level} 471 + </span> 472 + </td> 473 + <td className="px-4 py-2 text-gray-400">{log.service}</td> 474 + <td className="px-4 py-2"> 475 + {log.eventType && ( 476 + <span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium"> 477 + {log.eventType} 478 + </span> 479 + )} 480 + </td> 481 + <td className="px-4 py-2"> 482 + <div>{log.message}</div> 483 + {log.context && Object.keys(log.context).length > 0 && ( 484 + <div className="text-xs text-gray-500 mt-1"> 485 + {JSON.stringify(log.context)} 486 + </div> 487 + )} 488 + </td> 489 + </tr> 490 + ))} 491 + </tbody> 492 + </table> 493 + </div> 494 + </div> 495 + </div> 496 + )} 497 + 498 + {tab === 'errors' && ( 499 + <div className="space-y-4"> 500 + <h2 className="text-xl font-bold">Recent Errors</h2> 501 + <div className="space-y-3"> 502 + {errors.map((error) => ( 503 + <div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4"> 504 + <div className="flex items-start justify-between mb-2"> 505 + <div className="flex-1"> 506 + <div className="font-semibold text-red-400">{error.message}</div> 507 + <div className="text-sm text-gray-400 mt-1"> 508 + Service: {error.service} โ€ข Count: {error.count} โ€ข Last seen:{' '} 509 + {new Date(error.lastSeen).toLocaleString()} 510 + </div> 511 + </div> 512 + </div> 513 + {error.stack && ( 514 + <pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto"> 515 + {error.stack} 516 + </pre> 517 + )} 518 + </div> 519 + ))} 520 + {errors.length === 0 && ( 521 + <div className="text-center text-gray-500 py-8">No errors found</div> 522 + )} 523 + </div> 524 + </div> 525 + )} 526 + 527 + {tab === 'database' && database && ( 528 + <div className="space-y-6"> 529 + {/* Stats */} 530 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 531 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 532 + <div className="text-sm text-gray-400 mb-1">Total Sites</div> 533 + <div className="text-3xl font-bold">{database.stats.totalSites}</div> 534 + </div> 535 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 536 + <div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div> 537 + <div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div> 538 + </div> 539 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 540 + <div className="text-sm text-gray-400 mb-1">Custom Domains</div> 541 + <div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div> 542 + </div> 543 + </div> 544 + 545 + {/* Recent Sites */} 546 + <div> 547 + <h3 className="text-lg font-semibold mb-3">Recent Sites</h3> 548 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 549 + <table className="w-full text-sm"> 550 + <thead className="bg-gray-800"> 551 + <tr> 552 + <th className="px-4 py-2 text-left">Site Name</th> 553 + <th className="px-4 py-2 text-left">Subdomain</th> 554 + <th className="px-4 py-2 text-left">DID</th> 555 + <th className="px-4 py-2 text-left">RKey</th> 556 + <th className="px-4 py-2 text-left">Created</th> 557 + </tr> 558 + </thead> 559 + <tbody> 560 + {database.recentSites.map((site: any, i: number) => ( 561 + <tr key={i} className="border-t border-gray-800"> 562 + <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 563 + <td className="px-4 py-2"> 564 + {site.subdomain ? ( 565 + <a 566 + href={`https://${site.subdomain}`} 567 + target="_blank" 568 + rel="noopener noreferrer" 569 + className="text-blue-400 hover:underline" 570 + > 571 + {site.subdomain} 572 + </a> 573 + ) : ( 574 + <span className="text-gray-500">No domain</span> 575 + )} 576 + </td> 577 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 578 + {site.did.slice(0, 20)}... 579 + </td> 580 + <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 581 + <td className="px-4 py-2 text-gray-400"> 582 + {formatDbDate(site.created_at).toLocaleDateString()} 583 + </td> 584 + <td className="px-4 py-2"> 585 + <a 586 + href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 587 + target="_blank" 588 + rel="noopener noreferrer" 589 + className="text-blue-400 hover:text-blue-300 transition-colors" 590 + title="View on PDSls.dev" 591 + > 592 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 593 + <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" /> 594 + </svg> 595 + </a> 596 + </td> 597 + </tr> 598 + ))} 599 + </tbody> 600 + </table> 601 + </div> 602 + </div> 603 + 604 + {/* Recent Domains */} 605 + <div> 606 + <h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3> 607 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 608 + <table className="w-full text-sm"> 609 + <thead className="bg-gray-800"> 610 + <tr> 611 + <th className="px-4 py-2 text-left">Domain</th> 612 + <th className="px-4 py-2 text-left">DID</th> 613 + <th className="px-4 py-2 text-left">Verified</th> 614 + <th className="px-4 py-2 text-left">Created</th> 615 + </tr> 616 + </thead> 617 + <tbody> 618 + {database.recentDomains.map((domain: any, i: number) => ( 619 + <tr key={i} className="border-t border-gray-800"> 620 + <td className="px-4 py-2">{domain.domain}</td> 621 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 622 + {domain.did.slice(0, 20)}... 623 + </td> 624 + <td className="px-4 py-2"> 625 + <span 626 + className={`px-2 py-1 rounded text-xs ${ 627 + domain.verified 628 + ? 'bg-green-900 text-green-200' 629 + : 'bg-yellow-900 text-yellow-200' 630 + }`} 631 + > 632 + {domain.verified ? 'Yes' : 'No'} 633 + </span> 634 + </td> 635 + <td className="px-4 py-2 text-gray-400"> 636 + {formatDbDate(domain.created_at).toLocaleDateString()} 637 + </td> 638 + </tr> 639 + ))} 640 + </tbody> 641 + </table> 642 + </div> 643 + </div> 644 + </div> 645 + )} 646 + 647 + {tab === 'sites' && sites && ( 648 + <div className="space-y-6"> 649 + {/* All Sites */} 650 + <div> 651 + <h3 className="text-lg font-semibold mb-3">All Sites</h3> 652 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 653 + <table className="w-full text-sm"> 654 + <thead className="bg-gray-800"> 655 + <tr> 656 + <th className="px-4 py-2 text-left">Site Name</th> 657 + <th className="px-4 py-2 text-left">Subdomain</th> 658 + <th className="px-4 py-2 text-left">DID</th> 659 + <th className="px-4 py-2 text-left">RKey</th> 660 + <th className="px-4 py-2 text-left">Created</th> 661 + </tr> 662 + </thead> 663 + <tbody> 664 + {sites.sites.map((site: any, i: number) => ( 665 + <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 666 + <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 667 + <td className="px-4 py-2"> 668 + {site.subdomain ? ( 669 + <a 670 + href={`https://${site.subdomain}`} 671 + target="_blank" 672 + rel="noopener noreferrer" 673 + className="text-blue-400 hover:underline" 674 + > 675 + {site.subdomain} 676 + </a> 677 + ) : ( 678 + <span className="text-gray-500">No domain</span> 679 + )} 680 + </td> 681 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 682 + {site.did.slice(0, 30)}... 683 + </td> 684 + <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 685 + <td className="px-4 py-2 text-gray-400"> 686 + {formatDbDate(site.created_at).toLocaleString()} 687 + </td> 688 + <td className="px-4 py-2"> 689 + <a 690 + href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 691 + target="_blank" 692 + rel="noopener noreferrer" 693 + className="text-blue-400 hover:text-blue-300 transition-colors" 694 + title="View on PDSls.dev" 695 + > 696 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 697 + <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" /> 698 + </svg> 699 + </a> 700 + </td> 701 + </tr> 702 + ))} 703 + </tbody> 704 + </table> 705 + </div> 706 + </div> 707 + 708 + {/* Custom Domains */} 709 + <div> 710 + <h3 className="text-lg font-semibold mb-3">Custom Domains</h3> 711 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 712 + <table className="w-full text-sm"> 713 + <thead className="bg-gray-800"> 714 + <tr> 715 + <th className="px-4 py-2 text-left">Domain</th> 716 + <th className="px-4 py-2 text-left">Verified</th> 717 + <th className="px-4 py-2 text-left">DID</th> 718 + <th className="px-4 py-2 text-left">RKey</th> 719 + <th className="px-4 py-2 text-left">Created</th> 720 + <th className="px-4 py-2 text-left">PDSls</th> 721 + </tr> 722 + </thead> 723 + <tbody> 724 + {sites.customDomains.map((domain: any, i: number) => ( 725 + <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 726 + <td className="px-4 py-2"> 727 + {domain.verified ? ( 728 + <a 729 + href={`https://${domain.domain}`} 730 + target="_blank" 731 + rel="noopener noreferrer" 732 + className="text-blue-400 hover:underline" 733 + > 734 + {domain.domain} 735 + </a> 736 + ) : ( 737 + <span className="text-gray-400">{domain.domain}</span> 738 + )} 739 + </td> 740 + <td className="px-4 py-2"> 741 + <span 742 + className={`px-2 py-1 rounded text-xs ${ 743 + domain.verified 744 + ? 'bg-green-900 text-green-200' 745 + : 'bg-yellow-900 text-yellow-200' 746 + }`} 747 + > 748 + {domain.verified ? 'Yes' : 'Pending'} 749 + </span> 750 + </td> 751 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 752 + {domain.did.slice(0, 30)}... 753 + </td> 754 + <td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td> 755 + <td className="px-4 py-2 text-gray-400"> 756 + {formatDbDate(domain.created_at).toLocaleString()} 757 + </td> 758 + <td className="px-4 py-2"> 759 + <a 760 + href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`} 761 + target="_blank" 762 + rel="noopener noreferrer" 763 + className="text-blue-400 hover:text-blue-300 transition-colors" 764 + title="View on PDSls.dev" 765 + > 766 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 767 + <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" /> 768 + </svg> 769 + </a> 770 + </td> 771 + </tr> 772 + ))} 773 + </tbody> 774 + </table> 775 + </div> 776 + </div> 777 + </div> 778 + )} 779 + </div> 780 + </div> 781 + ) 782 + } 783 + 784 + // Main App 785 + function App() { 786 + const [authenticated, setAuthenticated] = useState(false) 787 + const [checking, setChecking] = useState(true) 788 + 789 + useEffect(() => { 790 + fetch('/api/admin/status', { credentials: 'include' }) 791 + .then((res) => res.json()) 792 + .then((data) => { 793 + setAuthenticated(data.authenticated) 794 + setChecking(false) 795 + }) 796 + .catch(() => { 797 + setChecking(false) 798 + }) 799 + }, []) 800 + 801 + if (checking) { 802 + return ( 803 + <div className="min-h-screen bg-gray-950 flex items-center justify-center"> 804 + <div className="text-white">Loading...</div> 805 + </div> 806 + ) 807 + } 808 + 809 + if (!authenticated) { 810 + return <Login onLogin={() => setAuthenticated(true)} /> 811 + } 812 + 813 + return <Dashboard /> 814 + } 815 + 816 + createRoot(document.getElementById('root')!).render( 817 + <StrictMode> 818 + <App /> 819 + </StrictMode> 820 + )
+13
public/admin/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Admin Dashboard - Wisp.place</title> 7 + <link rel="stylesheet" href="./styles.css" /> 8 + </head> 9 + <body> 10 + <div id="root"></div> 11 + <script type="module" src="./admin.tsx"></script> 12 + </body> 13 + </html>
+1
public/admin/styles.css
··· 1 + @import "tailwindcss";
+23
public/components/ui/code-block.tsx
··· 1 + import ShikiHighlighter from 'react-shiki' 2 + 3 + interface CodeBlockProps { 4 + code: string 5 + language?: string 6 + className?: string 7 + } 8 + 9 + export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) { 10 + return ( 11 + <ShikiHighlighter 12 + language={language} 13 + theme={{ 14 + light: 'catppuccin-latte', 15 + dark: 'catppuccin-mocha', 16 + }} 17 + defaultColor="light-dark()" 18 + className={className} 19 + > 20 + {code.trim()} 21 + </ShikiHighlighter> 22 + ) 23 + }
+1 -1
public/components/ui/radio-group.tsx
··· 27 27 <RadioGroupPrimitive.Item 28 28 data-slot="radio-group-item" 29 29 className={cn( 30 - "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 30 + "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 31 31 className 32 32 )} 33 33 {...props}
+2 -2
public/components/ui/tabs.tsx
··· 24 24 <TabsPrimitive.List 25 25 data-slot="tabs-list" 26 26 className={cn( 27 - "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 27 + "bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 28 28 className 29 29 )} 30 30 {...props} ··· 40 40 <TabsPrimitive.Trigger 41 41 data-slot="tabs-trigger" 42 42 className={cn( 43 - "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 43 + "data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 44 44 className 45 45 )} 46 46 {...props}
+586 -41
public/editor/editor.tsx
··· 38 38 Settings 39 39 } from 'lucide-react' 40 40 import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 41 + import { CodeBlock } from '@public/components/ui/code-block' 41 42 42 43 import Layout from '@public/layouts' 43 44 ··· 88 89 const [configuringSite, setConfiguringSite] = useState<Site | null>(null) 89 90 const [selectedDomain, setSelectedDomain] = useState<string>('') 90 91 const [isSavingConfig, setIsSavingConfig] = useState(false) 92 + const [isDeletingSite, setIsDeletingSite] = useState(false) 91 93 92 94 // Upload state 93 - const [siteName, setSiteName] = useState('') 95 + const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 96 + const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 97 + const [newSiteName, setNewSiteName] = useState('') 94 98 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 95 99 const [isUploading, setIsUploading] = useState(false) 96 100 const [uploadProgress, setUploadProgress] = useState('') 101 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 102 + const [uploadedCount, setUploadedCount] = useState(0) 97 103 98 104 // Custom domain modal state 99 105 const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) ··· 104 110 }>({}) 105 111 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 106 112 113 + // Wisp domain claim state 114 + const [wispHandle, setWispHandle] = useState('') 115 + const [isClaimingWisp, setIsClaimingWisp] = useState(false) 116 + const [wispAvailability, setWispAvailability] = useState<{ 117 + available: boolean | null 118 + checking: boolean 119 + }>({ available: null, checking: false }) 120 + 107 121 // Fetch user info on mount 108 122 useEffect(() => { 109 123 fetchUserInfo() ··· 111 125 fetchDomains() 112 126 }, []) 113 127 128 + // Auto-switch to 'new' mode if no sites exist 129 + useEffect(() => { 130 + if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 131 + setSiteMode('new') 132 + } 133 + }, [sites, sitesLoading, siteMode]) 134 + 114 135 const fetchUserInfo = async () => { 115 136 try { 116 137 const response = await fetch('/api/user/info') ··· 205 226 } 206 227 207 228 const handleUpload = async () => { 229 + const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 230 + 208 231 if (!siteName) { 209 - alert('Please enter a site name') 232 + alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 210 233 return 211 234 } 212 235 ··· 232 255 const data = await response.json() 233 256 if (data.success) { 234 257 setUploadProgress('Upload complete!') 235 - setSiteName('') 258 + setSkippedFiles(data.skippedFiles || []) 259 + setUploadedCount(data.uploadedCount || data.fileCount || 0) 260 + setSelectedSiteRkey('') 261 + setNewSiteName('') 236 262 setSelectedFiles(null) 237 263 238 264 // Refresh sites list 239 265 await fetchSites() 240 266 241 - // Reset form 267 + // Reset form - give more time if there are skipped files 268 + const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 242 269 setTimeout(() => { 243 270 setUploadProgress('') 271 + setSkippedFiles([]) 272 + setUploadedCount(0) 244 273 setIsUploading(false) 245 - }, 1500) 274 + }, resetDelay) 246 275 } else { 247 276 throw new Error(data.error || 'Upload failed') 248 277 } ··· 423 452 } 424 453 } 425 454 455 + const handleDeleteSite = async () => { 456 + if (!configuringSite) return 457 + 458 + if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { 459 + return 460 + } 461 + 462 + setIsDeletingSite(true) 463 + try { 464 + const response = await fetch(`/api/site/${configuringSite.rkey}`, { 465 + method: 'DELETE' 466 + }) 467 + 468 + const data = await response.json() 469 + if (data.success) { 470 + // Refresh sites list 471 + await fetchSites() 472 + // Refresh domains in case this site was mapped 473 + await fetchDomains() 474 + setConfiguringSite(null) 475 + } else { 476 + throw new Error(data.error || 'Failed to delete site') 477 + } 478 + } catch (err) { 479 + console.error('Delete site error:', err) 480 + alert( 481 + `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 482 + ) 483 + } finally { 484 + setIsDeletingSite(false) 485 + } 486 + } 487 + 488 + const checkWispAvailability = async (handle: string) => { 489 + const trimmedHandle = handle.trim().toLowerCase() 490 + if (!trimmedHandle) { 491 + setWispAvailability({ available: null, checking: false }) 492 + return 493 + } 494 + 495 + setWispAvailability({ available: null, checking: true }) 496 + try { 497 + const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 498 + const data = await response.json() 499 + setWispAvailability({ available: data.available, checking: false }) 500 + } catch (err) { 501 + console.error('Check availability error:', err) 502 + setWispAvailability({ available: false, checking: false }) 503 + } 504 + } 505 + 506 + const handleClaimWispDomain = async () => { 507 + const trimmedHandle = wispHandle.trim().toLowerCase() 508 + if (!trimmedHandle) { 509 + alert('Please enter a handle') 510 + return 511 + } 512 + 513 + setIsClaimingWisp(true) 514 + try { 515 + const response = await fetch('/api/domain/claim', { 516 + method: 'POST', 517 + headers: { 'Content-Type': 'application/json' }, 518 + body: JSON.stringify({ handle: trimmedHandle }) 519 + }) 520 + 521 + const data = await response.json() 522 + if (data.success) { 523 + setWispHandle('') 524 + setWispAvailability({ available: null, checking: false }) 525 + await fetchDomains() 526 + } else { 527 + throw new Error(data.error || 'Failed to claim domain') 528 + } 529 + } catch (err) { 530 + console.error('Claim domain error:', err) 531 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 532 + 533 + // Handle "Already claimed" error more gracefully 534 + if (errorMessage.includes('Already claimed')) { 535 + alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 536 + await fetchDomains() 537 + } else { 538 + alert(`Failed to claim domain: ${errorMessage}`) 539 + } 540 + } finally { 541 + setIsClaimingWisp(false) 542 + } 543 + } 544 + 426 545 if (loading) { 427 546 return ( 428 547 <div className="w-full min-h-screen bg-background flex items-center justify-center"> ··· 461 580 </div> 462 581 463 582 <Tabs defaultValue="sites" className="space-y-6 w-full"> 464 - <TabsList className="grid w-full grid-cols-3 max-w-md"> 583 + <TabsList className="grid w-full grid-cols-4"> 465 584 <TabsTrigger value="sites">Sites</TabsTrigger> 466 585 <TabsTrigger value="domains">Domains</TabsTrigger> 467 586 <TabsTrigger value="upload">Upload</TabsTrigger> 587 + <TabsTrigger value="cli">CLI</TabsTrigger> 468 588 </TabsList> 469 589 470 590 {/* Sites Tab */} ··· 579 699 </p> 580 700 </> 581 701 ) : ( 582 - <div className="text-center py-4 text-muted-foreground"> 583 - <p>No wisp.place subdomain claimed yet.</p> 584 - <p className="text-sm mt-1"> 585 - You should have claimed one during onboarding! 586 - </p> 702 + <div className="space-y-4"> 703 + <div className="p-4 bg-muted/30 rounded-lg"> 704 + <p className="text-sm text-muted-foreground mb-4"> 705 + Claim your free wisp.place subdomain 706 + </p> 707 + <div className="space-y-3"> 708 + <div className="space-y-2"> 709 + <Label htmlFor="wisp-handle">Choose your handle</Label> 710 + <div className="flex gap-2"> 711 + <div className="flex-1 relative"> 712 + <Input 713 + id="wisp-handle" 714 + placeholder="mysite" 715 + value={wispHandle} 716 + onChange={(e) => { 717 + setWispHandle(e.target.value) 718 + if (e.target.value.trim()) { 719 + checkWispAvailability(e.target.value) 720 + } else { 721 + setWispAvailability({ available: null, checking: false }) 722 + } 723 + }} 724 + disabled={isClaimingWisp} 725 + className="pr-24" 726 + /> 727 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 728 + .wisp.place 729 + </span> 730 + </div> 731 + </div> 732 + {wispAvailability.checking && ( 733 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 734 + <Loader2 className="w-3 h-3 animate-spin" /> 735 + Checking availability... 736 + </p> 737 + )} 738 + {!wispAvailability.checking && wispAvailability.available === true && ( 739 + <p className="text-xs text-green-600 flex items-center gap-1"> 740 + <CheckCircle2 className="w-3 h-3" /> 741 + Available 742 + </p> 743 + )} 744 + {!wispAvailability.checking && wispAvailability.available === false && ( 745 + <p className="text-xs text-red-600 flex items-center gap-1"> 746 + <XCircle className="w-3 h-3" /> 747 + Not available 748 + </p> 749 + )} 750 + </div> 751 + <Button 752 + onClick={handleClaimWispDomain} 753 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 754 + className="w-full" 755 + > 756 + {isClaimingWisp ? ( 757 + <> 758 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 759 + Claiming... 760 + </> 761 + ) : ( 762 + 'Claim Subdomain' 763 + )} 764 + </Button> 765 + </div> 766 + </div> 587 767 </div> 588 768 )} 589 769 </CardContent> ··· 705 885 </CardDescription> 706 886 </CardHeader> 707 887 <CardContent className="space-y-6"> 708 - <div className="space-y-2"> 709 - <Label htmlFor="site-name">Site Name</Label> 710 - <Input 711 - id="site-name" 712 - placeholder="my-awesome-site" 713 - value={siteName} 714 - onChange={(e) => setSiteName(e.target.value)} 715 - disabled={isUploading} 716 - /> 888 + <div className="space-y-4"> 889 + <div className="p-4 bg-muted/50 rounded-lg"> 890 + <RadioGroup 891 + value={siteMode} 892 + onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 893 + disabled={isUploading} 894 + > 895 + <div className="flex items-center space-x-2"> 896 + <RadioGroupItem value="existing" id="existing" /> 897 + <Label htmlFor="existing" className="cursor-pointer"> 898 + Update existing site 899 + </Label> 900 + </div> 901 + <div className="flex items-center space-x-2"> 902 + <RadioGroupItem value="new" id="new" /> 903 + <Label htmlFor="new" className="cursor-pointer"> 904 + Create new site 905 + </Label> 906 + </div> 907 + </RadioGroup> 908 + </div> 909 + 910 + {siteMode === 'existing' ? ( 911 + <div className="space-y-2"> 912 + <Label htmlFor="site-select">Select Site</Label> 913 + {sitesLoading ? ( 914 + <div className="flex items-center justify-center py-4"> 915 + <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 916 + </div> 917 + ) : sites.length === 0 ? ( 918 + <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 919 + No sites available. Create a new site instead. 920 + </div> 921 + ) : ( 922 + <select 923 + id="site-select" 924 + 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" 925 + value={selectedSiteRkey} 926 + onChange={(e) => setSelectedSiteRkey(e.target.value)} 927 + disabled={isUploading} 928 + > 929 + <option value="">Select a site...</option> 930 + {sites.map((site) => ( 931 + <option key={site.rkey} value={site.rkey}> 932 + {site.display_name || site.rkey} 933 + </option> 934 + ))} 935 + </select> 936 + )} 937 + </div> 938 + ) : ( 939 + <div className="space-y-2"> 940 + <Label htmlFor="new-site-name">New Site Name</Label> 941 + <Input 942 + id="new-site-name" 943 + placeholder="my-awesome-site" 944 + value={newSiteName} 945 + onChange={(e) => setNewSiteName(e.target.value)} 946 + disabled={isUploading} 947 + /> 948 + </div> 949 + )} 950 + 951 + <p className="text-xs text-muted-foreground"> 952 + File limits: 100MB per file, 300MB total 953 + </p> 717 954 </div> 718 955 719 956 <div className="grid md:grid-cols-2 gap-4"> ··· 774 1011 </div> 775 1012 776 1013 {uploadProgress && ( 777 - <div className="p-4 bg-muted rounded-lg"> 778 - <div className="flex items-center gap-2"> 779 - <Loader2 className="w-4 h-4 animate-spin" /> 780 - <span className="text-sm">{uploadProgress}</span> 1014 + <div className="space-y-3"> 1015 + <div className="p-4 bg-muted rounded-lg"> 1016 + <div className="flex items-center gap-2"> 1017 + <Loader2 className="w-4 h-4 animate-spin" /> 1018 + <span className="text-sm">{uploadProgress}</span> 1019 + </div> 781 1020 </div> 1021 + 1022 + {skippedFiles.length > 0 && ( 1023 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1024 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1025 + <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 1026 + <div className="flex-1"> 1027 + <span className="font-medium"> 1028 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 1029 + </span> 1030 + {uploadedCount > 0 && ( 1031 + <span className="text-sm ml-2"> 1032 + ({uploadedCount} uploaded successfully) 1033 + </span> 1034 + )} 1035 + </div> 1036 + </div> 1037 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 1038 + {skippedFiles.slice(0, 5).map((file, idx) => ( 1039 + <div key={idx} className="text-xs"> 1040 + <span className="font-mono">{file.name}</span> 1041 + <span className="text-muted-foreground"> - {file.reason}</span> 1042 + </div> 1043 + ))} 1044 + {skippedFiles.length > 5 && ( 1045 + <div className="text-xs text-muted-foreground"> 1046 + ...and {skippedFiles.length - 5} more 1047 + </div> 1048 + )} 1049 + </div> 1050 + </div> 1051 + )} 782 1052 </div> 783 1053 )} 784 1054 785 1055 <Button 786 1056 onClick={handleUpload} 787 1057 className="w-full" 788 - disabled={!siteName || isUploading} 1058 + disabled={ 1059 + (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 1060 + isUploading || 1061 + (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 1062 + } 789 1063 > 790 1064 {isUploading ? ( 791 1065 <> ··· 794 1068 </> 795 1069 ) : ( 796 1070 <> 797 - {selectedFiles && selectedFiles.length > 0 798 - ? 'Upload & Deploy' 799 - : 'Create Empty Site'} 1071 + {siteMode === 'existing' ? ( 1072 + 'Update Site' 1073 + ) : ( 1074 + selectedFiles && selectedFiles.length > 0 1075 + ? 'Upload & Deploy' 1076 + : 'Create Empty Site' 1077 + )} 800 1078 </> 801 1079 )} 802 1080 </Button> 803 1081 </CardContent> 804 1082 </Card> 805 1083 </TabsContent> 1084 + 1085 + {/* CLI Tab */} 1086 + <TabsContent value="cli" className="space-y-4 min-h-[400px]"> 1087 + <Card> 1088 + <CardHeader> 1089 + <div className="flex items-center gap-2 mb-2"> 1090 + <CardTitle>Wisp CLI Tool</CardTitle> 1091 + <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 1092 + <Badge variant="outline" className="text-xs">Alpha</Badge> 1093 + </div> 1094 + <CardDescription> 1095 + Deploy static sites directly from your terminal 1096 + </CardDescription> 1097 + </CardHeader> 1098 + <CardContent className="space-y-6"> 1099 + <div className="prose prose-sm max-w-none dark:prose-invert"> 1100 + <p className="text-sm text-muted-foreground"> 1101 + The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 1102 + Authenticate with app password or OAuth and deploy from CI/CD pipelines. 1103 + </p> 1104 + </div> 1105 + 1106 + <div className="space-y-3"> 1107 + <h3 className="text-sm font-semibold">Download CLI</h3> 1108 + <div className="grid gap-2"> 1109 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1110 + <a 1111 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 1112 + target="_blank" 1113 + rel="noopener noreferrer" 1114 + className="flex items-center justify-between mb-2" 1115 + > 1116 + <span className="font-mono text-sm">macOS (Apple Silicon)</span> 1117 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1118 + </a> 1119 + <div className="text-xs text-muted-foreground"> 1120 + <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 1121 + </div> 1122 + </div> 1123 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1124 + <a 1125 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 1126 + target="_blank" 1127 + rel="noopener noreferrer" 1128 + className="flex items-center justify-between mb-2" 1129 + > 1130 + <span className="font-mono text-sm">Linux (ARM64)</span> 1131 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1132 + </a> 1133 + <div className="text-xs text-muted-foreground"> 1134 + <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 1135 + </div> 1136 + </div> 1137 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1138 + <a 1139 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 1140 + target="_blank" 1141 + rel="noopener noreferrer" 1142 + className="flex items-center justify-between mb-2" 1143 + > 1144 + <span className="font-mono text-sm">Linux (x86_64)</span> 1145 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1146 + </a> 1147 + <div className="text-xs text-muted-foreground"> 1148 + <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 1149 + </div> 1150 + </div> 1151 + </div> 1152 + </div> 1153 + 1154 + <div className="space-y-3"> 1155 + <h3 className="text-sm font-semibold">Basic Usage</h3> 1156 + <CodeBlock 1157 + code={`# Download and make executable 1158 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 1159 + chmod +x wisp-cli-macos-arm64 1160 + 1161 + # Deploy your site (will use OAuth) 1162 + ./wisp-cli-macos-arm64 your-handle.bsky.social \\ 1163 + --path ./dist \\ 1164 + --site my-site 1165 + 1166 + # Your site will be available at: 1167 + # https://sites.wisp.place/your-handle/my-site`} 1168 + language="bash" 1169 + /> 1170 + </div> 1171 + 1172 + <div className="space-y-3"> 1173 + <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 1174 + <p className="text-xs text-muted-foreground"> 1175 + Deploy automatically on every push using{' '} 1176 + <a 1177 + href="https://blog.tangled.org/ci" 1178 + target="_blank" 1179 + rel="noopener noreferrer" 1180 + className="text-accent hover:underline" 1181 + > 1182 + Tangled Spindle 1183 + </a> 1184 + </p> 1185 + 1186 + <div className="space-y-4"> 1187 + <div> 1188 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1189 + <span>Example 1: Simple Asset Publishing</span> 1190 + <Badge variant="secondary" className="text-xs">Copy Files</Badge> 1191 + </h4> 1192 + <CodeBlock 1193 + code={`when: 1194 + - event: ['push'] 1195 + branch: ['main'] 1196 + - event: ['manual'] 1197 + 1198 + engine: 'nixery' 1199 + 1200 + clone: 1201 + skip: false 1202 + depth: 1 1203 + 1204 + dependencies: 1205 + nixpkgs: 1206 + - coreutils 1207 + - curl 1208 + 1209 + environment: 1210 + SITE_PATH: '.' # Copy entire repo 1211 + SITE_NAME: 'myWebbedSite' 1212 + WISP_HANDLE: 'your-handle.bsky.social' 1213 + 1214 + steps: 1215 + - name: deploy assets to wisp 1216 + command: | 1217 + # Download Wisp CLI 1218 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1219 + chmod +x wisp-cli 1220 + 1221 + # Deploy to Wisp 1222 + ./wisp-cli \\ 1223 + "$WISP_HANDLE" \\ 1224 + --path "$SITE_PATH" \\ 1225 + --site "$SITE_NAME" \\ 1226 + --password "$WISP_APP_PASSWORD" 1227 + 1228 + # Output 1229 + #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 1230 + #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 1231 + `} 1232 + language="yaml" 1233 + /> 1234 + </div> 1235 + 1236 + <div> 1237 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1238 + <span>Example 2: React/Vite Build & Deploy</span> 1239 + <Badge variant="secondary" className="text-xs">Full Build</Badge> 1240 + </h4> 1241 + <CodeBlock 1242 + code={`when: 1243 + - event: ['push'] 1244 + branch: ['main'] 1245 + - event: ['manual'] 1246 + 1247 + engine: 'nixery' 1248 + 1249 + clone: 1250 + skip: false 1251 + depth: 1 1252 + submodules: false 1253 + 1254 + dependencies: 1255 + nixpkgs: 1256 + - nodejs 1257 + - coreutils 1258 + - curl 1259 + github:NixOS/nixpkgs/nixpkgs-unstable: 1260 + - bun 1261 + 1262 + environment: 1263 + SITE_PATH: 'dist' 1264 + SITE_NAME: 'my-react-site' 1265 + WISP_HANDLE: 'your-handle.bsky.social' 1266 + 1267 + steps: 1268 + - name: build site 1269 + command: | 1270 + # necessary to ensure bun is in PATH 1271 + export PATH="$HOME/.nix-profile/bin:$PATH" 1272 + 1273 + bun install --frozen-lockfile 1274 + 1275 + # build with vite, run directly to get around env issues 1276 + bun node_modules/.bin/vite build 1277 + 1278 + - name: deploy to wisp 1279 + command: | 1280 + # Download Wisp CLI 1281 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1282 + chmod +x wisp-cli 1283 + 1284 + # Deploy to Wisp 1285 + ./wisp-cli \\ 1286 + "$WISP_HANDLE" \\ 1287 + --path "$SITE_PATH" \\ 1288 + --site "$SITE_NAME" \\ 1289 + --password "$WISP_APP_PASSWORD"`} 1290 + language="yaml" 1291 + /> 1292 + </div> 1293 + </div> 1294 + 1295 + <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 1296 + <p className="text-xs text-muted-foreground"> 1297 + <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 1298 + Generate an app password from your AT Protocol account settings. 1299 + </p> 1300 + </div> 1301 + </div> 1302 + 1303 + <div className="space-y-3"> 1304 + <h3 className="text-sm font-semibold">Learn More</h3> 1305 + <div className="grid gap-2"> 1306 + <a 1307 + href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 1308 + target="_blank" 1309 + rel="noopener noreferrer" 1310 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1311 + > 1312 + <span className="text-sm">Source Code</span> 1313 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1314 + </a> 1315 + <a 1316 + href="https://blog.tangled.org/ci" 1317 + target="_blank" 1318 + rel="noopener noreferrer" 1319 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1320 + > 1321 + <span className="text-sm">Tangled Spindle CI/CD</span> 1322 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1323 + </a> 1324 + </div> 1325 + </div> 1326 + </CardContent> 1327 + </Card> 1328 + </TabsContent> 806 1329 </Tabs> 807 1330 </div> 808 1331 ··· 951 1474 </RadioGroup> 952 1475 </div> 953 1476 )} 954 - <DialogFooter> 955 - <Button 956 - variant="outline" 957 - onClick={() => setConfiguringSite(null)} 958 - disabled={isSavingConfig} 959 - > 960 - Cancel 961 - </Button> 1477 + <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2"> 962 1478 <Button 963 - onClick={handleSaveSiteConfig} 964 - disabled={isSavingConfig} 1479 + variant="destructive" 1480 + onClick={handleDeleteSite} 1481 + disabled={isSavingConfig || isDeletingSite} 1482 + className="sm:mr-auto" 965 1483 > 966 - {isSavingConfig ? ( 1484 + {isDeletingSite ? ( 967 1485 <> 968 1486 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 969 - Saving... 1487 + Deleting... 970 1488 </> 971 1489 ) : ( 972 - 'Save' 1490 + <> 1491 + <Trash2 className="w-4 h-4 mr-2" /> 1492 + Delete Site 1493 + </> 973 1494 )} 974 1495 </Button> 1496 + <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto"> 1497 + <Button 1498 + variant="outline" 1499 + onClick={() => setConfiguringSite(null)} 1500 + disabled={isSavingConfig || isDeletingSite} 1501 + className="w-full sm:w-auto" 1502 + > 1503 + Cancel 1504 + </Button> 1505 + <Button 1506 + onClick={handleSaveSiteConfig} 1507 + disabled={isSavingConfig || isDeletingSite} 1508 + className="w-full sm:w-auto" 1509 + > 1510 + {isSavingConfig ? ( 1511 + <> 1512 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1513 + Saving... 1514 + </> 1515 + ) : ( 1516 + 'Save' 1517 + )} 1518 + </Button> 1519 + </div> 975 1520 </DialogFooter> 976 1521 </DialogContent> 977 1522 </Dialog>
+1
public/editor/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Elysia Static</title> 7 + <link rel="icon" type="image/x-icon" href="../favicon.ico"> 7 8 </head> 8 9 <body> 9 10 <div id="elysia"></div>
public/favicon.ico

This is a binary file and will not be displayed.

+14
public/favicon.svg
··· 1 + <!--?xml version="1.0" encoding="utf-8"?--> 2 + <svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Centered large wisp on black background"> 3 + <!-- black background --> 4 + <rect width="64" height="64" fill="#000000"></rect> 5 + 6 + <!-- outer faint glow --> 7 + <circle cx="32" cy="32" r="14" fill="none" stroke="#7CF5D8" stroke-opacity="0.35" stroke-width="2.6"></circle> 8 + 9 + <!-- bright halo --> 10 + <circle cx="32" cy="32" r="10" fill="none" stroke="#CFF8EE" stroke-opacity="0.95" stroke-width="2.4"></circle> 11 + 12 + <!-- bright core --> 13 + <circle cx="32" cy="32" r="4" fill="#FFFFFF"></circle> 14 + </svg>
+1
public/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Elysia Static</title> 7 + <link rel="icon" type="image/x-icon" href="./favicon.ico"> 7 8 </head> 8 9 <body> 9 10 <div id="elysia"></div>
+265 -250
public/index.tsx
··· 25 25 }, [showForm]) 26 26 27 27 return ( 28 - <div className="min-h-screen"> 29 - {/* Header */} 30 - <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 31 - <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 32 - <div className="flex items-center gap-2"> 33 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 34 - <Globe className="w-5 h-5 text-primary-foreground" /> 28 + <> 29 + <div className="min-h-screen"> 30 + {/* Header */} 31 + <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 32 + <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 33 + <div className="flex items-center gap-2"> 34 + <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 35 + <Globe className="w-5 h-5 text-primary-foreground" /> 36 + </div> 37 + <span className="text-xl font-semibold text-foreground"> 38 + wisp.place 39 + </span> 40 + </div> 41 + <div className="flex items-center gap-3"> 42 + <Button 43 + variant="ghost" 44 + size="sm" 45 + onClick={() => setShowForm(true)} 46 + > 47 + Sign In 48 + </Button> 49 + <Button 50 + size="sm" 51 + className="bg-accent text-accent-foreground hover:bg-accent/90" 52 + > 53 + Get Started 54 + </Button> 35 55 </div> 36 - <span className="text-xl font-semibold text-foreground"> 37 - wisp.place 38 - </span> 39 56 </div> 40 - <div className="flex items-center gap-3"> 41 - <Button 42 - variant="ghost" 43 - size="sm" 44 - onClick={() => setShowForm(true)} 45 - > 46 - Sign In 47 - </Button> 48 - <Button 49 - size="sm" 50 - className="bg-accent text-accent-foreground hover:bg-accent/90" 51 - > 52 - Get Started 53 - </Button> 54 - </div> 55 - </div> 56 - </header> 57 + </header> 57 58 58 - {/* Hero Section */} 59 - <section className="container mx-auto px-4 py-20 md:py-32"> 60 - <div className="max-w-4xl mx-auto text-center"> 61 - <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 62 - <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 63 - <span className="text-sm text-accent-foreground"> 64 - Built on AT Protocol 65 - </span> 66 - </div> 59 + {/* Hero Section */} 60 + <section className="container mx-auto px-4 py-20 md:py-32"> 61 + <div className="max-w-4xl mx-auto text-center"> 62 + <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 63 + <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 64 + <span className="text-sm text-accent-foreground"> 65 + Built on AT Protocol 66 + </span> 67 + </div> 67 68 68 - <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 69 - Host your sites on the{' '} 70 - <span className="text-primary">decentralized</span> web 71 - </h1> 69 + <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 70 + Your Website.Your Control. Lightning Fast. 71 + </h1> 72 72 73 - <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 74 - Deploy static sites to a truly open network. Your 75 - content, your control, your identity. No platform 76 - lock-in, ever. 77 - </p> 73 + <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 74 + Host static sites in your AT Protocol account. You 75 + keep ownership and control. We just serve them fast 76 + through our CDN. 77 + </p> 78 78 79 - <div className="max-w-md mx-auto relative"> 80 - <div 81 - className={`transition-all duration-500 ease-in-out ${ 82 - showForm 83 - ? 'opacity-0 -translate-y-5 pointer-events-none' 84 - : 'opacity-100 translate-y-0' 85 - }`} 86 - > 87 - <Button 88 - size="lg" 89 - className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 90 - onClick={() => setShowForm(true)} 79 + <div className="max-w-md mx-auto relative"> 80 + <div 81 + className={`transition-all duration-500 ease-in-out ${ 82 + showForm 83 + ? 'opacity-0 -translate-y-5 pointer-events-none' 84 + : 'opacity-100 translate-y-0' 85 + }`} 91 86 > 92 - Log in with AT Proto 93 - <ArrowRight className="ml-2 w-5 h-5" /> 94 - </Button> 95 - </div> 87 + <Button 88 + size="lg" 89 + className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 90 + onClick={() => setShowForm(true)} 91 + > 92 + Log in with AT Proto 93 + <ArrowRight className="ml-2 w-5 h-5" /> 94 + </Button> 95 + </div> 96 96 97 - <div 98 - className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 99 - showForm 100 - ? 'opacity-100 translate-y-0' 101 - : 'opacity-0 translate-y-5 pointer-events-none' 102 - }`} 103 - > 104 - <form 105 - onSubmit={async (e) => { 106 - e.preventDefault() 107 - try { 108 - const handle = inputRef.current?.value 109 - const res = await fetch( 110 - '/api/auth/signin', 111 - { 112 - method: 'POST', 113 - headers: { 114 - 'Content-Type': 115 - 'application/json' 116 - }, 117 - body: JSON.stringify({ handle }) 97 + <div 98 + className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 99 + showForm 100 + ? 'opacity-100 translate-y-0' 101 + : 'opacity-0 translate-y-5 pointer-events-none' 102 + }`} 103 + > 104 + <form 105 + onSubmit={async (e) => { 106 + e.preventDefault() 107 + try { 108 + const handle = 109 + inputRef.current?.value 110 + const res = await fetch( 111 + '/api/auth/signin', 112 + { 113 + method: 'POST', 114 + headers: { 115 + 'Content-Type': 116 + 'application/json' 117 + }, 118 + body: JSON.stringify({ 119 + handle 120 + }) 121 + } 122 + ) 123 + if (!res.ok) 124 + throw new Error( 125 + 'Request failed' 126 + ) 127 + const data = await res.json() 128 + if (data.url) { 129 + window.location.href = data.url 130 + } else { 131 + alert('Unexpected response') 118 132 } 119 - ) 120 - if (!res.ok) 121 - throw new Error('Request failed') 122 - const data = await res.json() 123 - if (data.url) { 124 - window.location.href = data.url 125 - } else { 126 - alert('Unexpected response') 133 + } catch (error) { 134 + console.error( 135 + 'Login failed:', 136 + error 137 + ) 138 + alert('Authentication failed') 127 139 } 128 - } catch (error) { 129 - console.error('Login failed:', error) 130 - alert('Authentication failed') 131 - } 132 - }} 133 - className="space-y-3" 134 - > 135 - <input 136 - ref={inputRef} 137 - type="text" 138 - name="handle" 139 - placeholder="Enter your handle (e.g., alice.bsky.social)" 140 - 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" 141 - /> 142 - <button 143 - type="submit" 144 - 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" 140 + }} 141 + className="space-y-3" 145 142 > 146 - Continue 147 - <ArrowRight className="ml-2 w-5 h-5" /> 148 - </button> 149 - </form> 143 + <input 144 + ref={inputRef} 145 + type="text" 146 + name="handle" 147 + placeholder="Enter your handle (e.g., alice.bsky.social)" 148 + 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" 149 + /> 150 + <button 151 + type="submit" 152 + 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" 153 + > 154 + Continue 155 + <ArrowRight className="ml-2 w-5 h-5" /> 156 + </button> 157 + </form> 158 + </div> 150 159 </div> 151 160 </div> 152 - </div> 153 - </section> 161 + </section> 154 162 155 - {/* Stats Section */} 156 - <section className="container mx-auto px-4 py-16"> 157 - <div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto"> 158 - {[ 159 - { value: '100%', label: 'Decentralized' }, 160 - { value: '0ms', label: 'Cold Start' }, 161 - { value: 'โˆž', label: 'Scalability' }, 162 - { value: 'You', label: 'Own Your Data' } 163 - ].map((stat, i) => ( 164 - <div key={i} className="text-center"> 165 - <div className="text-4xl md:text-5xl font-bold text-primary mb-2"> 166 - {stat.value} 163 + {/* How It Works */} 164 + <section className="container mx-auto px-4 py-16 bg-muted/30"> 165 + <div className="max-w-3xl mx-auto text-center"> 166 + <h2 className="text-3xl md:text-4xl font-bold mb-8"> 167 + How it works 168 + </h2> 169 + <div className="space-y-6 text-left"> 170 + <div className="flex gap-4 items-start"> 171 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 172 + 01 173 + </div> 174 + <div> 175 + <h3 className="text-xl font-semibold mb-2"> 176 + Upload your static site 177 + </h3> 178 + <p className="text-muted-foreground"> 179 + Your HTML, CSS, and JavaScript files are 180 + stored in your AT Protocol account as 181 + gzipped blobs and a manifest record. 182 + </p> 183 + </div> 184 + </div> 185 + <div className="flex gap-4 items-start"> 186 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 187 + 02 188 + </div> 189 + <div> 190 + <h3 className="text-xl font-semibold mb-2"> 191 + We serve it globally 192 + </h3> 193 + <p className="text-muted-foreground"> 194 + Wisp.place reads your site from your 195 + account and delivers it through our CDN 196 + for fast loading anywhere. 197 + </p> 198 + </div> 167 199 </div> 168 - <div className="text-sm text-muted-foreground"> 169 - {stat.label} 200 + <div className="flex gap-4 items-start"> 201 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 202 + 03 203 + </div> 204 + <div> 205 + <h3 className="text-xl font-semibold mb-2"> 206 + You stay in control 207 + </h3> 208 + <p className="text-muted-foreground"> 209 + Update or remove your site anytime 210 + through your AT Protocol account. No 211 + lock-in, no middleman ownership. 212 + </p> 213 + </div> 170 214 </div> 171 215 </div> 172 - ))} 173 - </div> 174 - </section> 216 + </div> 217 + </section> 175 218 176 - {/* Features Grid */} 177 - <section id="features" className="container mx-auto px-4 py-20"> 178 - <div className="text-center mb-16"> 179 - <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 180 - Built for the open web 181 - </h2> 182 - <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 183 - Everything you need to deploy and manage static sites on 184 - a decentralized network 185 - </p> 186 - </div> 219 + {/* Features Grid */} 220 + <section id="features" className="container mx-auto px-4 py-20"> 221 + <div className="text-center mb-16"> 222 + <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 223 + Why Wisp.place? 224 + </h2> 225 + <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 226 + Static site hosting that respects your ownership 227 + </p> 228 + </div> 187 229 188 - <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 189 - {[ 190 - { 191 - icon: Shield, 192 - title: 'True Ownership', 193 - description: 194 - 'Your content lives on the AT Protocol network. No single company can take it down or lock you out.' 195 - }, 196 - { 197 - icon: Zap, 198 - title: 'Lightning Fast', 199 - description: 200 - 'Distributed edge network ensures your sites load instantly from anywhere in the world.' 201 - }, 202 - { 203 - icon: Lock, 204 - title: 'Cryptographic Security', 205 - description: 206 - 'Content-addressed storage and cryptographic verification ensure integrity and authenticity.' 207 - }, 208 - { 209 - icon: Code, 210 - title: 'Developer Friendly', 211 - description: 212 - 'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.' 213 - }, 214 - { 215 - icon: Server, 216 - title: 'Zero Vendor Lock-in', 217 - description: 218 - 'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.' 219 - }, 220 - { 221 - icon: Globe, 222 - title: 'Global Network', 223 - description: 224 - 'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.' 225 - } 226 - ].map((feature, i) => ( 227 - <Card 228 - key={i} 229 - className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 230 - > 231 - <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 232 - <feature.icon className="w-6 h-6 text-accent" /> 233 - </div> 234 - <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 235 - {feature.title} 236 - </h3> 237 - <p className="text-muted-foreground leading-relaxed"> 238 - {feature.description} 239 - </p> 240 - </Card> 241 - ))} 242 - </div> 243 - </section> 244 - 245 - {/* How It Works */} 246 - <section 247 - id="how-it-works" 248 - className="container mx-auto px-4 py-20 bg-muted/30" 249 - > 250 - <div className="max-w-4xl mx-auto"> 251 - <h2 className="text-4xl md:text-5xl font-bold text-center mb-16 text-balance"> 252 - Deploy in three steps 253 - </h2> 254 - 255 - <div className="space-y-12"> 230 + <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 256 231 {[ 257 232 { 258 - step: '01', 259 - title: 'Upload your site', 233 + icon: Shield, 234 + title: 'You Own Your Content', 260 235 description: 261 - 'Link your Git repository or upload a folder containing your static site directly.' 236 + 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' 262 237 }, 263 238 { 264 - step: '02', 265 - title: 'Name and set domain', 239 + icon: Zap, 240 + title: 'CDN Performance', 266 241 description: 267 - 'Name your site and set domain routing to it. You can bring your own domain too.' 242 + 'We cache and serve your site from edge locations worldwide for fast load times.' 268 243 }, 269 244 { 270 - step: '03', 271 - title: 'Deploy to AT Protocol', 245 + icon: Lock, 246 + title: 'No Vendor Lock-in', 272 247 description: 273 - 'Your site is published to the decentralized network with a permanent, verifiable identity.' 248 + 'Your data stays in your account. Switch providers or self-host whenever you want.' 249 + }, 250 + { 251 + icon: Code, 252 + title: 'Simple Deployment', 253 + description: 254 + 'Upload your static files and we handle the rest. No complex configuration needed.' 255 + }, 256 + { 257 + icon: Server, 258 + title: 'AT Protocol Native', 259 + description: 260 + 'Built for the decentralized web. Your site has a verifiable identity on the network.' 261 + }, 262 + { 263 + icon: Globe, 264 + title: 'Custom Domains', 265 + description: 266 + 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' 274 267 } 275 - ].map((step, i) => ( 276 - <div key={i} className="flex gap-6 items-start"> 277 - <div className="text-6xl font-bold text-accent/20 min-w-[80px]"> 278 - {step.step} 279 - </div> 280 - <div className="flex-1 pt-2"> 281 - <h3 className="text-2xl font-semibold mb-3"> 282 - {step.title} 283 - </h3> 284 - <p className="text-lg text-muted-foreground leading-relaxed"> 285 - {step.description} 286 - </p> 268 + ].map((feature, i) => ( 269 + <Card 270 + key={i} 271 + className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 272 + > 273 + <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 274 + <feature.icon className="w-6 h-6 text-accent" /> 287 275 </div> 288 - </div> 276 + <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 277 + {feature.title} 278 + </h3> 279 + <p className="text-muted-foreground leading-relaxed"> 280 + {feature.description} 281 + </p> 282 + </Card> 289 283 ))} 290 284 </div> 291 - </div> 292 - </section> 285 + </section> 293 286 294 - {/* Footer */} 295 - <footer className="border-t border-border/40 bg-muted/20"> 296 - <div className="container mx-auto px-4 py-8"> 297 - <div className="text-center text-sm text-muted-foreground"> 298 - <p> 299 - Built by{' '} 300 - <a 301 - href="https://bsky.app/profile/nekomimi.pet" 302 - target="_blank" 303 - rel="noopener noreferrer" 304 - className="text-accent hover:text-accent/80 transition-colors font-medium" 305 - > 306 - @nekomimi.pet 307 - </a> 287 + {/* CTA Section */} 288 + <section className="container mx-auto px-4 py-20"> 289 + <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 290 + <h2 className="text-3xl md:text-4xl font-bold mb-4"> 291 + Ready to deploy? 292 + </h2> 293 + <p className="text-xl text-muted-foreground mb-8"> 294 + Host your static site on your own AT Protocol 295 + account today 308 296 </p> 297 + <Button 298 + size="lg" 299 + className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6" 300 + onClick={() => setShowForm(true)} 301 + > 302 + Get Started 303 + <ArrowRight className="ml-2 w-5 h-5" /> 304 + </Button> 309 305 </div> 310 - </div> 311 - </footer> 312 - </div> 306 + </section> 307 + 308 + {/* Footer */} 309 + <footer className="border-t border-border/40 bg-muted/20"> 310 + <div className="container mx-auto px-4 py-8"> 311 + <div className="text-center text-sm text-muted-foreground"> 312 + <p> 313 + Built by{' '} 314 + <a 315 + href="https://bsky.app/profile/nekomimi.pet" 316 + target="_blank" 317 + rel="noopener noreferrer" 318 + className="text-accent hover:text-accent/80 transition-colors font-medium" 319 + > 320 + @nekomimi.pet 321 + </a> 322 + </p> 323 + </div> 324 + </div> 325 + </footer> 326 + </div> 327 + </> 313 328 ) 314 329 } 315 330
+24
public/layouts/index.tsx
··· 1 1 import type { PropsWithChildren } from 'react' 2 + import { useEffect } from 'react' 2 3 3 4 import { QueryClientProvider, QueryClient } from '@tanstack/react-query' 4 5 import clsx from 'clsx' ··· 12 13 } 13 14 14 15 export default function Layout({ children, className }: LayoutProps) { 16 + useEffect(() => { 17 + // Function to update dark mode based on system preference 18 + const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => { 19 + if (e.matches) { 20 + document.documentElement.classList.add('dark') 21 + } else { 22 + document.documentElement.classList.remove('dark') 23 + } 24 + } 25 + 26 + // Create media query 27 + const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 28 + 29 + // Set initial value 30 + updateDarkMode(darkModeQuery) 31 + 32 + // Listen for changes 33 + darkModeQuery.addEventListener('change', updateDarkMode) 34 + 35 + // Cleanup 36 + return () => darkModeQuery.removeEventListener('change', updateDarkMode) 37 + }, []) 38 + 15 39 return ( 16 40 <QueryClientProvider client={client}> 17 41 <div
+68 -13
public/onboarding/onboarding.tsx
··· 10 10 } from '@public/components/ui/card' 11 11 import { Input } from '@public/components/ui/input' 12 12 import { Label } from '@public/components/ui/label' 13 - import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react' 13 + import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react' 14 14 import Layout from '@public/layouts' 15 15 16 16 type OnboardingStep = 'domain' | 'upload' | 'complete' ··· 28 28 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 29 29 const [isUploading, setIsUploading] = useState(false) 30 30 const [uploadProgress, setUploadProgress] = useState('') 31 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 32 + const [uploadedCount, setUploadedCount] = useState(0) 31 33 32 34 // Check domain availability as user types 33 35 useEffect(() => { ··· 73 75 setClaimedDomain(data.domain) 74 76 setStep('upload') 75 77 } else { 76 - alert('Failed to claim domain. Please try again.') 78 + throw new Error(data.error || 'Failed to claim domain') 77 79 } 78 80 } catch (err) { 79 81 console.error('Error claiming domain:', err) 80 - alert('Failed to claim domain. Please try again.') 82 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 83 + 84 + // Handle "Already claimed" error - redirect to editor 85 + if (errorMessage.includes('Already claimed')) { 86 + alert('You have already claimed a wisp.place subdomain. Redirecting to editor...') 87 + window.location.href = '/editor' 88 + } else { 89 + alert(`Failed to claim domain: ${errorMessage}`) 90 + } 81 91 } finally { 82 92 setIsClaimingDomain(false) 83 93 } ··· 117 127 const data = await response.json() 118 128 if (data.success) { 119 129 setUploadProgress('Upload complete!') 120 - // Redirect to the claimed domain 121 - setTimeout(() => { 122 - window.location.href = `https://${claimedDomain}` 123 - }, 1500) 130 + setSkippedFiles(data.skippedFiles || []) 131 + setUploadedCount(data.uploadedCount || data.fileCount || 0) 132 + 133 + // If there are skipped files, show them briefly before redirecting 134 + if (data.skippedFiles && data.skippedFiles.length > 0) { 135 + setTimeout(() => { 136 + window.location.href = `https://${claimedDomain}` 137 + }, 3000) // Give more time to see skipped files 138 + } else { 139 + setTimeout(() => { 140 + window.location.href = `https://${claimedDomain}` 141 + }, 1500) 142 + } 124 143 } else { 125 144 throw new Error(data.error || 'Upload failed') 126 145 } ··· 355 374 <p className="text-xs text-muted-foreground"> 356 375 Supported: HTML, CSS, JS, images, fonts, and more 357 376 </p> 377 + <p className="text-xs text-muted-foreground"> 378 + Limits: 100MB per file, 300MB total 379 + </p> 358 380 </div> 359 381 360 382 {uploadProgress && ( 361 - <div className="p-4 bg-muted rounded-lg"> 362 - <div className="flex items-center gap-2"> 363 - <Loader2 className="w-4 h-4 animate-spin" /> 364 - <span className="text-sm"> 365 - {uploadProgress} 366 - </span> 383 + <div className="space-y-3"> 384 + <div className="p-4 bg-muted rounded-lg"> 385 + <div className="flex items-center gap-2"> 386 + <Loader2 className="w-4 h-4 animate-spin" /> 387 + <span className="text-sm"> 388 + {uploadProgress} 389 + </span> 390 + </div> 367 391 </div> 392 + 393 + {skippedFiles.length > 0 && ( 394 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 395 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 396 + <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 397 + <div className="flex-1"> 398 + <span className="font-medium"> 399 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 400 + </span> 401 + {uploadedCount > 0 && ( 402 + <span className="text-sm ml-2"> 403 + ({uploadedCount} uploaded successfully) 404 + </span> 405 + )} 406 + </div> 407 + </div> 408 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 409 + {skippedFiles.slice(0, 5).map((file, idx) => ( 410 + <div key={idx} className="text-xs"> 411 + <span className="font-mono">{file.name}</span> 412 + <span className="text-muted-foreground"> - {file.reason}</span> 413 + </div> 414 + ))} 415 + {skippedFiles.length > 5 && ( 416 + <div className="text-xs text-muted-foreground"> 417 + ...and {skippedFiles.length - 5} more 418 + </div> 419 + )} 420 + </div> 421 + </div> 422 + )} 368 423 </div> 369 424 )} 370 425
+98 -58
public/styles/global.css
··· 1 1 @import "tailwindcss"; 2 2 @import "tw-animate-css"; 3 3 4 - @custom-variant dark (&:is(.dark *)); 4 + @custom-variant dark (@media (prefers-color-scheme: dark)); 5 5 6 6 :root { 7 - /* #F2E7C9 - parchment background */ 8 - --background: oklch(0.93 0.03 85); 9 - /* #413C58 - violet for text */ 10 - --foreground: oklch(0.32 0.04 285); 7 + color-scheme: light; 11 8 12 - --card: oklch(0.98 0.01 85); 13 - --card-foreground: oklch(0.32 0.04 285); 9 + /* Warm beige background inspired by Sunset design #E9DDD8 */ 10 + --background: oklch(0.90 0.012 35); 11 + /* Very dark brown text for strong contrast #2A2420 */ 12 + --foreground: oklch(0.18 0.01 30); 14 13 15 - --popover: oklch(0.98 0.01 85); 16 - --popover-foreground: oklch(0.32 0.04 285); 14 + /* Slightly lighter card background */ 15 + --card: oklch(0.93 0.01 35); 16 + --card-foreground: oklch(0.18 0.01 30); 17 + 18 + --popover: oklch(0.93 0.01 35); 19 + --popover-foreground: oklch(0.18 0.01 30); 17 20 18 - /* #413C58 - violet primary */ 19 - --primary: oklch(0.32 0.04 285); 20 - --primary-foreground: oklch(0.98 0.01 85); 21 + /* Dark brown primary inspired by #645343 */ 22 + --primary: oklch(0.35 0.02 35); 23 + --primary-foreground: oklch(0.95 0.01 35); 21 24 22 - /* #FFAAD2 - pink accent */ 25 + /* Bright pink accent for links #FFAAD2 */ 23 26 --accent: oklch(0.78 0.15 345); 24 - --accent-foreground: oklch(0.32 0.04 285); 27 + --accent-foreground: oklch(0.18 0.01 30); 25 28 26 - /* #348AA7 - blue secondary */ 27 - --secondary: oklch(0.56 0.08 220); 28 - --secondary-foreground: oklch(0.98 0.01 85); 29 + /* Medium taupe secondary inspired by #867D76 */ 30 + --secondary: oklch(0.52 0.015 30); 31 + --secondary-foreground: oklch(0.95 0.01 35); 29 32 30 - /* #CCD7C5 - ash muted */ 31 - --muted: oklch(0.85 0.02 130); 32 - --muted-foreground: oklch(0.45 0.03 285); 33 + /* Light warm muted background */ 34 + --muted: oklch(0.88 0.01 35); 35 + --muted-foreground: oklch(0.42 0.015 30); 33 36 34 - --border: oklch(0.75 0.02 285); 35 - --input: oklch(0.75 0.02 285); 36 - --ring: oklch(0.78 0.15 345); 37 + --border: oklch(0.75 0.015 30); 38 + --input: oklch(0.92 0.01 35); 39 + --ring: oklch(0.72 0.08 15); 37 40 38 41 --destructive: oklch(0.577 0.245 27.325); 39 42 --destructive-foreground: oklch(0.985 0 0); ··· 56 59 } 57 60 58 61 .dark { 59 - /* #413C58 - violet background for dark mode */ 60 - --background: oklch(0.28 0.04 285); 61 - /* #F2E7C9 - parchment text */ 62 - --foreground: oklch(0.93 0.03 85); 62 + color-scheme: dark; 63 63 64 - --card: oklch(0.32 0.04 285); 65 - --card-foreground: oklch(0.93 0.03 85); 64 + /* Slate violet background - #2C2C2C with violet tint */ 65 + --background: oklch(0.23 0.015 285); 66 + /* Light gray text - #E4E4E4 */ 67 + --foreground: oklch(0.90 0.005 285); 66 68 67 - --popover: oklch(0.32 0.04 285); 68 - --popover-foreground: oklch(0.93 0.03 85); 69 + /* Slightly lighter slate for cards */ 70 + --card: oklch(0.28 0.015 285); 71 + --card-foreground: oklch(0.90 0.005 285); 69 72 70 - /* #FFAAD2 - pink primary in dark mode */ 71 - --primary: oklch(0.78 0.15 345); 72 - --primary-foreground: oklch(0.32 0.04 285); 73 + --popover: oklch(0.28 0.015 285); 74 + --popover-foreground: oklch(0.90 0.005 285); 73 75 74 - --accent: oklch(0.78 0.15 345); 75 - --accent-foreground: oklch(0.32 0.04 285); 76 + /* Lavender buttons - #B39CD0 */ 77 + --primary: oklch(0.70 0.10 295); 78 + --primary-foreground: oklch(0.23 0.015 285); 76 79 77 - --secondary: oklch(0.56 0.08 220); 78 - --secondary-foreground: oklch(0.93 0.03 85); 80 + /* Soft pink accent - #FFC1CC */ 81 + --accent: oklch(0.85 0.08 5); 82 + --accent-foreground: oklch(0.23 0.015 285); 79 83 80 - --muted: oklch(0.38 0.03 285); 81 - --muted-foreground: oklch(0.75 0.02 85); 84 + /* Light cyan secondary - #A8DADC */ 85 + --secondary: oklch(0.82 0.05 200); 86 + --secondary-foreground: oklch(0.23 0.015 285); 82 87 83 - --border: oklch(0.42 0.03 285); 84 - --input: oklch(0.42 0.03 285); 85 - --ring: oklch(0.78 0.15 345); 88 + /* Muted slate areas */ 89 + --muted: oklch(0.33 0.015 285); 90 + --muted-foreground: oklch(0.72 0.01 285); 86 91 87 - --destructive: oklch(0.577 0.245 27.325); 88 - --destructive-foreground: oklch(0.985 0 0); 92 + /* Subtle borders */ 93 + --border: oklch(0.38 0.02 285); 94 + --input: oklch(0.30 0.015 285); 95 + --ring: oklch(0.70 0.10 295); 96 + 97 + /* Warm destructive color */ 98 + --destructive: oklch(0.60 0.22 27); 99 + --destructive-foreground: oklch(0.98 0.01 85); 89 100 90 - --chart-1: oklch(0.78 0.15 345); 91 - --chart-2: oklch(0.93 0.03 85); 92 - --chart-3: oklch(0.56 0.08 220); 93 - --chart-4: oklch(0.85 0.02 130); 94 - --chart-5: oklch(0.32 0.04 285); 95 - --sidebar: oklch(0.205 0 0); 96 - --sidebar-foreground: oklch(0.985 0 0); 97 - --sidebar-primary: oklch(0.488 0.243 264.376); 98 - --sidebar-primary-foreground: oklch(0.985 0 0); 99 - --sidebar-accent: oklch(0.269 0 0); 100 - --sidebar-accent-foreground: oklch(0.985 0 0); 101 - --sidebar-border: oklch(0.269 0 0); 102 - --sidebar-ring: oklch(0.439 0 0); 101 + /* Chart colors using the accent palette */ 102 + --chart-1: oklch(0.85 0.08 5); 103 + --chart-2: oklch(0.82 0.05 200); 104 + --chart-3: oklch(0.70 0.10 295); 105 + --chart-4: oklch(0.75 0.08 340); 106 + --chart-5: oklch(0.65 0.08 180); 107 + 108 + /* Sidebar slate */ 109 + --sidebar: oklch(0.20 0.015 285); 110 + --sidebar-foreground: oklch(0.90 0.005 285); 111 + --sidebar-primary: oklch(0.70 0.10 295); 112 + --sidebar-primary-foreground: oklch(0.20 0.015 285); 113 + --sidebar-accent: oklch(0.28 0.015 285); 114 + --sidebar-accent-foreground: oklch(0.90 0.005 285); 115 + --sidebar-border: oklch(0.32 0.02 285); 116 + --sidebar-ring: oklch(0.70 0.10 295); 103 117 } 104 118 105 119 @theme inline { ··· 150 164 @apply bg-background text-foreground; 151 165 } 152 166 } 167 + 168 + @keyframes arrow-bounce { 169 + 0%, 100% { 170 + transform: translateX(0); 171 + } 172 + 50% { 173 + transform: translateX(4px); 174 + } 175 + } 176 + 177 + .arrow-animate { 178 + animation: arrow-bounce 1.5s ease-in-out infinite; 179 + } 180 + 181 + /* Shiki syntax highlighting styles */ 182 + .shiki-wrapper { 183 + border-radius: 0.5rem; 184 + padding: 1rem; 185 + overflow-x: auto; 186 + border: 1px solid hsl(var(--border)); 187 + } 188 + 189 + .shiki-wrapper pre { 190 + margin: 0 !important; 191 + padding: 0 !important; 192 + }
+46
scripts/change-admin-password.ts
··· 1 + // Change admin password 2 + import { adminAuth } from './src/lib/admin-auth' 3 + import { db } from './src/lib/db' 4 + import { randomBytes, createHash } from 'crypto' 5 + 6 + // Get username and new password from command line 7 + const username = process.argv[2] 8 + const newPassword = process.argv[3] 9 + 10 + if (!username || !newPassword) { 11 + console.error('Usage: bun run change-admin-password.ts <username> <new-password>') 12 + process.exit(1) 13 + } 14 + 15 + if (newPassword.length < 8) { 16 + console.error('Password must be at least 8 characters') 17 + process.exit(1) 18 + } 19 + 20 + // Hash password 21 + function hashPassword(password: string, salt: string): string { 22 + return createHash('sha256').update(password + salt).digest('hex') 23 + } 24 + 25 + function generateSalt(): string { 26 + return randomBytes(32).toString('hex') 27 + } 28 + 29 + // Initialize 30 + await adminAuth.init() 31 + 32 + // Check if user exists 33 + const result = await db`SELECT username FROM admin_users WHERE username = ${username}` 34 + if (result.length === 0) { 35 + console.error(`Admin user '${username}' not found`) 36 + process.exit(1) 37 + } 38 + 39 + // Update password 40 + const salt = generateSalt() 41 + const passwordHash = hashPassword(newPassword, salt) 42 + 43 + await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}` 44 + 45 + console.log(`โœ“ Password updated for admin user '${username}'`) 46 + process.exit(0)
+31
scripts/create-admin.ts
··· 1 + // Quick script to create admin user with randomly generated password 2 + import { adminAuth } from './src/lib/admin-auth' 3 + import { randomBytes } from 'crypto' 4 + 5 + // Generate a secure random password 6 + function generatePassword(length: number = 20): string { 7 + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' 8 + const bytes = randomBytes(length) 9 + let password = '' 10 + for (let i = 0; i < length; i++) { 11 + password += chars[bytes[i] % chars.length] 12 + } 13 + return password 14 + } 15 + 16 + const username = 'admin' 17 + const password = generatePassword(20) 18 + 19 + await adminAuth.init() 20 + await adminAuth.createAdmin(username, password) 21 + 22 + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—') 23 + console.log('โ•‘ ADMIN USER CREATED SUCCESSFULLY โ•‘') 24 + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n') 25 + console.log(`Username: ${username}`) 26 + console.log(`Password: ${password}`) 27 + console.log('\nโš ๏ธ IMPORTANT: Save this password securely!') 28 + console.log('This password will not be shown again.\n') 29 + console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') 30 + 31 + process.exit(0)
+119 -18
src/index.ts
··· 1 1 import { Elysia } from 'elysia' 2 + import type { Context } from 'elysia' 2 3 import { cors } from '@elysiajs/cors' 3 4 import { staticPlugin } from '@elysiajs/static' 4 - import { openapi, fromTypes } from '@elysiajs/openapi' 5 5 6 6 import type { Config } from './lib/types' 7 7 import { BASE_HOST } from './lib/constants' 8 8 import { 9 9 createClientMetadata, 10 10 getOAuthClient, 11 - getCurrentKeys 11 + getCurrentKeys, 12 + cleanupExpiredSessions, 13 + rotateKeysIfNeeded 12 14 } from './lib/oauth-client' 13 15 import { authRoutes } from './routes/auth' 14 16 import { wispRoutes } from './routes/wisp' 15 17 import { domainRoutes } from './routes/domain' 16 18 import { userRoutes } from './routes/user' 19 + import { siteRoutes } from './routes/site' 20 + import { csrfProtection } from './lib/csrf' 21 + import { DNSVerificationWorker } from './lib/dns-verification-worker' 22 + import { logger, logCollector, observabilityMiddleware } from './lib/observability' 23 + import { promptAdminSetup } from './lib/admin-auth' 24 + import { adminRoutes } from './routes/admin' 17 25 18 26 const config: Config = { 19 - domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, 27 + domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'], 20 28 clientName: Bun.env.CLIENT_NAME ?? 'PDS-View' 21 29 } 22 30 31 + // Initialize admin setup (prompt if no admin exists) 32 + await promptAdminSetup() 33 + 23 34 const client = await getOAuthClient(config) 24 35 25 - export const app = new Elysia() 26 - .use( 27 - openapi({ 28 - references: fromTypes() 29 - }) 30 - ) 36 + // Periodic maintenance: cleanup expired sessions and rotate keys 37 + // Run every hour 38 + const runMaintenance = async () => { 39 + console.log('[Maintenance] Running periodic maintenance...') 40 + await cleanupExpiredSessions() 41 + await rotateKeysIfNeeded() 42 + } 43 + 44 + // Run maintenance on startup 45 + runMaintenance() 46 + 47 + // Schedule maintenance to run every hour 48 + setInterval(runMaintenance, 60 * 60 * 1000) 49 + 50 + // Start DNS verification worker (runs every 10 minutes) 51 + const dnsVerifier = new DNSVerificationWorker( 52 + 10 * 60 * 1000, // 10 minutes 53 + (msg, data) => { 54 + logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined) 55 + } 56 + ) 57 + 58 + dnsVerifier.start() 59 + logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 60 + 61 + export const app = new Elysia({ 62 + serve: { 63 + maxPayloadLength: 1024 * 1024 * 128 * 3, 64 + development: Bun.env.NODE_ENV !== 'production' ? true : false, 65 + id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 66 + } 67 + }) 68 + // Observability middleware 69 + .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 70 + .onAfterHandle((ctx: Context) => { 71 + observabilityMiddleware('main-app').afterHandle(ctx) 72 + // Security headers middleware 73 + const { set } = ctx 74 + // Prevent clickjacking attacks 75 + set.headers['X-Frame-Options'] = 'DENY' 76 + // Prevent MIME type sniffing 77 + set.headers['X-Content-Type-Options'] = 'nosniff' 78 + // Strict Transport Security (HSTS) - enforce HTTPS 79 + set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' 80 + // Referrer policy - limit referrer information 81 + set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' 82 + // Content Security Policy 83 + set.headers['Content-Security-Policy'] = 84 + "default-src 'self'; " + 85 + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + 86 + "style-src 'self' 'unsafe-inline'; " + 87 + "img-src 'self' data: https:; " + 88 + "font-src 'self' data:; " + 89 + "connect-src 'self' https:; " + 90 + "frame-ancestors 'none'; " + 91 + "base-uri 'self'; " + 92 + "form-action 'self'" 93 + // Additional security headers 94 + set.headers['X-XSS-Protection'] = '1; mode=block' 95 + set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 96 + }) 97 + .onError(observabilityMiddleware('main-app').onError) 98 + .use(csrfProtection()) 99 + .use(authRoutes(client)) 100 + .use(wispRoutes(client)) 101 + .use(domainRoutes(client)) 102 + .use(userRoutes(client)) 103 + .use(siteRoutes(client)) 104 + .use(adminRoutes()) 31 105 .use( 32 106 await staticPlugin({ 33 107 prefix: '/' 34 108 }) 35 109 ) 36 - .use(authRoutes(client)) 37 - .use(wispRoutes(client)) 38 - .use(domainRoutes(client)) 39 - .use(userRoutes(client)) 40 - .get('/client-metadata.json', (c) => { 110 + .get('/client-metadata.json', () => { 41 111 return createClientMetadata(config) 42 112 }) 43 - .get('/jwks.json', (c) => { 44 - const keys = getCurrentKeys() 113 + .get('/jwks.json', async () => { 114 + const keys = await getCurrentKeys() 45 115 if (!keys.length) return { keys: [] } 46 116 47 117 return { ··· 52 122 }) 53 123 } 54 124 }) 125 + .get('/api/health', () => { 126 + const dnsVerifierHealth = dnsVerifier.getHealth() 127 + return { 128 + status: 'ok', 129 + timestamp: new Date().toISOString(), 130 + dnsVerifier: dnsVerifierHealth 131 + } 132 + }) 133 + .get('/api/admin/test', () => { 134 + return { message: 'Admin routes test works!' } 135 + }) 136 + .post('/api/admin/verify-dns', async () => { 137 + try { 138 + await dnsVerifier.trigger() 139 + return { 140 + success: true, 141 + message: 'DNS verification triggered' 142 + } 143 + } catch (error) { 144 + return { 145 + success: false, 146 + error: error instanceof Error ? error.message : String(error) 147 + } 148 + } 149 + }) 150 + .get('/.well-known/atproto-did', ({ set }) => { 151 + // Return plain text DID for AT Protocol domain verification 152 + set.headers['Content-Type'] = 'text/plain' 153 + return 'did:plc:7puq73yz2hkvbcpdhnsze2qw' 154 + }) 55 155 .use(cors({ 56 156 origin: config.domain, 57 157 credentials: true, 58 - methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 59 - allowedHeaders: ['Content-Type', 'Authorization'], 158 + methods: ['GET', 'POST', 'DELETE', 'PUT', 'PATCH', 'OPTIONS'], 159 + allowedHeaders: ['Content-Type', 'Authorization', 'Origin', 'X-Forwarded-Host'], 160 + exposeHeaders: ['Content-Type'], 60 161 maxAge: 86400 // 24 hours 61 162 })) 62 163 .listen(8000)
-44
src/lexicon/index.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { 5 - type Auth, 6 - type Options as XrpcOptions, 7 - Server as XrpcServer, 8 - type StreamConfigOrHandler, 9 - type MethodConfigOrHandler, 10 - createServer as createXrpcServer, 11 - } from '@atproto/xrpc-server' 12 - import { schemas } from './lexicons.js' 13 - 14 - export function createServer(options?: XrpcOptions): Server { 15 - return new Server(options) 16 - } 17 - 18 - export class Server { 19 - xrpc: XrpcServer 20 - place: PlaceNS 21 - 22 - constructor(options?: XrpcOptions) { 23 - this.xrpc = createXrpcServer(schemas, options) 24 - this.place = new PlaceNS(this) 25 - } 26 - } 27 - 28 - export class PlaceNS { 29 - _server: Server 30 - wisp: PlaceWispNS 31 - 32 - constructor(server: Server) { 33 - this._server = server 34 - this.wisp = new PlaceWispNS(server) 35 - } 36 - } 37 - 38 - export class PlaceWispNS { 39 - _server: Server 40 - 41 - constructor(server: Server) { 42 - this._server = server 43 - } 44 - }
-127
src/lexicon/lexicons.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { 5 - type LexiconDoc, 6 - Lexicons, 7 - ValidationError, 8 - type ValidationResult, 9 - } from '@atproto/lexicon' 10 - import { type $Typed, is$typed, maybe$typed } from './util.js' 11 - 12 - export const schemaDict = { 13 - PlaceWispFs: { 14 - lexicon: 1, 15 - id: 'place.wisp.fs', 16 - defs: { 17 - main: { 18 - type: 'record', 19 - description: 'Virtual filesystem manifest for a Wisp site', 20 - record: { 21 - type: 'object', 22 - required: ['site', 'root', 'createdAt'], 23 - properties: { 24 - site: { 25 - type: 'string', 26 - }, 27 - root: { 28 - type: 'ref', 29 - ref: 'lex:place.wisp.fs#directory', 30 - }, 31 - fileCount: { 32 - type: 'integer', 33 - minimum: 0, 34 - maximum: 1000, 35 - }, 36 - createdAt: { 37 - type: 'string', 38 - format: 'datetime', 39 - }, 40 - }, 41 - }, 42 - }, 43 - file: { 44 - type: 'object', 45 - required: ['type', 'blob'], 46 - properties: { 47 - type: { 48 - type: 'string', 49 - const: 'file', 50 - }, 51 - blob: { 52 - type: 'blob', 53 - accept: ['*/*'], 54 - maxSize: 1000000, 55 - description: 'Content blob ref', 56 - }, 57 - }, 58 - }, 59 - directory: { 60 - type: 'object', 61 - required: ['type', 'entries'], 62 - properties: { 63 - type: { 64 - type: 'string', 65 - const: 'directory', 66 - }, 67 - entries: { 68 - type: 'array', 69 - maxLength: 500, 70 - items: { 71 - type: 'ref', 72 - ref: 'lex:place.wisp.fs#entry', 73 - }, 74 - }, 75 - }, 76 - }, 77 - entry: { 78 - type: 'object', 79 - required: ['name', 'node'], 80 - properties: { 81 - name: { 82 - type: 'string', 83 - maxLength: 255, 84 - }, 85 - node: { 86 - type: 'union', 87 - refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 88 - }, 89 - }, 90 - }, 91 - }, 92 - }, 93 - } as const satisfies Record<string, LexiconDoc> 94 - export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 95 - export const lexicons: Lexicons = new Lexicons(schemas) 96 - 97 - export function validate<T extends { $type: string }>( 98 - v: unknown, 99 - id: string, 100 - hash: string, 101 - requiredType: true, 102 - ): ValidationResult<T> 103 - export function validate<T extends { $type?: string }>( 104 - v: unknown, 105 - id: string, 106 - hash: string, 107 - requiredType?: false, 108 - ): ValidationResult<T> 109 - export function validate( 110 - v: unknown, 111 - id: string, 112 - hash: string, 113 - requiredType?: boolean, 114 - ): ValidationResult { 115 - return (requiredType ? is$typed : maybe$typed)(v, id, hash) 116 - ? lexicons.validate(`${id}#${hash}`, v) 117 - : { 118 - success: false, 119 - error: new ValidationError( 120 - `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 121 - ), 122 - } 123 - } 124 - 125 - export const ids = { 126 - PlaceWispFs: 'place.wisp.fs', 127 - } as const
-79
src/lexicon/types/place/wisp/fs.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 - import { CID } from 'multiformats/cid' 6 - import { validate as _validate } from '../../../lexicons' 7 - import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 - 9 - const is$typed = _is$typed, 10 - validate = _validate 11 - const id = 'place.wisp.fs' 12 - 13 - export interface Record { 14 - $type: 'place.wisp.fs' 15 - site: string 16 - root: Directory 17 - fileCount?: number 18 - createdAt: string 19 - [k: string]: unknown 20 - } 21 - 22 - const hashRecord = 'main' 23 - 24 - export function isRecord<V>(v: V) { 25 - return is$typed(v, id, hashRecord) 26 - } 27 - 28 - export function validateRecord<V>(v: V) { 29 - return validate<Record & V>(v, id, hashRecord, true) 30 - } 31 - 32 - export interface File { 33 - $type?: 'place.wisp.fs#file' 34 - type: 'file' 35 - /** Content blob ref */ 36 - blob: BlobRef 37 - } 38 - 39 - const hashFile = 'file' 40 - 41 - export function isFile<V>(v: V) { 42 - return is$typed(v, id, hashFile) 43 - } 44 - 45 - export function validateFile<V>(v: V) { 46 - return validate<File & V>(v, id, hashFile) 47 - } 48 - 49 - export interface Directory { 50 - $type?: 'place.wisp.fs#directory' 51 - type: 'directory' 52 - entries: Entry[] 53 - } 54 - 55 - const hashDirectory = 'directory' 56 - 57 - export function isDirectory<V>(v: V) { 58 - return is$typed(v, id, hashDirectory) 59 - } 60 - 61 - export function validateDirectory<V>(v: V) { 62 - return validate<Directory & V>(v, id, hashDirectory) 63 - } 64 - 65 - export interface Entry { 66 - $type?: 'place.wisp.fs#entry' 67 - name: string 68 - node: $Typed<File> | $Typed<Directory> | { $type: string } 69 - } 70 - 71 - const hashEntry = 'entry' 72 - 73 - export function isEntry<V>(v: V) { 74 - return is$typed(v, id, hashEntry) 75 - } 76 - 77 - export function validateEntry<V>(v: V) { 78 - return validate<Entry & V>(v, id, hashEntry) 79 - }
-82
src/lexicon/util.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - 5 - import { type ValidationResult } from '@atproto/lexicon' 6 - 7 - export type OmitKey<T, K extends keyof T> = { 8 - [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 - } 10 - 11 - export type $Typed<V, T extends string = string> = V & { $type: T } 12 - export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 - 14 - export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 - ? Id 16 - : `${Id}#${Hash}` 17 - 18 - function isObject<V>(v: V): v is V & object { 19 - return v != null && typeof v === 'object' 20 - } 21 - 22 - function is$type<Id extends string, Hash extends string>( 23 - $type: unknown, 24 - id: Id, 25 - hash: Hash, 26 - ): $type is $Type<Id, Hash> { 27 - return hash === 'main' 28 - ? $type === id 29 - : // $type === `${id}#${hash}` 30 - typeof $type === 'string' && 31 - $type.length === id.length + 1 + hash.length && 32 - $type.charCodeAt(id.length) === 35 /* '#' */ && 33 - $type.startsWith(id) && 34 - $type.endsWith(hash) 35 - } 36 - 37 - export type $TypedObject< 38 - V, 39 - Id extends string, 40 - Hash extends string, 41 - > = V extends { 42 - $type: $Type<Id, Hash> 43 - } 44 - ? V 45 - : V extends { $type?: string } 46 - ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 - ? V & { $type: T } 48 - : never 49 - : V & { $type: $Type<Id, Hash> } 50 - 51 - export function is$typed<V, Id extends string, Hash extends string>( 52 - v: V, 53 - id: Id, 54 - hash: Hash, 55 - ): v is $TypedObject<V, Id, Hash> { 56 - return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 - } 58 - 59 - export function maybe$typed<V, Id extends string, Hash extends string>( 60 - v: V, 61 - id: Id, 62 - hash: Hash, 63 - ): v is V & object & { $type?: $Type<Id, Hash> } { 64 - return ( 65 - isObject(v) && 66 - ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 - ) 68 - } 69 - 70 - export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 - export type ValidatorParam<V extends Validator> = 72 - V extends Validator<infer R> ? R : never 73 - 74 - /** 75 - * Utility function that allows to convert a "validate*" utility function into a 76 - * type predicate. 77 - */ 78 - export function asPredicate<V extends Validator>(validate: V) { 79 - return function <T>(v: T): v is T & ValidatorParam<V> { 80 - return validate(v).success 81 - } 82 - }
+44
src/lexicons/index.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type Auth, 6 + type Options as XrpcOptions, 7 + Server as XrpcServer, 8 + type StreamConfigOrHandler, 9 + type MethodConfigOrHandler, 10 + createServer as createXrpcServer, 11 + } from '@atproto/xrpc-server' 12 + import { schemas } from './lexicons.js' 13 + 14 + export function createServer(options?: XrpcOptions): Server { 15 + return new Server(options) 16 + } 17 + 18 + export class Server { 19 + xrpc: XrpcServer 20 + place: PlaceNS 21 + 22 + constructor(options?: XrpcOptions) { 23 + this.xrpc = createXrpcServer(schemas, options) 24 + this.place = new PlaceNS(this) 25 + } 26 + } 27 + 28 + export class PlaceNS { 29 + _server: Server 30 + wisp: PlaceWispNS 31 + 32 + constructor(server: Server) { 33 + this._server = server 34 + this.wisp = new PlaceWispNS(server) 35 + } 36 + } 37 + 38 + export class PlaceWispNS { 39 + _server: Server 40 + 41 + constructor(server: Server) { 42 + this._server = server 43 + } 44 + }
+127
src/lexicons/lexicons.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { 5 + type LexiconDoc, 6 + Lexicons, 7 + ValidationError, 8 + type ValidationResult, 9 + } from '@atproto/lexicon' 10 + import { type $Typed, is$typed, maybe$typed } from './util.js' 11 + 12 + export const schemaDict = { 13 + PlaceWispFs: { 14 + lexicon: 1, 15 + id: 'place.wisp.fs', 16 + defs: { 17 + main: { 18 + type: 'record', 19 + description: 'Virtual filesystem manifest for a Wisp site', 20 + record: { 21 + type: 'object', 22 + required: ['site', 'root', 'createdAt'], 23 + properties: { 24 + site: { 25 + type: 'string', 26 + }, 27 + root: { 28 + type: 'ref', 29 + ref: 'lex:place.wisp.fs#directory', 30 + }, 31 + fileCount: { 32 + type: 'integer', 33 + minimum: 0, 34 + maximum: 1000, 35 + }, 36 + createdAt: { 37 + type: 'string', 38 + format: 'datetime', 39 + }, 40 + }, 41 + }, 42 + }, 43 + file: { 44 + type: 'object', 45 + required: ['type', 'blob'], 46 + properties: { 47 + type: { 48 + type: 'string', 49 + const: 'file', 50 + }, 51 + blob: { 52 + type: 'blob', 53 + accept: ['*/*'], 54 + maxSize: 1000000, 55 + description: 'Content blob ref', 56 + }, 57 + }, 58 + }, 59 + directory: { 60 + type: 'object', 61 + required: ['type', 'entries'], 62 + properties: { 63 + type: { 64 + type: 'string', 65 + const: 'directory', 66 + }, 67 + entries: { 68 + type: 'array', 69 + maxLength: 500, 70 + items: { 71 + type: 'ref', 72 + ref: 'lex:place.wisp.fs#entry', 73 + }, 74 + }, 75 + }, 76 + }, 77 + entry: { 78 + type: 'object', 79 + required: ['name', 'node'], 80 + properties: { 81 + name: { 82 + type: 'string', 83 + maxLength: 255, 84 + }, 85 + node: { 86 + type: 'union', 87 + refs: ['lex:place.wisp.fs#file', 'lex:place.wisp.fs#directory'], 88 + }, 89 + }, 90 + }, 91 + }, 92 + }, 93 + } as const satisfies Record<string, LexiconDoc> 94 + export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] 95 + export const lexicons: Lexicons = new Lexicons(schemas) 96 + 97 + export function validate<T extends { $type: string }>( 98 + v: unknown, 99 + id: string, 100 + hash: string, 101 + requiredType: true, 102 + ): ValidationResult<T> 103 + export function validate<T extends { $type?: string }>( 104 + v: unknown, 105 + id: string, 106 + hash: string, 107 + requiredType?: false, 108 + ): ValidationResult<T> 109 + export function validate( 110 + v: unknown, 111 + id: string, 112 + hash: string, 113 + requiredType?: boolean, 114 + ): ValidationResult { 115 + return (requiredType ? is$typed : maybe$typed)(v, id, hash) 116 + ? lexicons.validate(`${id}#${hash}`, v) 117 + : { 118 + success: false, 119 + error: new ValidationError( 120 + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, 121 + ), 122 + } 123 + } 124 + 125 + export const ids = { 126 + PlaceWispFs: 'place.wisp.fs', 127 + } as const
+85
src/lexicons/types/place/wisp/fs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.fs' 12 + 13 + export interface Main { 14 + $type: 'place.wisp.fs' 15 + site: string 16 + root: Directory 17 + fileCount?: number 18 + createdAt: string 19 + [k: string]: unknown 20 + } 21 + 22 + const hashMain = 'main' 23 + 24 + export function isMain<V>(v: V) { 25 + return is$typed(v, id, hashMain) 26 + } 27 + 28 + export function validateMain<V>(v: V) { 29 + return validate<Main & V>(v, id, hashMain, true) 30 + } 31 + 32 + export { 33 + type Main as Record, 34 + isMain as isRecord, 35 + validateMain as validateRecord, 36 + } 37 + 38 + export interface File { 39 + $type?: 'place.wisp.fs#file' 40 + type: 'file' 41 + /** Content blob ref */ 42 + blob: BlobRef 43 + } 44 + 45 + const hashFile = 'file' 46 + 47 + export function isFile<V>(v: V) { 48 + return is$typed(v, id, hashFile) 49 + } 50 + 51 + export function validateFile<V>(v: V) { 52 + return validate<File & V>(v, id, hashFile) 53 + } 54 + 55 + export interface Directory { 56 + $type?: 'place.wisp.fs#directory' 57 + type: 'directory' 58 + entries: Entry[] 59 + } 60 + 61 + const hashDirectory = 'directory' 62 + 63 + export function isDirectory<V>(v: V) { 64 + return is$typed(v, id, hashDirectory) 65 + } 66 + 67 + export function validateDirectory<V>(v: V) { 68 + return validate<Directory & V>(v, id, hashDirectory) 69 + } 70 + 71 + export interface Entry { 72 + $type?: 'place.wisp.fs#entry' 73 + name: string 74 + node: $Typed<File> | $Typed<Directory> | { $type: string } 75 + } 76 + 77 + const hashEntry = 'entry' 78 + 79 + export function isEntry<V>(v: V) { 80 + return is$typed(v, id, hashEntry) 81 + } 82 + 83 + export function validateEntry<V>(v: V) { 84 + return validate<Entry & V>(v, id, hashEntry) 85 + }
+82
src/lexicons/util.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + 5 + import { type ValidationResult } from '@atproto/lexicon' 6 + 7 + export type OmitKey<T, K extends keyof T> = { 8 + [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 + } 10 + 11 + export type $Typed<V, T extends string = string> = V & { $type: T } 12 + export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'> 13 + 14 + export type $Type<Id extends string, Hash extends string> = Hash extends 'main' 15 + ? Id 16 + : `${Id}#${Hash}` 17 + 18 + function isObject<V>(v: V): v is V & object { 19 + return v != null && typeof v === 'object' 20 + } 21 + 22 + function is$type<Id extends string, Hash extends string>( 23 + $type: unknown, 24 + id: Id, 25 + hash: Hash, 26 + ): $type is $Type<Id, Hash> { 27 + return hash === 'main' 28 + ? $type === id 29 + : // $type === `${id}#${hash}` 30 + typeof $type === 'string' && 31 + $type.length === id.length + 1 + hash.length && 32 + $type.charCodeAt(id.length) === 35 /* '#' */ && 33 + $type.startsWith(id) && 34 + $type.endsWith(hash) 35 + } 36 + 37 + export type $TypedObject< 38 + V, 39 + Id extends string, 40 + Hash extends string, 41 + > = V extends { 42 + $type: $Type<Id, Hash> 43 + } 44 + ? V 45 + : V extends { $type?: string } 46 + ? V extends { $type?: infer T extends $Type<Id, Hash> } 47 + ? V & { $type: T } 48 + : never 49 + : V & { $type: $Type<Id, Hash> } 50 + 51 + export function is$typed<V, Id extends string, Hash extends string>( 52 + v: V, 53 + id: Id, 54 + hash: Hash, 55 + ): v is $TypedObject<V, Id, Hash> { 56 + return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 + } 58 + 59 + export function maybe$typed<V, Id extends string, Hash extends string>( 60 + v: V, 61 + id: Id, 62 + hash: Hash, 63 + ): v is V & object & { $type?: $Type<Id, Hash> } { 64 + return ( 65 + isObject(v) && 66 + ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 + ) 68 + } 69 + 70 + export type Validator<R = unknown> = (v: unknown) => ValidationResult<R> 71 + export type ValidatorParam<V extends Validator> = 72 + V extends Validator<infer R> ? R : never 73 + 74 + /** 75 + * Utility function that allows to convert a "validate*" utility function into a 76 + * type predicate. 77 + */ 78 + export function asPredicate<V extends Validator>(validate: V) { 79 + return function <T>(v: T): v is T & ValidatorParam<V> { 80 + return validate(v).success 81 + } 82 + }
+208
src/lib/admin-auth.ts
··· 1 + // Admin authentication system 2 + import { db } from './db' 3 + import { randomBytes, createHash } from 'crypto' 4 + 5 + interface AdminUser { 6 + id: number 7 + username: string 8 + password_hash: string 9 + created_at: Date 10 + } 11 + 12 + interface AdminSession { 13 + sessionId: string 14 + username: string 15 + expiresAt: Date 16 + } 17 + 18 + // In-memory session storage 19 + const sessions = new Map<string, AdminSession>() 20 + const SESSION_DURATION = 24 * 60 * 60 * 1000 // 24 hours 21 + 22 + // Hash password using SHA-256 with salt 23 + function hashPassword(password: string, salt: string): string { 24 + return createHash('sha256').update(password + salt).digest('hex') 25 + } 26 + 27 + // Generate random salt 28 + function generateSalt(): string { 29 + return randomBytes(32).toString('hex') 30 + } 31 + 32 + // Generate session ID 33 + function generateSessionId(): string { 34 + return randomBytes(32).toString('hex') 35 + } 36 + 37 + // Generate a secure random password 38 + function generatePassword(length: number = 20): string { 39 + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' 40 + const bytes = randomBytes(length) 41 + let password = '' 42 + for (let i = 0; i < length; i++) { 43 + password += chars[bytes[i] % chars.length] 44 + } 45 + return password 46 + } 47 + 48 + export const adminAuth = { 49 + // Initialize admin table 50 + async init() { 51 + await db` 52 + CREATE TABLE IF NOT EXISTS admin_users ( 53 + id SERIAL PRIMARY KEY, 54 + username TEXT UNIQUE NOT NULL, 55 + password_hash TEXT NOT NULL, 56 + salt TEXT NOT NULL, 57 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 58 + ) 59 + ` 60 + }, 61 + 62 + // Check if any admin exists 63 + async hasAdmin(): Promise<boolean> { 64 + const result = await db`SELECT COUNT(*) as count FROM admin_users` 65 + return result[0].count > 0 66 + }, 67 + 68 + // Create admin user 69 + async createAdmin(username: string, password: string): Promise<boolean> { 70 + try { 71 + const salt = generateSalt() 72 + const passwordHash = hashPassword(password, salt) 73 + 74 + await db`INSERT INTO admin_users (username, password_hash, salt) VALUES (${username}, ${passwordHash}, ${salt})` 75 + 76 + console.log(`โœ“ Admin user '${username}' created successfully`) 77 + return true 78 + } catch (error) { 79 + console.error('Failed to create admin user:', error) 80 + return false 81 + } 82 + }, 83 + 84 + // Verify admin credentials 85 + async verify(username: string, password: string): Promise<boolean> { 86 + try { 87 + const result = await db`SELECT password_hash, salt FROM admin_users WHERE username = ${username}` 88 + 89 + if (result.length === 0) { 90 + return false 91 + } 92 + 93 + const { password_hash, salt } = result[0] 94 + const hash = hashPassword(password, salt as string) 95 + return hash === password_hash 96 + } catch (error) { 97 + console.error('Failed to verify admin:', error) 98 + return false 99 + } 100 + }, 101 + 102 + // Create session 103 + createSession(username: string): string { 104 + const sessionId = generateSessionId() 105 + const expiresAt = new Date(Date.now() + SESSION_DURATION) 106 + 107 + sessions.set(sessionId, { 108 + sessionId, 109 + username, 110 + expiresAt 111 + }) 112 + 113 + // Clean up expired sessions 114 + this.cleanupSessions() 115 + 116 + return sessionId 117 + }, 118 + 119 + // Verify session 120 + verifySession(sessionId: string): AdminSession | null { 121 + const session = sessions.get(sessionId) 122 + 123 + if (!session) { 124 + return null 125 + } 126 + 127 + if (session.expiresAt.getTime() < Date.now()) { 128 + sessions.delete(sessionId) 129 + return null 130 + } 131 + 132 + return session 133 + }, 134 + 135 + // Delete session 136 + deleteSession(sessionId: string) { 137 + sessions.delete(sessionId) 138 + }, 139 + 140 + // Cleanup expired sessions 141 + cleanupSessions() { 142 + const now = Date.now() 143 + for (const [sessionId, session] of sessions.entries()) { 144 + if (session.expiresAt.getTime() < now) { 145 + sessions.delete(sessionId) 146 + } 147 + } 148 + } 149 + } 150 + 151 + // Prompt for admin creation on startup 152 + export async function promptAdminSetup() { 153 + await adminAuth.init() 154 + 155 + const hasAdmin = await adminAuth.hasAdmin() 156 + if (hasAdmin) { 157 + return 158 + } 159 + 160 + // Skip prompt if SKIP_ADMIN_SETUP is set 161 + if (process.env.SKIP_ADMIN_SETUP === 'true') { 162 + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—') 163 + console.log('โ•‘ ADMIN SETUP REQUIRED โ•‘') 164 + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n') 165 + console.log('No admin user found.') 166 + console.log('Create one with: bun run create-admin.ts\n') 167 + return 168 + } 169 + 170 + console.log('\n===========================================') 171 + console.log(' ADMIN SETUP REQUIRED') 172 + console.log('===========================================\n') 173 + console.log('No admin user found. Creating one automatically...\n') 174 + 175 + // Auto-generate admin credentials with random password 176 + const username = 'admin' 177 + const password = generatePassword(20) 178 + 179 + await adminAuth.createAdmin(username, password) 180 + 181 + console.log('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—') 182 + console.log('โ•‘ ADMIN USER CREATED SUCCESSFULLY โ•‘') 183 + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n') 184 + console.log(`Username: ${username}`) 185 + console.log(`Password: ${password}`) 186 + console.log('\nโš ๏ธ IMPORTANT: Save this password securely!') 187 + console.log('This password will not be shown again.\n') 188 + console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') 189 + } 190 + 191 + // Elysia middleware to protect admin routes 192 + export function requireAdmin({ cookie, set }: any) { 193 + const sessionId = cookie.admin_session?.value 194 + 195 + if (!sessionId) { 196 + set.status = 401 197 + return { error: 'Unauthorized' } 198 + } 199 + 200 + const session = adminAuth.verifySession(sessionId) 201 + if (!session) { 202 + set.status = 401 203 + return { error: 'Unauthorized' } 204 + } 205 + 206 + // Session is valid, continue 207 + return 208 + }
+81
src/lib/csrf.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { verifyRequestOrigin } from './csrf' 3 + 4 + describe('verifyRequestOrigin', () => { 5 + test('should accept matching origin and host', () => { 6 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 7 + expect(verifyRequestOrigin('http://localhost:8000', ['localhost:8000'])).toBe(true) 8 + expect(verifyRequestOrigin('https://app.example.com', ['app.example.com'])).toBe(true) 9 + }) 10 + 11 + test('should accept origin matching one of multiple allowed hosts', () => { 12 + const allowedHosts = ['example.com', 'app.example.com', 'localhost:8000'] 13 + expect(verifyRequestOrigin('https://example.com', allowedHosts)).toBe(true) 14 + expect(verifyRequestOrigin('https://app.example.com', allowedHosts)).toBe(true) 15 + expect(verifyRequestOrigin('http://localhost:8000', allowedHosts)).toBe(true) 16 + }) 17 + 18 + test('should reject non-matching origin', () => { 19 + expect(verifyRequestOrigin('https://evil.com', ['example.com'])).toBe(false) 20 + expect(verifyRequestOrigin('https://fake-example.com', ['example.com'])).toBe(false) 21 + expect(verifyRequestOrigin('https://example.com.evil.com', ['example.com'])).toBe(false) 22 + }) 23 + 24 + test('should reject empty origin', () => { 25 + expect(verifyRequestOrigin('', ['example.com'])).toBe(false) 26 + }) 27 + 28 + test('should reject invalid URL format', () => { 29 + expect(verifyRequestOrigin('not-a-url', ['example.com'])).toBe(false) 30 + expect(verifyRequestOrigin('javascript:alert(1)', ['example.com'])).toBe(false) 31 + expect(verifyRequestOrigin('file:///etc/passwd', ['example.com'])).toBe(false) 32 + }) 33 + 34 + test('should handle different protocols correctly', () => { 35 + // Same host, different protocols should match (we only check host) 36 + expect(verifyRequestOrigin('http://example.com', ['example.com'])).toBe(true) 37 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 38 + }) 39 + 40 + test('should handle port numbers correctly', () => { 41 + expect(verifyRequestOrigin('http://localhost:3000', ['localhost:3000'])).toBe(true) 42 + expect(verifyRequestOrigin('http://localhost:3000', ['localhost:8000'])).toBe(false) 43 + expect(verifyRequestOrigin('http://localhost', ['localhost'])).toBe(true) 44 + }) 45 + 46 + test('should handle subdomains correctly', () => { 47 + expect(verifyRequestOrigin('https://sub.example.com', ['sub.example.com'])).toBe(true) 48 + expect(verifyRequestOrigin('https://sub.example.com', ['example.com'])).toBe(false) 49 + }) 50 + 51 + test('should handle case sensitivity (exact match required)', () => { 52 + // URL host is automatically lowercased by URL parser 53 + expect(verifyRequestOrigin('https://EXAMPLE.COM', ['example.com'])).toBe(true) 54 + expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true) 55 + // But allowed hosts are case-sensitive 56 + expect(verifyRequestOrigin('https://example.com', ['EXAMPLE.COM'])).toBe(false) 57 + }) 58 + 59 + test('should handle trailing slashes in origin', () => { 60 + expect(verifyRequestOrigin('https://example.com/', ['example.com'])).toBe(true) 61 + }) 62 + 63 + test('should handle paths in origin (host extraction)', () => { 64 + expect(verifyRequestOrigin('https://example.com/path/to/page', ['example.com'])).toBe(true) 65 + expect(verifyRequestOrigin('https://evil.com/example.com', ['example.com'])).toBe(false) 66 + }) 67 + 68 + test('should reject when allowed hosts is empty', () => { 69 + expect(verifyRequestOrigin('https://example.com', [])).toBe(false) 70 + }) 71 + 72 + test('should handle IPv4 addresses', () => { 73 + expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true) 74 + expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true) 75 + }) 76 + 77 + test('should handle IPv6 addresses', () => { 78 + expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true) 79 + expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true) 80 + }) 81 + })
+80
src/lib/csrf.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { logger } from './logger' 3 + 4 + /** 5 + * CSRF Protection using Origin/Host header verification 6 + * Based on Lucia's recommended approach for cookie-based authentication 7 + * 8 + * This validates that the Origin header matches the Host header for 9 + * state-changing requests (POST, PUT, DELETE, PATCH). 10 + */ 11 + 12 + /** 13 + * Verify that the request origin matches the expected host 14 + * @param origin - The Origin header value 15 + * @param allowedHosts - Array of allowed host values 16 + * @returns true if origin is valid, false otherwise 17 + */ 18 + export function verifyRequestOrigin(origin: string, allowedHosts: string[]): boolean { 19 + if (!origin) { 20 + return false 21 + } 22 + 23 + try { 24 + const originUrl = new URL(origin) 25 + const originHost = originUrl.host 26 + 27 + return allowedHosts.some(host => originHost === host) 28 + } catch { 29 + // Invalid URL 30 + return false 31 + } 32 + } 33 + 34 + /** 35 + * CSRF Protection Middleware for Elysia 36 + * 37 + * Validates Origin header against Host header for non-GET requests 38 + * to prevent CSRF attacks when using cookie-based authentication. 39 + * 40 + * Usage: 41 + * ```ts 42 + * import { csrfProtection } from './lib/csrf' 43 + * 44 + * new Elysia() 45 + * .use(csrfProtection()) 46 + * .post('/api/protected', handler) 47 + * ``` 48 + */ 49 + export const csrfProtection = () => { 50 + return new Elysia({ name: 'csrf-protection' }) 51 + .onBeforeHandle(({ request, set }) => { 52 + const method = request.method.toUpperCase() 53 + 54 + // Only protect state-changing methods 55 + if (['GET', 'HEAD', 'OPTIONS'].includes(method)) { 56 + return 57 + } 58 + 59 + // Get headers 60 + const originHeader = request.headers.get('Origin') 61 + // Use X-Forwarded-Host if behind a proxy, otherwise use Host 62 + const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host') 63 + 64 + // Validate origin matches host 65 + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { 66 + logger.warn('[CSRF] Request blocked', { 67 + method, 68 + origin: originHeader, 69 + host: hostHeader, 70 + path: new URL(request.url).pathname 71 + }) 72 + 73 + set.status = 403 74 + return { 75 + error: 'CSRF validation failed', 76 + message: 'Request origin does not match host' 77 + } 78 + } 79 + }) 80 + }
+211 -44
src/lib/db.ts
··· 23 23 CREATE TABLE IF NOT EXISTS oauth_sessions ( 24 24 sub TEXT PRIMARY KEY, 25 25 data TEXT NOT NULL, 26 - updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 26 + updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()), 27 + expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000 27 28 ) 28 29 `; 29 30 30 31 await db` 31 32 CREATE TABLE IF NOT EXISTS oauth_keys ( 32 33 kid TEXT PRIMARY KEY, 33 - jwk TEXT NOT NULL 34 + jwk TEXT NOT NULL, 35 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 34 36 ) 35 37 `; 36 38 ··· 44 46 ) 45 47 `; 46 48 47 - // Add rkey column if it doesn't exist (for existing databases) 49 + // Add columns if they don't exist (for existing databases) 48 50 try { 49 51 await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`; 50 52 } catch (err) { 51 53 // Column might already exist, ignore 52 54 } 53 55 56 + try { 57 + await db`ALTER TABLE oauth_sessions ADD COLUMN IF NOT EXISTS expires_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) + 2592000`; 58 + } catch (err) { 59 + // Column might already exist, ignore 60 + } 61 + 62 + try { 63 + await db`ALTER TABLE oauth_keys ADD COLUMN IF NOT EXISTS created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())`; 64 + } catch (err) { 65 + // Column might already exist, ignore 66 + } 67 + 68 + try { 69 + await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`; 70 + } catch (err) { 71 + // Column might already exist, ignore 72 + } 73 + 54 74 // Custom domains table for BYOD (bring your own domain) 55 75 await db` 56 76 CREATE TABLE IF NOT EXISTS custom_domains ( 57 77 id TEXT PRIMARY KEY, 58 78 domain TEXT UNIQUE NOT NULL, 59 79 did TEXT NOT NULL, 60 - rkey TEXT NOT NULL DEFAULT 'self', 80 + rkey TEXT, 61 81 verified BOOLEAN DEFAULT false, 62 82 last_verified_at BIGINT, 63 83 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 64 84 ) 65 85 `; 86 + 87 + // Migrate existing tables to make rkey nullable and remove default 88 + try { 89 + await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP NOT NULL`; 90 + } catch (err) { 91 + // Column might already be nullable, ignore 92 + } 93 + try { 94 + await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP DEFAULT`; 95 + } catch (err) { 96 + // Default might already be removed, ignore 97 + } 66 98 67 99 // Sites table - cache of place.wisp.fs records from PDS 68 100 await db` ··· 205 237 return rows[0]?.rkey ?? null; 206 238 }; 207 239 240 + // Session timeout configuration (30 days in seconds) 241 + const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 242 + // OAuth state timeout (1 hour in seconds) 243 + const STATE_TIMEOUT = 60 * 60; // 3600 seconds 244 + 208 245 const stateStore = { 209 246 async set(key: string, data: any) { 210 247 console.debug('[stateStore] set', key) 248 + const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 211 249 await db` 212 - INSERT INTO oauth_states (key, data) 213 - VALUES (${key}, ${JSON.stringify(data)}) 214 - ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 250 + INSERT INTO oauth_states (key, data, created_at, expires_at) 251 + VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 252 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 215 253 `; 216 254 }, 217 255 async get(key: string) { 218 256 console.debug('[stateStore] get', key) 219 - const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 220 - return result[0] ? JSON.parse(result[0].data) : undefined; 257 + const now = Math.floor(Date.now() / 1000); 258 + const result = await db` 259 + SELECT data, expires_at 260 + FROM oauth_states 261 + WHERE key = ${key} 262 + `; 263 + if (!result[0]) return undefined; 264 + 265 + // Check if expired 266 + const expiresAt = Number(result[0].expires_at); 267 + if (expiresAt && now > expiresAt) { 268 + console.debug('[stateStore] State expired, deleting', key); 269 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 270 + return undefined; 271 + } 272 + 273 + return JSON.parse(result[0].data); 221 274 }, 222 275 async del(key: string) { 223 276 console.debug('[stateStore] del', key) ··· 228 281 const sessionStore = { 229 282 async set(sub: string, data: any) { 230 283 console.debug('[sessionStore] set', sub) 284 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 231 285 await db` 232 - INSERT INTO oauth_sessions (sub, data) 233 - VALUES (${sub}, ${JSON.stringify(data)}) 234 - ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 286 + INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 287 + VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 288 + ON CONFLICT (sub) DO UPDATE SET 289 + data = EXCLUDED.data, 290 + updated_at = EXTRACT(EPOCH FROM NOW()), 291 + expires_at = ${expiresAt} 235 292 `; 236 293 }, 237 294 async get(sub: string) { 238 295 console.debug('[sessionStore] get', sub) 239 - const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 240 - return result[0] ? JSON.parse(result[0].data) : undefined; 296 + const now = Math.floor(Date.now() / 1000); 297 + const result = await db` 298 + SELECT data, expires_at 299 + FROM oauth_sessions 300 + WHERE sub = ${sub} 301 + `; 302 + if (!result[0]) return undefined; 303 + 304 + // Check if expired 305 + const expiresAt = Number(result[0].expires_at); 306 + if (expiresAt && now > expiresAt) { 307 + console.log('[sessionStore] Session expired, deleting', sub); 308 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 309 + return undefined; 310 + } 311 + 312 + return JSON.parse(result[0].data); 241 313 }, 242 314 async del(sub: string) { 243 315 console.debug('[sessionStore] del', sub) ··· 247 319 248 320 export { sessionStore }; 249 321 250 - export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({ 251 - client_id: `${config.domain}/client-metadata.json`, 252 - client_name: config.clientName, 253 - client_uri: config.domain, 254 - logo_uri: `${config.domain}/logo.png`, 255 - tos_uri: `${config.domain}/tos`, 256 - policy_uri: `${config.domain}/policy`, 257 - redirect_uris: [`${config.domain}/api/auth/callback`], 258 - grant_types: ['authorization_code', 'refresh_token'], 259 - response_types: ['code'], 260 - application_type: 'web', 261 - token_endpoint_auth_method: 'private_key_jwt', 262 - token_endpoint_auth_signing_alg: "ES256", 263 - scope: "atproto transition:generic", 264 - dpop_bound_access_tokens: true, 265 - jwks_uri: `${config.domain}/jwks.json`, 266 - subject_type: 'public', 267 - authorization_signed_response_alg: 'ES256' 268 - }); 322 + // Cleanup expired sessions and states 323 + export const cleanupExpiredSessions = async () => { 324 + const now = Math.floor(Date.now() / 1000); 325 + try { 326 + const sessionsDeleted = await db` 327 + DELETE FROM oauth_sessions WHERE expires_at < ${now} 328 + `; 329 + const statesDeleted = await db` 330 + DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 331 + `; 332 + console.log(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 333 + return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 334 + } catch (err) { 335 + console.error('[Cleanup] Failed to cleanup expired data:', err); 336 + return { sessions: 0, states: 0 }; 337 + } 338 + }; 339 + 340 + export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => { 341 + const isLocalDev = process.env.LOCAL_DEV === 'true'; 342 + 343 + if (isLocalDev) { 344 + // Loopback client for local development 345 + // For loopback, scopes and redirect_uri must be in client_id query string 346 + const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 347 + const scope = 'atproto transition:generic'; 348 + const params = new URLSearchParams(); 349 + params.append('redirect_uri', redirectUri); 350 + params.append('scope', scope); 351 + 352 + return { 353 + client_id: `http://localhost?${params.toString()}`, 354 + client_name: config.clientName, 355 + client_uri: config.domain, 356 + redirect_uris: [redirectUri], 357 + grant_types: ['authorization_code', 'refresh_token'], 358 + response_types: ['code'], 359 + application_type: 'web', 360 + token_endpoint_auth_method: 'none', 361 + scope: scope, 362 + dpop_bound_access_tokens: false, 363 + subject_type: 'public' 364 + }; 365 + } 366 + 367 + // Production client with private_key_jwt 368 + return { 369 + client_id: `${config.domain}/client-metadata.json`, 370 + client_name: config.clientName, 371 + client_uri: config.domain, 372 + logo_uri: `${config.domain}/logo.png`, 373 + tos_uri: `${config.domain}/tos`, 374 + policy_uri: `${config.domain}/policy`, 375 + redirect_uris: [`${config.domain}/api/auth/callback`], 376 + grant_types: ['authorization_code', 'refresh_token'], 377 + response_types: ['code'], 378 + application_type: 'web', 379 + token_endpoint_auth_method: 'private_key_jwt', 380 + token_endpoint_auth_signing_alg: "ES256", 381 + scope: "atproto transition:generic", 382 + dpop_bound_access_tokens: true, 383 + jwks_uri: `${config.domain}/jwks.json`, 384 + subject_type: 'public', 385 + authorization_signed_response_alg: 'ES256' 386 + }; 387 + }; 269 388 270 389 const persistKey = async (key: JoseKey) => { 271 390 const priv = key.privateJwk; 272 391 if (!priv) return; 273 392 const kid = key.kid ?? crypto.randomUUID(); 274 393 await db` 275 - INSERT INTO oauth_keys (kid, jwk) 276 - VALUES (${kid}, ${JSON.stringify(priv)}) 394 + INSERT INTO oauth_keys (kid, jwk, created_at) 395 + VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 277 396 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 278 397 `; 279 398 }; 280 399 281 400 const loadPersistedKeys = async (): Promise<JoseKey[]> => { 282 - const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 401 + const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 283 402 const keys: JoseKey[] = []; 284 403 for (const row of rows) { 285 404 try { ··· 309 428 return keys; 310 429 }; 311 430 312 - let currentKeys: JoseKey[] = []; 431 + // Load keys from database every time (stateless - safe for horizontal scaling) 432 + export const getCurrentKeys = async (): Promise<JoseKey[]> => { 433 + return await loadPersistedKeys(); 434 + }; 435 + 436 + // Key rotation - rotate keys older than 30 days (monthly rotation) 437 + const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 438 + 439 + export const rotateKeysIfNeeded = async (): Promise<boolean> => { 440 + const now = Math.floor(Date.now() / 1000); 441 + const cutoffTime = now - KEY_MAX_AGE; 442 + 443 + try { 444 + // Find keys older than 30 days 445 + const oldKeys = await db` 446 + SELECT kid, created_at FROM oauth_keys 447 + WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 448 + ORDER BY created_at ASC 449 + `; 450 + 451 + if (oldKeys.length === 0) { 452 + console.log('[KeyRotation] No keys need rotation'); 453 + return false; 454 + } 455 + 456 + console.log(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 457 + 458 + // Rotate the oldest key 459 + const oldestKey = oldKeys[0]; 460 + const oldKid = oldestKey.kid; 461 + 462 + // Generate new key with same kid 463 + const newKey = await JoseKey.generate(['ES256'], oldKid); 464 + await persistKey(newKey); 313 465 314 - export const getCurrentKeys = () => currentKeys; 466 + console.log(`[KeyRotation] Rotated key ${oldKid}`); 315 467 316 - export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 317 - if (currentKeys.length === 0) { 318 - currentKeys = await ensureKeys(); 468 + return true; 469 + } catch (err) { 470 + console.error('[KeyRotation] Failed to rotate keys:', err); 471 + return false; 319 472 } 473 + }; 474 + 475 + export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => { 476 + const keys = await ensureKeys(); 320 477 321 478 return new NodeOAuthClient({ 322 479 clientMetadata: createClientMetadata(config), 323 - keyset: currentKeys, 480 + keyset: keys, 324 481 stateStore, 325 482 sessionStore 326 483 }); ··· 346 503 return rows[0] ?? null; 347 504 }; 348 505 349 - export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => { 506 + export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => { 350 507 const domainLower = domain.toLowerCase(); 351 508 try { 352 509 await db` ··· 360 517 } 361 518 }; 362 519 363 - export const updateCustomDomainRkey = async (id: string, rkey: string) => { 520 + export const updateCustomDomainRkey = async (id: string, rkey: string | null) => { 364 521 const rows = await db` 365 522 UPDATE custom_domains 366 523 SET rkey = ${rkey} ··· 411 568 return { success: false, error: err }; 412 569 } 413 570 }; 571 + 572 + export const deleteSite = async (did: string, rkey: string) => { 573 + try { 574 + await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`; 575 + return { success: true }; 576 + } catch (err) { 577 + console.error('Failed to delete site', err); 578 + return { success: false, error: err }; 579 + } 580 + };
+190
src/lib/dns-verification-worker.ts
··· 1 + import { verifyCustomDomain } from './dns-verify'; 2 + import { db } from './db'; 3 + 4 + interface VerificationStats { 5 + totalChecked: number; 6 + verified: number; 7 + failed: number; 8 + errors: number; 9 + } 10 + 11 + export class DNSVerificationWorker { 12 + private interval: Timer | null = null; 13 + private isRunning = false; 14 + private lastRunTime: number | null = null; 15 + private stats: VerificationStats = { 16 + totalChecked: 0, 17 + verified: 0, 18 + failed: 0, 19 + errors: 0, 20 + }; 21 + 22 + constructor( 23 + private checkIntervalMs: number = 60 * 60 * 1000, // 1 hour default 24 + private onLog?: (message: string, data?: any) => void 25 + ) {} 26 + 27 + private log(message: string, data?: any) { 28 + if (this.onLog) { 29 + this.onLog(message, data); 30 + } 31 + } 32 + 33 + async start() { 34 + if (this.isRunning) { 35 + this.log('DNS verification worker already running'); 36 + return; 37 + } 38 + 39 + this.isRunning = true; 40 + this.log('Starting DNS verification worker', { 41 + intervalMinutes: this.checkIntervalMs / 60000, 42 + }); 43 + 44 + // Run immediately on start 45 + await this.verifyAllDomains(); 46 + 47 + // Then run on interval 48 + this.interval = setInterval(() => { 49 + this.verifyAllDomains(); 50 + }, this.checkIntervalMs); 51 + } 52 + 53 + stop() { 54 + if (this.interval) { 55 + clearInterval(this.interval); 56 + this.interval = null; 57 + } 58 + this.isRunning = false; 59 + this.log('DNS verification worker stopped'); 60 + } 61 + 62 + private async verifyAllDomains() { 63 + this.log('Starting DNS verification check'); 64 + const startTime = Date.now(); 65 + 66 + const runStats: VerificationStats = { 67 + totalChecked: 0, 68 + verified: 0, 69 + failed: 0, 70 + errors: 0, 71 + }; 72 + 73 + try { 74 + // Get all custom domains (both verified and pending) 75 + const domains = await db<Array<{ 76 + id: string; 77 + domain: string; 78 + did: string; 79 + verified: boolean; 80 + }>>` 81 + SELECT id, domain, did, verified FROM custom_domains 82 + `; 83 + 84 + if (!domains || domains.length === 0) { 85 + this.log('No custom domains to check'); 86 + this.lastRunTime = Date.now(); 87 + return; 88 + } 89 + 90 + const verifiedCount = domains.filter(d => d.verified).length; 91 + const pendingCount = domains.filter(d => !d.verified).length; 92 + this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`); 93 + 94 + // Verify each domain 95 + for (const row of domains) { 96 + runStats.totalChecked++; 97 + const { id, domain, did, verified: wasVerified } = row; 98 + 99 + try { 100 + // Extract hash from id (SHA256 of did:domain) 101 + const expectedHash = id.substring(0, 16); 102 + 103 + // Verify DNS records 104 + const result = await verifyCustomDomain(domain, did, expectedHash); 105 + 106 + if (result.verified) { 107 + // Update verified status and last_verified_at timestamp 108 + await db` 109 + UPDATE custom_domains 110 + SET verified = true, 111 + last_verified_at = EXTRACT(EPOCH FROM NOW()) 112 + WHERE id = ${id} 113 + `; 114 + runStats.verified++; 115 + if (!wasVerified) { 116 + this.log(`Domain newly verified: ${domain}`, { did }); 117 + } else { 118 + this.log(`Domain re-verified: ${domain}`, { did }); 119 + } 120 + } else { 121 + // Mark domain as unverified or keep it pending 122 + await db` 123 + UPDATE custom_domains 124 + SET verified = false, 125 + last_verified_at = EXTRACT(EPOCH FROM NOW()) 126 + WHERE id = ${id} 127 + `; 128 + runStats.failed++; 129 + if (wasVerified) { 130 + this.log(`Domain verification failed (was verified): ${domain}`, { 131 + did, 132 + error: result.error, 133 + found: result.found, 134 + }); 135 + } else { 136 + this.log(`Domain still pending: ${domain}`, { 137 + did, 138 + error: result.error, 139 + found: result.found, 140 + }); 141 + } 142 + } 143 + } catch (error) { 144 + runStats.errors++; 145 + this.log(`Error verifying domain: ${domain}`, { 146 + did, 147 + error: error instanceof Error ? error.message : String(error), 148 + }); 149 + } 150 + } 151 + 152 + // Update cumulative stats 153 + this.stats.totalChecked += runStats.totalChecked; 154 + this.stats.verified += runStats.verified; 155 + this.stats.failed += runStats.failed; 156 + this.stats.errors += runStats.errors; 157 + 158 + const duration = Date.now() - startTime; 159 + this.lastRunTime = Date.now(); 160 + 161 + this.log('DNS verification check completed', { 162 + duration: `${duration}ms`, 163 + ...runStats, 164 + }); 165 + } catch (error) { 166 + this.log('Fatal error in DNS verification worker', { 167 + error: error instanceof Error ? error.message : String(error), 168 + }); 169 + } 170 + } 171 + 172 + getHealth() { 173 + return { 174 + isRunning: this.isRunning, 175 + lastRunTime: this.lastRunTime, 176 + intervalMs: this.checkIntervalMs, 177 + stats: this.stats, 178 + healthy: this.isRunning && ( 179 + this.lastRunTime === null || 180 + Date.now() - this.lastRunTime < this.checkIntervalMs * 2 181 + ), 182 + }; 183 + } 184 + 185 + // Manual trigger for testing 186 + async trigger() { 187 + this.log('Manual DNS verification triggered'); 188 + await this.verifyAllDomains(); 189 + } 190 + }
+46
src/lib/logger.ts
··· 1 + // Secure logging utility - only verbose in development mode 2 + const isDev = process.env.NODE_ENV !== 'production'; 3 + 4 + export const logger = { 5 + // Always log these (safe for production) 6 + info: (...args: any[]) => { 7 + console.log(...args); 8 + }, 9 + 10 + // Only log in development (may contain sensitive info) 11 + debug: (...args: any[]) => { 12 + if (isDev) { 13 + console.debug(...args); 14 + } 15 + }, 16 + 17 + // Warning logging (always logged but may be sanitized in production) 18 + warn: (message: string, context?: Record<string, any>) => { 19 + if (isDev) { 20 + console.warn(message, context); 21 + } else { 22 + console.warn(message); 23 + } 24 + }, 25 + 26 + // Safe error logging - sanitizes in production 27 + error: (message: string, error?: any) => { 28 + if (isDev) { 29 + // Development: log full error details 30 + console.error(message, error); 31 + } else { 32 + // Production: log only the message, not error details 33 + console.error(message); 34 + } 35 + }, 36 + 37 + // Log error with context but sanitize sensitive data in production 38 + errorWithContext: (message: string, context?: Record<string, any>, error?: any) => { 39 + if (isDev) { 40 + console.error(message, context, error); 41 + } else { 42 + // In production, only log the message 43 + console.error(message); 44 + } 45 + } 46 + };
+146 -23
src/lib/oauth-client.ts
··· 1 1 import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 2 2 import { JoseKey } from "@atproto/jwk-jose"; 3 3 import { db } from "./db"; 4 + import { logger } from "./logger"; 5 + 6 + // Session timeout configuration (30 days in seconds) 7 + const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds 8 + // OAuth state timeout (1 hour in seconds) 9 + const STATE_TIMEOUT = 60 * 60; // 3600 seconds 4 10 5 11 const stateStore = { 6 12 async set(key: string, data: any) { 7 13 console.debug('[stateStore] set', key) 14 + const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT; 8 15 await db` 9 - INSERT INTO oauth_states (key, data) 10 - VALUES (${key}, ${JSON.stringify(data)}) 11 - ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data 16 + INSERT INTO oauth_states (key, data, created_at, expires_at) 17 + VALUES (${key}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 18 + ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data, expires_at = ${expiresAt} 12 19 `; 13 20 }, 14 21 async get(key: string) { 15 22 console.debug('[stateStore] get', key) 16 - const result = await db`SELECT data FROM oauth_states WHERE key = ${key}`; 17 - return result[0] ? JSON.parse(result[0].data) : undefined; 23 + const now = Math.floor(Date.now() / 1000); 24 + const result = await db` 25 + SELECT data, expires_at 26 + FROM oauth_states 27 + WHERE key = ${key} 28 + `; 29 + if (!result[0]) return undefined; 30 + 31 + // Check if expired 32 + const expiresAt = Number(result[0].expires_at); 33 + if (expiresAt && now > expiresAt) { 34 + console.debug('[stateStore] State expired, deleting', key); 35 + await db`DELETE FROM oauth_states WHERE key = ${key}`; 36 + return undefined; 37 + } 38 + 39 + return JSON.parse(result[0].data); 18 40 }, 19 41 async del(key: string) { 20 42 console.debug('[stateStore] del', key) ··· 25 47 const sessionStore = { 26 48 async set(sub: string, data: any) { 27 49 console.debug('[sessionStore] set', sub) 50 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT; 28 51 await db` 29 - INSERT INTO oauth_sessions (sub, data) 30 - VALUES (${sub}, ${JSON.stringify(data)}) 31 - ON CONFLICT (sub) DO UPDATE SET data = EXCLUDED.data, updated_at = EXTRACT(EPOCH FROM NOW()) 52 + INSERT INTO oauth_sessions (sub, data, updated_at, expires_at) 53 + VALUES (${sub}, ${JSON.stringify(data)}, EXTRACT(EPOCH FROM NOW()), ${expiresAt}) 54 + ON CONFLICT (sub) DO UPDATE SET 55 + data = EXCLUDED.data, 56 + updated_at = EXTRACT(EPOCH FROM NOW()), 57 + expires_at = ${expiresAt} 32 58 `; 33 59 }, 34 60 async get(sub: string) { 35 61 console.debug('[sessionStore] get', sub) 36 - const result = await db`SELECT data FROM oauth_sessions WHERE sub = ${sub}`; 37 - return result[0] ? JSON.parse(result[0].data) : undefined; 62 + const now = Math.floor(Date.now() / 1000); 63 + const result = await db` 64 + SELECT data, expires_at 65 + FROM oauth_sessions 66 + WHERE sub = ${sub} 67 + `; 68 + if (!result[0]) return undefined; 69 + 70 + // Check if expired 71 + const expiresAt = Number(result[0].expires_at); 72 + if (expiresAt && now > expiresAt) { 73 + logger.debug('[sessionStore] Session expired, deleting', sub); 74 + await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`; 75 + return undefined; 76 + } 77 + 78 + return JSON.parse(result[0].data); 38 79 }, 39 80 async del(sub: string) { 40 81 console.debug('[sessionStore] del', sub) ··· 44 85 45 86 export { sessionStore }; 46 87 47 - export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => { 48 - // Use editor.wisp.place for OAuth endpoints since that's where the API routes live 88 + // Cleanup expired sessions and states 89 + export const cleanupExpiredSessions = async () => { 90 + const now = Math.floor(Date.now() / 1000); 91 + try { 92 + const sessionsDeleted = await db` 93 + DELETE FROM oauth_sessions WHERE expires_at < ${now} 94 + `; 95 + const statesDeleted = await db` 96 + DELETE FROM oauth_states WHERE expires_at IS NOT NULL AND expires_at < ${now} 97 + `; 98 + logger.info(`[Cleanup] Deleted ${sessionsDeleted.length} expired sessions and ${statesDeleted.length} expired states`); 99 + return { sessions: sessionsDeleted.length, states: statesDeleted.length }; 100 + } catch (err) { 101 + logger.error('[Cleanup] Failed to cleanup expired data', err); 102 + return { sessions: 0, states: 0 }; 103 + } 104 + }; 105 + 106 + export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => { 107 + const isLocalDev = Bun.env.LOCAL_DEV === 'true'; 108 + 109 + if (isLocalDev) { 110 + // Loopback client for local development 111 + // For loopback, scopes and redirect_uri must be in client_id query string 112 + const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 113 + const scope = 'atproto transition:generic'; 114 + const params = new URLSearchParams(); 115 + params.append('redirect_uri', redirectUri); 116 + params.append('scope', scope); 117 + 118 + return { 119 + client_id: `http://localhost?${params.toString()}`, 120 + client_name: config.clientName, 121 + client_uri: `https://wisp.place`, 122 + redirect_uris: [redirectUri], 123 + grant_types: ['authorization_code', 'refresh_token'], 124 + response_types: ['code'], 125 + application_type: 'web', 126 + token_endpoint_auth_method: 'none', 127 + scope: scope, 128 + dpop_bound_access_tokens: false, 129 + subject_type: 'public' 130 + }; 131 + } 132 + 133 + // Production client with private_key_jwt 49 134 return { 50 135 client_id: `${config.domain}/client-metadata.json`, 51 136 client_name: config.clientName, 52 - client_uri: `https://wisp.place`, 137 + client_uri: `https://wisp.place`, 53 138 logo_uri: `${config.domain}/logo.png`, 54 139 tos_uri: `${config.domain}/tos`, 55 140 policy_uri: `${config.domain}/policy`, ··· 72 157 if (!priv) return; 73 158 const kid = key.kid ?? crypto.randomUUID(); 74 159 await db` 75 - INSERT INTO oauth_keys (kid, jwk) 76 - VALUES (${kid}, ${JSON.stringify(priv)}) 160 + INSERT INTO oauth_keys (kid, jwk, created_at) 161 + VALUES (${kid}, ${JSON.stringify(priv)}, EXTRACT(EPOCH FROM NOW())) 77 162 ON CONFLICT (kid) DO UPDATE SET jwk = EXCLUDED.jwk 78 163 `; 79 164 }; 80 165 81 166 const loadPersistedKeys = async (): Promise<JoseKey[]> => { 82 - const rows = await db`SELECT kid, jwk FROM oauth_keys ORDER BY kid`; 167 + const rows = await db`SELECT kid, jwk, created_at FROM oauth_keys ORDER BY kid`; 83 168 const keys: JoseKey[] = []; 84 169 for (const row of rows) { 85 170 try { ··· 87 172 const key = await JoseKey.fromImportable(obj as any, (obj as any).kid); 88 173 keys.push(key); 89 174 } catch (err) { 90 - console.error('Could not parse stored JWK', err); 175 + logger.error('[OAuth] Could not parse stored JWK', err); 91 176 } 92 177 } 93 178 return keys; ··· 109 194 return keys; 110 195 }; 111 196 112 - let currentKeys: JoseKey[] = []; 197 + // Load keys from database every time (stateless - safe for horizontal scaling) 198 + export const getCurrentKeys = async (): Promise<JoseKey[]> => { 199 + return await loadPersistedKeys(); 200 + }; 113 201 114 - export const getCurrentKeys = () => currentKeys; 202 + // Key rotation - rotate keys older than 30 days (monthly rotation) 203 + const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds 115 204 116 - export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => { 117 - if (currentKeys.length === 0) { 118 - currentKeys = await ensureKeys(); 205 + export const rotateKeysIfNeeded = async (): Promise<boolean> => { 206 + const now = Math.floor(Date.now() / 1000); 207 + const cutoffTime = now - KEY_MAX_AGE; 208 + 209 + try { 210 + // Find keys older than 30 days 211 + const oldKeys = await db` 212 + SELECT kid, created_at FROM oauth_keys 213 + WHERE created_at IS NOT NULL AND created_at < ${cutoffTime} 214 + ORDER BY created_at ASC 215 + `; 216 + 217 + if (oldKeys.length === 0) { 218 + logger.debug('[KeyRotation] No keys need rotation'); 219 + return false; 220 + } 221 + 222 + logger.info(`[KeyRotation] Found ${oldKeys.length} key(s) older than 30 days, rotating oldest key`); 223 + 224 + // Rotate the oldest key 225 + const oldestKey = oldKeys[0]; 226 + const oldKid = oldestKey.kid; 227 + 228 + // Generate new key with same kid 229 + const newKey = await JoseKey.generate(['ES256'], oldKid); 230 + await persistKey(newKey); 231 + 232 + logger.info(`[KeyRotation] Rotated key ${oldKid}`); 233 + 234 + return true; 235 + } catch (err) { 236 + logger.error('[KeyRotation] Failed to rotate keys', err); 237 + return false; 119 238 } 239 + }; 240 + 241 + export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => { 242 + const keys = await ensureKeys(); 120 243 121 244 return new NodeOAuthClient({ 122 245 clientMetadata: createClientMetadata(config), 123 - keyset: currentKeys, 246 + keyset: keys, 124 247 stateStore, 125 248 sessionStore 126 249 });
+339
src/lib/observability.ts
··· 1 + // DIY Observability - Logs, Metrics, and Error Tracking 2 + // Types 3 + export interface LogEntry { 4 + id: string 5 + timestamp: Date 6 + level: 'info' | 'warn' | 'error' | 'debug' 7 + message: string 8 + service: string 9 + context?: Record<string, any> 10 + traceId?: string 11 + eventType?: string 12 + } 13 + 14 + export interface ErrorEntry { 15 + id: string 16 + timestamp: Date 17 + message: string 18 + stack?: string 19 + service: string 20 + context?: Record<string, any> 21 + count: number // How many times this error occurred 22 + lastSeen: Date 23 + } 24 + 25 + export interface MetricEntry { 26 + timestamp: Date 27 + path: string 28 + method: string 29 + statusCode: number 30 + duration: number // in milliseconds 31 + service: string 32 + } 33 + 34 + export interface DatabaseStats { 35 + totalSites: number 36 + totalDomains: number 37 + totalCustomDomains: number 38 + recentSites: any[] 39 + recentDomains: any[] 40 + } 41 + 42 + // In-memory storage with rotation 43 + const MAX_LOGS = 5000 44 + const MAX_ERRORS = 500 45 + const MAX_METRICS = 10000 46 + 47 + const logs: LogEntry[] = [] 48 + const errors: Map<string, ErrorEntry> = new Map() 49 + const metrics: MetricEntry[] = [] 50 + 51 + // Helper to generate unique IDs 52 + let logCounter = 0 53 + let errorCounter = 0 54 + 55 + function generateId(prefix: string, counter: number): string { 56 + return `${prefix}-${Date.now()}-${counter}` 57 + } 58 + 59 + // Helper to extract event type from message 60 + function extractEventType(message: string): string | undefined { 61 + const match = message.match(/^\[([^\]]+)\]/) 62 + return match ? match[1] : undefined 63 + } 64 + 65 + // Log collector 66 + export const logCollector = { 67 + log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) { 68 + const entry: LogEntry = { 69 + id: generateId('log', logCounter++), 70 + timestamp: new Date(), 71 + level, 72 + message, 73 + service, 74 + context, 75 + traceId, 76 + eventType: extractEventType(message) 77 + } 78 + 79 + logs.unshift(entry) 80 + 81 + // Rotate if needed 82 + if (logs.length > MAX_LOGS) { 83 + logs.splice(MAX_LOGS) 84 + } 85 + 86 + // Also log to console for compatibility 87 + const contextStr = context ? ` ${JSON.stringify(context)}` : '' 88 + const traceStr = traceId ? ` [trace:${traceId}]` : '' 89 + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) 90 + }, 91 + 92 + info(message: string, service: string, context?: Record<string, any>, traceId?: string) { 93 + this.log('info', message, service, context, traceId) 94 + }, 95 + 96 + warn(message: string, service: string, context?: Record<string, any>, traceId?: string) { 97 + this.log('warn', message, service, context, traceId) 98 + }, 99 + 100 + error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) { 101 + const ctx = { ...context } 102 + if (error instanceof Error) { 103 + ctx.error = error.message 104 + ctx.stack = error.stack 105 + } else if (error) { 106 + ctx.error = String(error) 107 + } 108 + this.log('error', message, service, ctx, traceId) 109 + 110 + // Also track in errors 111 + errorTracker.track(message, service, error, context) 112 + }, 113 + 114 + debug(message: string, service: string, context?: Record<string, any>, traceId?: string) { 115 + if (process.env.NODE_ENV !== 'production') { 116 + this.log('debug', message, service, context, traceId) 117 + } 118 + }, 119 + 120 + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { 121 + let filtered = [...logs] 122 + 123 + if (filter?.level) { 124 + filtered = filtered.filter(log => log.level === filter.level) 125 + } 126 + 127 + if (filter?.service) { 128 + filtered = filtered.filter(log => log.service === filter.service) 129 + } 130 + 131 + if (filter?.eventType) { 132 + filtered = filtered.filter(log => log.eventType === filter.eventType) 133 + } 134 + 135 + if (filter?.search) { 136 + const search = filter.search.toLowerCase() 137 + filtered = filtered.filter(log => 138 + log.message.toLowerCase().includes(search) || 139 + (log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false) 140 + ) 141 + } 142 + 143 + const limit = filter?.limit || 100 144 + return filtered.slice(0, limit) 145 + }, 146 + 147 + clear() { 148 + logs.length = 0 149 + } 150 + } 151 + 152 + // Error tracker with deduplication 153 + export const errorTracker = { 154 + track(message: string, service: string, error?: any, context?: Record<string, any>) { 155 + const key = `${service}:${message}` 156 + 157 + const existing = errors.get(key) 158 + if (existing) { 159 + existing.count++ 160 + existing.lastSeen = new Date() 161 + if (context) { 162 + existing.context = { ...existing.context, ...context } 163 + } 164 + } else { 165 + const entry: ErrorEntry = { 166 + id: generateId('error', errorCounter++), 167 + timestamp: new Date(), 168 + message, 169 + service, 170 + context, 171 + count: 1, 172 + lastSeen: new Date() 173 + } 174 + 175 + if (error instanceof Error) { 176 + entry.stack = error.stack 177 + } 178 + 179 + errors.set(key, entry) 180 + 181 + // Rotate if needed 182 + if (errors.size > MAX_ERRORS) { 183 + const oldest = Array.from(errors.keys())[0] 184 + errors.delete(oldest) 185 + } 186 + } 187 + }, 188 + 189 + getErrors(filter?: { service?: string; limit?: number }) { 190 + let filtered = Array.from(errors.values()) 191 + 192 + if (filter?.service) { 193 + filtered = filtered.filter(err => err.service === filter.service) 194 + } 195 + 196 + // Sort by last seen (most recent first) 197 + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) 198 + 199 + const limit = filter?.limit || 100 200 + return filtered.slice(0, limit) 201 + }, 202 + 203 + clear() { 204 + errors.clear() 205 + } 206 + } 207 + 208 + // Metrics collector 209 + export const metricsCollector = { 210 + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { 211 + const entry: MetricEntry = { 212 + timestamp: new Date(), 213 + path, 214 + method, 215 + statusCode, 216 + duration, 217 + service 218 + } 219 + 220 + metrics.unshift(entry) 221 + 222 + // Rotate if needed 223 + if (metrics.length > MAX_METRICS) { 224 + metrics.splice(MAX_METRICS) 225 + } 226 + }, 227 + 228 + getMetrics(filter?: { service?: string; timeWindow?: number }) { 229 + let filtered = [...metrics] 230 + 231 + if (filter?.service) { 232 + filtered = filtered.filter(m => m.service === filter.service) 233 + } 234 + 235 + if (filter?.timeWindow) { 236 + const cutoff = Date.now() - filter.timeWindow 237 + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) 238 + } 239 + 240 + return filtered 241 + }, 242 + 243 + getStats(service?: string, timeWindow: number = 3600000) { 244 + const filtered = this.getMetrics({ service, timeWindow }) 245 + 246 + if (filtered.length === 0) { 247 + return { 248 + totalRequests: 0, 249 + avgDuration: 0, 250 + p50Duration: 0, 251 + p95Duration: 0, 252 + p99Duration: 0, 253 + errorRate: 0, 254 + requestsPerMinute: 0 255 + } 256 + } 257 + 258 + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) 259 + const totalDuration = durations.reduce((sum, d) => sum + d, 0) 260 + const errors = filtered.filter(m => m.statusCode >= 400).length 261 + 262 + const p50 = durations[Math.floor(durations.length * 0.5)] 263 + const p95 = durations[Math.floor(durations.length * 0.95)] 264 + const p99 = durations[Math.floor(durations.length * 0.99)] 265 + 266 + const timeWindowMinutes = timeWindow / 60000 267 + 268 + return { 269 + totalRequests: filtered.length, 270 + avgDuration: Math.round(totalDuration / filtered.length), 271 + p50Duration: Math.round(p50), 272 + p95Duration: Math.round(p95), 273 + p99Duration: Math.round(p99), 274 + errorRate: (errors / filtered.length) * 100, 275 + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 276 + } 277 + }, 278 + 279 + clear() { 280 + metrics.length = 0 281 + } 282 + } 283 + 284 + // Elysia middleware for request timing 285 + export function observabilityMiddleware(service: string) { 286 + return { 287 + beforeHandle: ({ request }: any) => { 288 + // Store start time on request object 289 + (request as any).__startTime = Date.now() 290 + }, 291 + afterHandle: ({ request, set }: any) => { 292 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 293 + const url = new URL(request.url) 294 + 295 + metricsCollector.recordRequest( 296 + url.pathname, 297 + request.method, 298 + set.status || 200, 299 + duration, 300 + service 301 + ) 302 + }, 303 + onError: ({ request, error, set }: any) => { 304 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 305 + const url = new URL(request.url) 306 + 307 + metricsCollector.recordRequest( 308 + url.pathname, 309 + request.method, 310 + set.status || 500, 311 + duration, 312 + service 313 + ) 314 + 315 + // Don't log 404 errors 316 + const statusCode = set.status || 500 317 + if (statusCode !== 404) { 318 + logCollector.error( 319 + `Request failed: ${request.method} ${url.pathname}`, 320 + service, 321 + error, 322 + { statusCode } 323 + ) 324 + } 325 + } 326 + } 327 + } 328 + 329 + // Export singleton logger for easy access 330 + export const logger = { 331 + info: (message: string, context?: Record<string, any>) => 332 + logCollector.info(message, 'main-app', context), 333 + warn: (message: string, context?: Record<string, any>) => 334 + logCollector.warn(message, 'main-app', context), 335 + error: (message: string, error?: any, context?: Record<string, any>) => 336 + logCollector.error(message, 'main-app', error, context), 337 + debug: (message: string, context?: Record<string, any>) => 338 + logCollector.debug(message, 'main-app', context) 339 + }
+2 -4
src/lib/types.ts
··· 1 - import type { BlobRef } from "@atproto/api"; 2 - 3 1 /** 4 2 * Configuration for the Wisp client 5 3 * @typeParam Config 6 4 */ 7 5 export type Config = { 8 - /** The base domain URL with HTTPS protocol */ 9 - domain: `https://${string}`, 6 + /** The base domain URL with HTTP or HTTPS protocol */ 7 + domain: `http://${string}` | `https://${string}`, 10 8 /** Name of the client application */ 11 9 clientName: string 12 10 };
+2 -1
src/lib/wisp-auth.ts
··· 2 2 import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 3 import type { OAuthSession } from "@atproto/oauth-client-node"; 4 4 import { Cookie } from "elysia"; 5 + import { logger } from "./logger"; 5 6 6 7 7 8 export interface AuthenticatedContext { ··· 20 21 const session = await client.restore(did, "auto"); 21 22 return session ? { did, session } : null; 22 23 } catch (err) { 23 - console.error('Authentication error:', err); 24 + logger.error('[Auth] Authentication error', err); 24 25 return null; 25 26 } 26 27 };
+639
src/lib/wisp-utils.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { 3 + shouldCompressFile, 4 + compressFile, 5 + processUploadedFiles, 6 + createManifest, 7 + updateFileBlobs, 8 + type UploadedFile, 9 + type FileUploadResult, 10 + } from './wisp-utils' 11 + import type { Directory } from '../lexicons/types/place/wisp/fs' 12 + import { gunzipSync } from 'zlib' 13 + import { BlobRef } from '@atproto/api' 14 + import { CID } from 'multiformats/cid' 15 + 16 + // Helper function to create a valid CID for testing 17 + // Using a real valid CID from actual AT Protocol usage 18 + const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y' 19 + 20 + function createMockBlobRef(mimeType: string, size: number): BlobRef { 21 + // Create a properly formatted CID 22 + const cid = CID.parse(TEST_CID_STRING) 23 + return new BlobRef(cid, mimeType, size) 24 + } 25 + 26 + describe('shouldCompressFile', () => { 27 + test('should compress HTML files', () => { 28 + expect(shouldCompressFile('text/html')).toBe(true) 29 + expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true) 30 + }) 31 + 32 + test('should compress CSS files', () => { 33 + expect(shouldCompressFile('text/css')).toBe(true) 34 + }) 35 + 36 + test('should compress JavaScript files', () => { 37 + expect(shouldCompressFile('text/javascript')).toBe(true) 38 + expect(shouldCompressFile('application/javascript')).toBe(true) 39 + expect(shouldCompressFile('application/x-javascript')).toBe(true) 40 + }) 41 + 42 + test('should compress JSON files', () => { 43 + expect(shouldCompressFile('application/json')).toBe(true) 44 + }) 45 + 46 + test('should compress SVG files', () => { 47 + expect(shouldCompressFile('image/svg+xml')).toBe(true) 48 + }) 49 + 50 + test('should compress XML files', () => { 51 + expect(shouldCompressFile('text/xml')).toBe(true) 52 + expect(shouldCompressFile('application/xml')).toBe(true) 53 + }) 54 + 55 + test('should compress plain text files', () => { 56 + expect(shouldCompressFile('text/plain')).toBe(true) 57 + }) 58 + 59 + test('should NOT compress images', () => { 60 + expect(shouldCompressFile('image/png')).toBe(false) 61 + expect(shouldCompressFile('image/jpeg')).toBe(false) 62 + expect(shouldCompressFile('image/jpg')).toBe(false) 63 + expect(shouldCompressFile('image/gif')).toBe(false) 64 + expect(shouldCompressFile('image/webp')).toBe(false) 65 + }) 66 + 67 + test('should NOT compress videos', () => { 68 + expect(shouldCompressFile('video/mp4')).toBe(false) 69 + expect(shouldCompressFile('video/webm')).toBe(false) 70 + }) 71 + 72 + test('should NOT compress already compressed formats', () => { 73 + expect(shouldCompressFile('application/zip')).toBe(false) 74 + expect(shouldCompressFile('application/gzip')).toBe(false) 75 + expect(shouldCompressFile('application/pdf')).toBe(false) 76 + }) 77 + 78 + test('should NOT compress fonts', () => { 79 + expect(shouldCompressFile('font/woff')).toBe(false) 80 + expect(shouldCompressFile('font/woff2')).toBe(false) 81 + expect(shouldCompressFile('font/ttf')).toBe(false) 82 + }) 83 + }) 84 + 85 + describe('compressFile', () => { 86 + test('should compress text content', () => { 87 + const content = Buffer.from('Hello, World! '.repeat(100)) 88 + const compressed = compressFile(content) 89 + 90 + expect(compressed.length).toBeLessThan(content.length) 91 + 92 + // Verify we can decompress it back 93 + const decompressed = gunzipSync(compressed) 94 + expect(decompressed.toString()).toBe(content.toString()) 95 + }) 96 + 97 + test('should compress HTML content significantly', () => { 98 + const html = ` 99 + <!DOCTYPE html> 100 + <html> 101 + <head><title>Test</title></head> 102 + <body> 103 + ${'<p>Hello World!</p>\n'.repeat(50)} 104 + </body> 105 + </html> 106 + ` 107 + const content = Buffer.from(html) 108 + const compressed = compressFile(content) 109 + 110 + expect(compressed.length).toBeLessThan(content.length) 111 + 112 + // Verify decompression 113 + const decompressed = gunzipSync(compressed) 114 + expect(decompressed.toString()).toBe(html) 115 + }) 116 + 117 + test('should handle empty content', () => { 118 + const content = Buffer.from('') 119 + const compressed = compressFile(content) 120 + const decompressed = gunzipSync(compressed) 121 + expect(decompressed.toString()).toBe('') 122 + }) 123 + 124 + test('should produce deterministic compression', () => { 125 + const content = Buffer.from('Test content') 126 + const compressed1 = compressFile(content) 127 + const compressed2 = compressFile(content) 128 + 129 + expect(compressed1.toString('base64')).toBe(compressed2.toString('base64')) 130 + }) 131 + }) 132 + 133 + describe('processUploadedFiles', () => { 134 + test('should process single root-level file', () => { 135 + const files: UploadedFile[] = [ 136 + { 137 + name: 'index.html', 138 + content: Buffer.from('<html></html>'), 139 + mimeType: 'text/html', 140 + size: 13, 141 + }, 142 + ] 143 + 144 + const result = processUploadedFiles(files) 145 + 146 + expect(result.fileCount).toBe(1) 147 + expect(result.directory.type).toBe('directory') 148 + expect(result.directory.entries).toHaveLength(1) 149 + expect(result.directory.entries[0].name).toBe('index.html') 150 + 151 + const node = result.directory.entries[0].node 152 + expect('blob' in node).toBe(true) // It's a file node 153 + }) 154 + 155 + test('should process multiple root-level files', () => { 156 + const files: UploadedFile[] = [ 157 + { 158 + name: 'index.html', 159 + content: Buffer.from('<html></html>'), 160 + mimeType: 'text/html', 161 + size: 13, 162 + }, 163 + { 164 + name: 'styles.css', 165 + content: Buffer.from('body {}'), 166 + mimeType: 'text/css', 167 + size: 7, 168 + }, 169 + { 170 + name: 'script.js', 171 + content: Buffer.from('console.log("hi")'), 172 + mimeType: 'application/javascript', 173 + size: 17, 174 + }, 175 + ] 176 + 177 + const result = processUploadedFiles(files) 178 + 179 + expect(result.fileCount).toBe(3) 180 + expect(result.directory.entries).toHaveLength(3) 181 + 182 + const names = result.directory.entries.map(e => e.name) 183 + expect(names).toContain('index.html') 184 + expect(names).toContain('styles.css') 185 + expect(names).toContain('script.js') 186 + }) 187 + 188 + test('should process files with subdirectories', () => { 189 + const files: UploadedFile[] = [ 190 + { 191 + name: 'dist/index.html', 192 + content: Buffer.from('<html></html>'), 193 + mimeType: 'text/html', 194 + size: 13, 195 + }, 196 + { 197 + name: 'dist/css/styles.css', 198 + content: Buffer.from('body {}'), 199 + mimeType: 'text/css', 200 + size: 7, 201 + }, 202 + { 203 + name: 'dist/js/app.js', 204 + content: Buffer.from('console.log()'), 205 + mimeType: 'application/javascript', 206 + size: 13, 207 + }, 208 + ] 209 + 210 + const result = processUploadedFiles(files) 211 + 212 + expect(result.fileCount).toBe(3) 213 + expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/ 214 + 215 + // Check root has index.html (after base folder removal) 216 + const indexEntry = result.directory.entries.find(e => e.name === 'index.html') 217 + expect(indexEntry).toBeDefined() 218 + 219 + // Check css directory exists 220 + const cssDir = result.directory.entries.find(e => e.name === 'css') 221 + expect(cssDir).toBeDefined() 222 + expect('entries' in cssDir!.node).toBe(true) 223 + 224 + if ('entries' in cssDir!.node) { 225 + expect(cssDir!.node.entries).toHaveLength(1) 226 + expect(cssDir!.node.entries[0].name).toBe('styles.css') 227 + } 228 + 229 + // Check js directory exists 230 + const jsDir = result.directory.entries.find(e => e.name === 'js') 231 + expect(jsDir).toBeDefined() 232 + expect('entries' in jsDir!.node).toBe(true) 233 + }) 234 + 235 + test('should handle deeply nested subdirectories', () => { 236 + const files: UploadedFile[] = [ 237 + { 238 + name: 'dist/deep/nested/folder/file.txt', 239 + content: Buffer.from('content'), 240 + mimeType: 'text/plain', 241 + size: 7, 242 + }, 243 + ] 244 + 245 + const result = processUploadedFiles(files) 246 + 247 + expect(result.fileCount).toBe(1) 248 + 249 + // Navigate through the directory structure (base folder removed) 250 + const deepDir = result.directory.entries.find(e => e.name === 'deep') 251 + expect(deepDir).toBeDefined() 252 + expect('entries' in deepDir!.node).toBe(true) 253 + 254 + if ('entries' in deepDir!.node) { 255 + const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested') 256 + expect(nestedDir).toBeDefined() 257 + 258 + if (nestedDir && 'entries' in nestedDir.node) { 259 + const folderDir = nestedDir.node.entries.find(e => e.name === 'folder') 260 + expect(folderDir).toBeDefined() 261 + 262 + if (folderDir && 'entries' in folderDir.node) { 263 + expect(folderDir.node.entries).toHaveLength(1) 264 + expect(folderDir.node.entries[0].name).toBe('file.txt') 265 + } 266 + } 267 + } 268 + }) 269 + 270 + test('should remove base folder name from paths', () => { 271 + const files: UploadedFile[] = [ 272 + { 273 + name: 'dist/index.html', 274 + content: Buffer.from('<html></html>'), 275 + mimeType: 'text/html', 276 + size: 13, 277 + }, 278 + { 279 + name: 'dist/css/styles.css', 280 + content: Buffer.from('body {}'), 281 + mimeType: 'text/css', 282 + size: 7, 283 + }, 284 + ] 285 + 286 + const result = processUploadedFiles(files) 287 + 288 + // After removing 'dist/', we should have index.html and css/ at root 289 + expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined() 290 + expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined() 291 + expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined() 292 + }) 293 + 294 + test('should handle empty file list', () => { 295 + const files: UploadedFile[] = [] 296 + const result = processUploadedFiles(files) 297 + 298 + expect(result.fileCount).toBe(0) 299 + expect(result.directory.entries).toHaveLength(0) 300 + }) 301 + 302 + test('should handle multiple files in same subdirectory', () => { 303 + const files: UploadedFile[] = [ 304 + { 305 + name: 'dist/assets/image1.png', 306 + content: Buffer.from('png1'), 307 + mimeType: 'image/png', 308 + size: 4, 309 + }, 310 + { 311 + name: 'dist/assets/image2.png', 312 + content: Buffer.from('png2'), 313 + mimeType: 'image/png', 314 + size: 4, 315 + }, 316 + ] 317 + 318 + const result = processUploadedFiles(files) 319 + 320 + expect(result.fileCount).toBe(2) 321 + 322 + const assetsDir = result.directory.entries.find(e => e.name === 'assets') 323 + expect(assetsDir).toBeDefined() 324 + 325 + if ('entries' in assetsDir!.node) { 326 + expect(assetsDir!.node.entries).toHaveLength(2) 327 + const names = assetsDir!.node.entries.map(e => e.name) 328 + expect(names).toContain('image1.png') 329 + expect(names).toContain('image2.png') 330 + } 331 + }) 332 + }) 333 + 334 + describe('createManifest', () => { 335 + test('should create valid manifest', () => { 336 + const root: Directory = { 337 + $type: 'place.wisp.fs#directory', 338 + type: 'directory', 339 + entries: [], 340 + } 341 + 342 + const manifest = createManifest('example.com', root, 0) 343 + 344 + expect(manifest.$type).toBe('place.wisp.fs') 345 + expect(manifest.site).toBe('example.com') 346 + expect(manifest.root).toBe(root) 347 + expect(manifest.fileCount).toBe(0) 348 + expect(manifest.createdAt).toBeDefined() 349 + 350 + // Verify it's a valid ISO date string 351 + const date = new Date(manifest.createdAt) 352 + expect(date.toISOString()).toBe(manifest.createdAt) 353 + }) 354 + 355 + test('should create manifest with file count', () => { 356 + const root: Directory = { 357 + $type: 'place.wisp.fs#directory', 358 + type: 'directory', 359 + entries: [], 360 + } 361 + 362 + const manifest = createManifest('test-site', root, 42) 363 + 364 + expect(manifest.fileCount).toBe(42) 365 + expect(manifest.site).toBe('test-site') 366 + }) 367 + 368 + test('should create manifest with populated directory', () => { 369 + const mockBlob = createMockBlobRef('text/html', 100) 370 + 371 + const root: Directory = { 372 + $type: 'place.wisp.fs#directory', 373 + type: 'directory', 374 + entries: [ 375 + { 376 + name: 'index.html', 377 + node: { 378 + $type: 'place.wisp.fs#file', 379 + type: 'file', 380 + blob: mockBlob, 381 + }, 382 + }, 383 + ], 384 + } 385 + 386 + const manifest = createManifest('populated-site', root, 1) 387 + 388 + expect(manifest).toBeDefined() 389 + expect(manifest.site).toBe('populated-site') 390 + expect(manifest.root.entries).toHaveLength(1) 391 + }) 392 + }) 393 + 394 + describe('updateFileBlobs', () => { 395 + test('should update single file blob at root', () => { 396 + const directory: Directory = { 397 + $type: 'place.wisp.fs#directory', 398 + type: 'directory', 399 + entries: [ 400 + { 401 + name: 'index.html', 402 + node: { 403 + $type: 'place.wisp.fs#file', 404 + type: 'file', 405 + blob: undefined as any, 406 + }, 407 + }, 408 + ], 409 + } 410 + 411 + const mockBlob = createMockBlobRef('text/html', 100) 412 + const uploadResults: FileUploadResult[] = [ 413 + { 414 + hash: TEST_CID_STRING, 415 + blobRef: mockBlob, 416 + mimeType: 'text/html', 417 + }, 418 + ] 419 + 420 + const filePaths = ['index.html'] 421 + 422 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 423 + 424 + expect(updated.entries).toHaveLength(1) 425 + const fileNode = updated.entries[0].node 426 + 427 + if ('blob' in fileNode) { 428 + expect(fileNode.blob).toBeDefined() 429 + expect(fileNode.blob.mimeType).toBe('text/html') 430 + expect(fileNode.blob.size).toBe(100) 431 + } else { 432 + throw new Error('Expected file node') 433 + } 434 + }) 435 + 436 + test('should update files in nested directories', () => { 437 + const directory: Directory = { 438 + $type: 'place.wisp.fs#directory', 439 + type: 'directory', 440 + entries: [ 441 + { 442 + name: 'css', 443 + node: { 444 + $type: 'place.wisp.fs#directory', 445 + type: 'directory', 446 + entries: [ 447 + { 448 + name: 'styles.css', 449 + node: { 450 + $type: 'place.wisp.fs#file', 451 + type: 'file', 452 + blob: undefined as any, 453 + }, 454 + }, 455 + ], 456 + }, 457 + }, 458 + ], 459 + } 460 + 461 + const mockBlob = createMockBlobRef('text/css', 50) 462 + const uploadResults: FileUploadResult[] = [ 463 + { 464 + hash: TEST_CID_STRING, 465 + blobRef: mockBlob, 466 + mimeType: 'text/css', 467 + encoding: 'gzip', 468 + }, 469 + ] 470 + 471 + const filePaths = ['css/styles.css'] 472 + 473 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 474 + 475 + const cssDir = updated.entries[0] 476 + expect(cssDir.name).toBe('css') 477 + 478 + if ('entries' in cssDir.node) { 479 + const cssFile = cssDir.node.entries[0] 480 + expect(cssFile.name).toBe('styles.css') 481 + 482 + if ('blob' in cssFile.node) { 483 + expect(cssFile.node.blob.mimeType).toBe('text/css') 484 + if ('encoding' in cssFile.node) { 485 + expect(cssFile.node.encoding).toBe('gzip') 486 + } 487 + } else { 488 + throw new Error('Expected file node') 489 + } 490 + } else { 491 + throw new Error('Expected directory node') 492 + } 493 + }) 494 + 495 + test('should handle normalized paths with base folder removed', () => { 496 + const directory: Directory = { 497 + $type: 'place.wisp.fs#directory', 498 + type: 'directory', 499 + entries: [ 500 + { 501 + name: 'index.html', 502 + node: { 503 + $type: 'place.wisp.fs#file', 504 + type: 'file', 505 + blob: undefined as any, 506 + }, 507 + }, 508 + ], 509 + } 510 + 511 + const mockBlob = createMockBlobRef('text/html', 100) 512 + const uploadResults: FileUploadResult[] = [ 513 + { 514 + hash: TEST_CID_STRING, 515 + blobRef: mockBlob, 516 + }, 517 + ] 518 + 519 + // Path includes base folder that should be normalized 520 + const filePaths = ['dist/index.html'] 521 + 522 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 523 + 524 + const fileNode = updated.entries[0].node 525 + if ('blob' in fileNode) { 526 + expect(fileNode.blob).toBeDefined() 527 + } else { 528 + throw new Error('Expected file node') 529 + } 530 + }) 531 + 532 + test('should preserve file metadata (encoding, mimeType, base64)', () => { 533 + const directory: Directory = { 534 + $type: 'place.wisp.fs#directory', 535 + type: 'directory', 536 + entries: [ 537 + { 538 + name: 'data.json', 539 + node: { 540 + $type: 'place.wisp.fs#file', 541 + type: 'file', 542 + blob: undefined as any, 543 + }, 544 + }, 545 + ], 546 + } 547 + 548 + const mockBlob = createMockBlobRef('application/json', 200) 549 + const uploadResults: FileUploadResult[] = [ 550 + { 551 + hash: TEST_CID_STRING, 552 + blobRef: mockBlob, 553 + mimeType: 'application/json', 554 + encoding: 'gzip', 555 + base64: true, 556 + }, 557 + ] 558 + 559 + const filePaths = ['data.json'] 560 + 561 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 562 + 563 + const fileNode = updated.entries[0].node 564 + if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) { 565 + expect(fileNode.mimeType).toBe('application/json') 566 + expect(fileNode.encoding).toBe('gzip') 567 + expect(fileNode.base64).toBe(true) 568 + } else { 569 + throw new Error('Expected file node with metadata') 570 + } 571 + }) 572 + 573 + test('should handle multiple files at different directory levels', () => { 574 + const directory: Directory = { 575 + $type: 'place.wisp.fs#directory', 576 + type: 'directory', 577 + entries: [ 578 + { 579 + name: 'index.html', 580 + node: { 581 + $type: 'place.wisp.fs#file', 582 + type: 'file', 583 + blob: undefined as any, 584 + }, 585 + }, 586 + { 587 + name: 'assets', 588 + node: { 589 + $type: 'place.wisp.fs#directory', 590 + type: 'directory', 591 + entries: [ 592 + { 593 + name: 'logo.svg', 594 + node: { 595 + $type: 'place.wisp.fs#file', 596 + type: 'file', 597 + blob: undefined as any, 598 + }, 599 + }, 600 + ], 601 + }, 602 + }, 603 + ], 604 + } 605 + 606 + const htmlBlob = createMockBlobRef('text/html', 100) 607 + const svgBlob = createMockBlobRef('image/svg+xml', 500) 608 + 609 + const uploadResults: FileUploadResult[] = [ 610 + { 611 + hash: TEST_CID_STRING, 612 + blobRef: htmlBlob, 613 + }, 614 + { 615 + hash: TEST_CID_STRING, 616 + blobRef: svgBlob, 617 + }, 618 + ] 619 + 620 + const filePaths = ['index.html', 'assets/logo.svg'] 621 + 622 + const updated = updateFileBlobs(directory, uploadResults, filePaths) 623 + 624 + // Check root file 625 + const indexNode = updated.entries[0].node 626 + if ('blob' in indexNode) { 627 + expect(indexNode.blob.mimeType).toBe('text/html') 628 + } 629 + 630 + // Check nested file 631 + const assetsDir = updated.entries[1] 632 + if ('entries' in assetsDir.node) { 633 + const logoNode = assetsDir.node.entries[0].node 634 + if ('blob' in logoNode) { 635 + expect(logoNode.blob.mimeType).toBe('image/svg+xml') 636 + } 637 + } 638 + }) 639 + })
+52 -4
src/lib/wisp-utils.ts
··· 1 1 import type { BlobRef } from "@atproto/api"; 2 - import type { Record, Directory, File, Entry } from "../lexicon/types/place/wisp/fs"; 2 + import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs"; 3 + import { validateRecord } from "../lexicons/types/place/wisp/fs"; 4 + import { gzipSync } from 'zlib'; 3 5 4 6 export interface UploadedFile { 5 7 name: string; 6 8 content: Buffer; 7 9 mimeType: string; 8 10 size: number; 11 + compressed?: boolean; 12 + originalMimeType?: string; 9 13 } 10 14 11 15 export interface FileUploadResult { 12 16 hash: string; 13 17 blobRef: BlobRef; 18 + encoding?: 'gzip'; 19 + mimeType?: string; 20 + base64?: boolean; 14 21 } 15 22 16 23 export interface ProcessedDirectory { 17 24 directory: Directory; 18 25 fileCount: number; 26 + } 27 + 28 + /** 29 + * Determine if a file should be gzip compressed based on its MIME type 30 + */ 31 + export function shouldCompressFile(mimeType: string): boolean { 32 + // Compress text-based files 33 + const compressibleTypes = [ 34 + 'text/html', 35 + 'text/css', 36 + 'text/javascript', 37 + 'application/javascript', 38 + 'application/json', 39 + 'image/svg+xml', 40 + 'text/xml', 41 + 'application/xml', 42 + 'text/plain', 43 + 'application/x-javascript' 44 + ]; 45 + 46 + // Check if mime type starts with any compressible type 47 + return compressibleTypes.some(type => mimeType.startsWith(type)); 48 + } 49 + 50 + /** 51 + * Compress a file using gzip 52 + */ 53 + export function compressFile(content: Buffer): Buffer { 54 + return gzipSync(content, { level: 9 }); 19 55 } 20 56 21 57 /** ··· 126 162 root: Directory, 127 163 fileCount: number 128 164 ): Record { 129 - return { 165 + const manifest = { 130 166 $type: 'place.wisp.fs' as const, 131 167 site: siteName, 132 168 root, 133 169 fileCount, 134 170 createdAt: new Date().toISOString() 135 171 }; 172 + 173 + // Validate the manifest before returning 174 + const validationResult = validateRecord(manifest); 175 + if (!validationResult.success) { 176 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 177 + } 178 + 179 + return manifest; 136 180 } 137 181 138 182 /** ··· 159 203 }); 160 204 161 205 if (fileIndex !== -1 && uploadResults[fileIndex]) { 162 - const blobRef = uploadResults[fileIndex].blobRef; 206 + const result = uploadResults[fileIndex]; 207 + const blobRef = result.blobRef; 163 208 164 209 return { 165 210 ...entry, 166 211 node: { 167 212 $type: 'place.wisp.fs#file' as const, 168 213 type: 'file' as const, 169 - blob: blobRef 214 + blob: blobRef, 215 + ...(result.encoding && { encoding: result.encoding }), 216 + ...(result.mimeType && { mimeType: result.mimeType }), 217 + ...(result.base64 && { base64: result.base64 }) 170 218 } 171 219 }; 172 220 } else {
+305
src/routes/admin.ts
··· 1 + // Admin API routes 2 + import { Elysia, t } from 'elysia' 3 + import { adminAuth, requireAdmin } from '../lib/admin-auth' 4 + import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 + import { db } from '../lib/db' 6 + 7 + export const adminRoutes = () => 8 + new Elysia({ prefix: '/api/admin' }) 9 + // Login 10 + .post( 11 + '/login', 12 + async ({ body, cookie, set }) => { 13 + const { username, password } = body 14 + 15 + const valid = await adminAuth.verify(username, password) 16 + if (!valid) { 17 + set.status = 401 18 + return { error: 'Invalid credentials' } 19 + } 20 + 21 + const sessionId = adminAuth.createSession(username) 22 + 23 + // Set cookie 24 + cookie.admin_session.set({ 25 + value: sessionId, 26 + httpOnly: true, 27 + secure: process.env.NODE_ENV === 'production', 28 + sameSite: 'lax', 29 + maxAge: 24 * 60 * 60 // 24 hours 30 + }) 31 + 32 + return { success: true } 33 + }, 34 + { 35 + body: t.Object({ 36 + username: t.String(), 37 + password: t.String() 38 + }) 39 + } 40 + ) 41 + 42 + // Logout 43 + .post('/logout', ({ cookie }) => { 44 + const sessionId = cookie.admin_session?.value 45 + if (sessionId && typeof sessionId === 'string') { 46 + adminAuth.deleteSession(sessionId) 47 + } 48 + cookie.admin_session.remove() 49 + return { success: true } 50 + }) 51 + 52 + // Check auth status 53 + .get('/status', ({ cookie }) => { 54 + const sessionId = cookie.admin_session?.value 55 + if (!sessionId || typeof sessionId !== 'string') { 56 + return { authenticated: false } 57 + } 58 + 59 + const session = adminAuth.verifySession(sessionId) 60 + if (!session) { 61 + return { authenticated: false } 62 + } 63 + 64 + return { 65 + authenticated: true, 66 + username: session.username 67 + } 68 + }) 69 + 70 + // Get logs (protected) 71 + .get('/logs', async ({ query, cookie, set }) => { 72 + const check = requireAdmin({ cookie, set }) 73 + if (check) return check 74 + 75 + const filter: any = {} 76 + 77 + if (query.level) filter.level = query.level 78 + if (query.service) filter.service = query.service 79 + if (query.search) filter.search = query.search 80 + if (query.eventType) filter.eventType = query.eventType 81 + if (query.limit) filter.limit = parseInt(query.limit as string) 82 + 83 + // Get logs from main app 84 + const mainLogs = logCollector.getLogs(filter) 85 + 86 + // Get logs from hosting service 87 + let hostingLogs: any[] = [] 88 + try { 89 + const hostingPort = process.env.HOSTING_PORT || '3001' 90 + const params = new URLSearchParams() 91 + if (query.level) params.append('level', query.level as string) 92 + if (query.service) params.append('service', query.service as string) 93 + if (query.search) params.append('search', query.search as string) 94 + if (query.eventType) params.append('eventType', query.eventType as string) 95 + params.append('limit', String(filter.limit || 100)) 96 + 97 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`) 98 + if (response.ok) { 99 + const data = await response.json() 100 + hostingLogs = data.logs 101 + } 102 + } catch (err) { 103 + // Hosting service might not be running 104 + } 105 + 106 + // Merge and sort by timestamp 107 + const allLogs = [...mainLogs, ...hostingLogs].sort((a, b) => 108 + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() 109 + ) 110 + 111 + return { logs: allLogs.slice(0, filter.limit || 100) } 112 + }) 113 + 114 + // Get errors (protected) 115 + .get('/errors', async ({ query, cookie, set }) => { 116 + const check = requireAdmin({ cookie, set }) 117 + if (check) return check 118 + 119 + const filter: any = {} 120 + 121 + if (query.service) filter.service = query.service 122 + if (query.limit) filter.limit = parseInt(query.limit as string) 123 + 124 + // Get errors from main app 125 + const mainErrors = errorTracker.getErrors(filter) 126 + 127 + // Get errors from hosting service 128 + let hostingErrors: any[] = [] 129 + try { 130 + const hostingPort = process.env.HOSTING_PORT || '3001' 131 + const params = new URLSearchParams() 132 + if (query.service) params.append('service', query.service as string) 133 + params.append('limit', String(filter.limit || 100)) 134 + 135 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`) 136 + if (response.ok) { 137 + const data = await response.json() 138 + hostingErrors = data.errors 139 + } 140 + } catch (err) { 141 + // Hosting service might not be running 142 + } 143 + 144 + // Merge and sort by last seen 145 + const allErrors = [...mainErrors, ...hostingErrors].sort((a, b) => 146 + new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime() 147 + ) 148 + 149 + return { errors: allErrors.slice(0, filter.limit || 100) } 150 + }) 151 + 152 + // Get metrics (protected) 153 + .get('/metrics', async ({ query, cookie, set }) => { 154 + const check = requireAdmin({ cookie, set }) 155 + if (check) return check 156 + 157 + const timeWindow = query.timeWindow 158 + ? parseInt(query.timeWindow as string) 159 + : 3600000 // 1 hour default 160 + 161 + const mainAppStats = metricsCollector.getStats('main-app', timeWindow) 162 + const overallStats = metricsCollector.getStats(undefined, timeWindow) 163 + 164 + // Get hosting service stats from its own endpoint 165 + let hostingServiceStats = { 166 + totalRequests: 0, 167 + avgDuration: 0, 168 + p50Duration: 0, 169 + p95Duration: 0, 170 + p99Duration: 0, 171 + errorRate: 0, 172 + requestsPerMinute: 0 173 + } 174 + 175 + try { 176 + const hostingPort = process.env.HOSTING_PORT || '3001' 177 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 178 + if (response.ok) { 179 + const data = await response.json() 180 + hostingServiceStats = data.stats 181 + } 182 + } catch (err) { 183 + // Hosting service might not be running 184 + } 185 + 186 + return { 187 + overall: overallStats, 188 + mainApp: mainAppStats, 189 + hostingService: hostingServiceStats, 190 + timeWindow 191 + } 192 + }) 193 + 194 + // Get database stats (protected) 195 + .get('/database', async ({ cookie, set }) => { 196 + const check = requireAdmin({ cookie, set }) 197 + if (check) return check 198 + 199 + try { 200 + // Get total counts 201 + const allSitesResult = await db`SELECT COUNT(*) as count FROM sites` 202 + const wispSubdomainsResult = await db`SELECT COUNT(*) as count FROM domains WHERE domain LIKE '%.wisp.place'` 203 + const customDomainsResult = await db`SELECT COUNT(*) as count FROM custom_domains WHERE verified = true` 204 + 205 + // Get recent sites (including those without domains) 206 + const recentSites = await db` 207 + SELECT 208 + s.did, 209 + s.rkey, 210 + s.display_name, 211 + s.created_at, 212 + d.domain as subdomain 213 + FROM sites s 214 + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' 215 + ORDER BY s.created_at DESC 216 + LIMIT 10 217 + ` 218 + 219 + // Get recent domains 220 + const recentDomains = await db`SELECT domain, did, rkey, verified, created_at FROM custom_domains ORDER BY created_at DESC LIMIT 10` 221 + 222 + return { 223 + stats: { 224 + totalSites: allSitesResult[0].count, 225 + totalWispSubdomains: wispSubdomainsResult[0].count, 226 + totalCustomDomains: customDomainsResult[0].count 227 + }, 228 + recentSites: recentSites, 229 + recentDomains: recentDomains 230 + } 231 + } catch (error) { 232 + set.status = 500 233 + return { 234 + error: 'Failed to fetch database stats', 235 + message: error instanceof Error ? error.message : String(error) 236 + } 237 + } 238 + }) 239 + 240 + // Get sites listing (protected) 241 + .get('/sites', async ({ query, cookie, set }) => { 242 + const check = requireAdmin({ cookie, set }) 243 + if (check) return check 244 + 245 + const limit = query.limit ? parseInt(query.limit as string) : 50 246 + const offset = query.offset ? parseInt(query.offset as string) : 0 247 + 248 + try { 249 + const sites = await db` 250 + SELECT 251 + s.did, 252 + s.rkey, 253 + s.display_name, 254 + s.created_at, 255 + d.domain as subdomain 256 + FROM sites s 257 + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' 258 + ORDER BY s.created_at DESC 259 + LIMIT ${limit} OFFSET ${offset} 260 + ` 261 + 262 + const customDomains = await db` 263 + SELECT 264 + domain, 265 + did, 266 + rkey, 267 + verified, 268 + created_at 269 + FROM custom_domains 270 + ORDER BY created_at DESC 271 + LIMIT ${limit} OFFSET ${offset} 272 + ` 273 + 274 + return { 275 + sites: sites, 276 + customDomains: customDomains 277 + } 278 + } catch (error) { 279 + set.status = 500 280 + return { 281 + error: 'Failed to fetch sites', 282 + message: error instanceof Error ? error.message : String(error) 283 + } 284 + } 285 + }) 286 + 287 + // Get system health (protected) 288 + .get('/health', ({ cookie, set }) => { 289 + const check = requireAdmin({ cookie, set }) 290 + if (check) return check 291 + 292 + const uptime = process.uptime() 293 + const memory = process.memoryUsage() 294 + 295 + return { 296 + uptime: Math.floor(uptime), 297 + memory: { 298 + heapUsed: Math.round(memory.heapUsed / 1024 / 1024), // MB 299 + heapTotal: Math.round(memory.heapTotal / 1024 / 1024), // MB 300 + rss: Math.round(memory.rss / 1024 / 1024) // MB 301 + }, 302 + timestamp: new Date().toISOString() 303 + } 304 + }) 305 +
+20 -14
src/routes/auth.ts
··· 3 3 import { getSitesByDid, getDomainByDid } from '../lib/db' 4 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 5 import { authenticateRequest } from '../lib/wisp-auth' 6 + import { logger } from '../lib/observability' 6 7 7 8 export const authRoutes = (client: NodeOAuthClient) => new Elysia() 8 9 .post('/api/auth/signin', async (c) => { 10 + let handle = 'unknown' 9 11 try { 10 - const { handle } = await c.request.json() 12 + const body = c.body as { handle: string } 13 + handle = body.handle 14 + logger.info('Sign-in attempt', { handle }) 11 15 const state = crypto.randomUUID() 12 16 const url = await client.authorize(handle, { state }) 17 + logger.info('Authorization URL generated', { handle }) 13 18 return { url: url.toString() } 14 19 } catch (err) { 15 - console.error('Signin error', err) 16 - return { error: 'Authentication failed' } 20 + logger.error('Signin error', err, { handle }) 21 + console.error('[Auth] Full error:', err) 22 + return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) } 17 23 } 18 24 }) 19 25 .get('/api/auth/callback', async (c) => { ··· 25 31 const { session } = await client.callback(params) 26 32 27 33 if (!session) { 28 - console.error('[Auth] OAuth callback failed: no session returned') 34 + logger.error('[Auth] OAuth callback failed: no session returned') 29 35 return c.redirect('/?error=auth_failed') 30 36 } 31 37 ··· 33 39 cookieSession.did.value = session.did 34 40 35 41 // Sync sites from PDS to database cache 36 - console.log('[Auth] Syncing sites from PDS for', session.did) 42 + logger.debug('[Auth] Syncing sites from PDS for', session.did) 37 43 try { 38 44 const syncResult = await syncSitesFromPDS(session.did, session) 39 - console.log(`[Auth] Sync complete: ${syncResult.synced} sites synced`) 45 + logger.debug(`[Auth] Sync complete: ${syncResult.synced} sites synced`) 40 46 if (syncResult.errors.length > 0) { 41 - console.warn('[Auth] Sync errors:', syncResult.errors) 47 + logger.debug('[Auth] Sync errors:', syncResult.errors) 42 48 } 43 49 } catch (err) { 44 - console.error('[Auth] Failed to sync sites:', err) 50 + logger.error('[Auth] Failed to sync sites', err) 45 51 // Don't fail auth if sync fails, just log it 46 52 } 47 53 ··· 57 63 return c.redirect('/editor') 58 64 } catch (err) { 59 65 // This catches state validation failures and other OAuth errors 60 - console.error('[Auth] OAuth callback error:', err) 66 + logger.error('[Auth] OAuth callback error', err) 61 67 return c.redirect('/?error=auth_failed') 62 68 } 63 69 }) ··· 74 80 if (did && typeof did === 'string') { 75 81 try { 76 82 await client.revoke(did) 77 - console.log('[Auth] Revoked OAuth session for', did) 83 + logger.debug('[Auth] Revoked OAuth session for', did) 78 84 } catch (err) { 79 - console.error('[Auth] Failed to revoke session:', err) 85 + logger.error('[Auth] Failed to revoke session', err) 80 86 // Continue with logout even if revoke fails 81 87 } 82 88 } 83 89 84 90 return { success: true } 85 91 } catch (err) { 86 - console.error('[Auth] Logout error:', err) 92 + logger.error('[Auth] Logout error', err) 87 93 return { error: 'Logout failed' } 88 94 } 89 95 }) ··· 100 106 did: auth.did 101 107 } 102 108 } catch (err) { 103 - console.error('[Auth] Status check error:', err) 109 + logger.error('[Auth] Status check error', err) 104 110 return { authenticated: false } 105 111 } 106 - }) 112 + })
+73 -14
src/routes/domain.ts
··· 20 20 } from '../lib/db' 21 21 import { createHash } from 'crypto' 22 22 import { verifyCustomDomain } from '../lib/dns-verify' 23 + import { logger } from '../lib/logger' 23 24 24 25 export const domainRoutes = (client: NodeOAuthClient) => 25 26 new Elysia({ prefix: '/api/domain' }) ··· 43 44 domain: toDomain(handle) 44 45 }; 45 46 } catch (err) { 46 - console.error("domain/check error", err); 47 + logger.error('[Domain] Check error', err); 47 48 return { 48 49 available: false 49 50 }; ··· 69 70 return { registered: false }; 70 71 } 71 72 } catch (err) { 72 - console.error("domain/registered error", err); 73 + logger.error('[Domain] Registered check error', err); 73 74 set.status = 500; 74 75 return { error: 'Failed to check domain' }; 75 76 } ··· 118 119 119 120 return { success: true, domain }; 120 121 } catch (err) { 121 - console.error("domain/claim error", err); 122 + logger.error('[Domain] Claim error', err); 122 123 throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`); 123 124 } 124 125 }) ··· 160 161 161 162 return { success: true, domain }; 162 163 } catch (err) { 163 - console.error("domain/update error", err); 164 + logger.error('[Domain] Update error', err); 164 165 throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); 165 166 } 166 167 }) ··· 169 170 const { domain } = body as { domain: string }; 170 171 const domainLower = domain.toLowerCase().trim(); 171 172 172 - // Basic validation 173 - if (!domainLower || domainLower.length < 3) { 174 - throw new Error('Invalid domain'); 173 + // Enhanced domain validation 174 + // 1. Length check (RFC 1035: labels 1-63 chars, total max 253) 175 + if (!domainLower || domainLower.length < 3 || domainLower.length > 253) { 176 + throw new Error('Invalid domain: must be 3-253 characters'); 177 + } 178 + 179 + // 2. Basic format validation 180 + // - Must contain at least one dot (require TLD) 181 + // - Valid characters: a-z, 0-9, hyphen, dot 182 + // - No consecutive dots, no leading/trailing dots or hyphens 183 + const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; 184 + if (!domainPattern.test(domainLower)) { 185 + throw new Error('Invalid domain format'); 186 + } 187 + 188 + // 3. Validate each label (part between dots) 189 + const labels = domainLower.split('.'); 190 + for (const label of labels) { 191 + if (label.length === 0 || label.length > 63) { 192 + throw new Error('Invalid domain: label length must be 1-63 characters'); 193 + } 194 + if (label.startsWith('-') || label.endsWith('-')) { 195 + throw new Error('Invalid domain: labels cannot start or end with hyphen'); 196 + } 197 + } 198 + 199 + // 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs) 200 + const tld = labels[labels.length - 1]; 201 + if (tld.length < 2 || /^\d+$/.test(tld)) { 202 + throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric'); 203 + } 204 + 205 + // 5. Homograph attack protection - block domains with mixed scripts or confusables 206 + // Block non-ASCII characters (Punycode domains should be pre-converted) 207 + if (!/^[a-z0-9.-]+$/.test(domainLower)) { 208 + throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed'); 209 + } 210 + 211 + // 6. Block localhost, internal IPs, and reserved domains 212 + const blockedDomains = [ 213 + 'localhost', 214 + 'example.com', 215 + 'example.org', 216 + 'example.net', 217 + 'test', 218 + 'invalid', 219 + 'local' 220 + ]; 221 + const blockedPatterns = [ 222 + /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs 223 + /^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address 224 + ]; 225 + 226 + if (blockedDomains.includes(domainLower)) { 227 + throw new Error('Invalid domain: reserved or blocked domain'); 228 + } 229 + 230 + for (const pattern of blockedPatterns) { 231 + if (pattern.test(domainLower)) { 232 + throw new Error('Invalid domain: IP addresses not allowed'); 233 + } 175 234 } 176 235 177 236 // Check if already exists ··· 193 252 verified: false 194 253 }; 195 254 } catch (err) { 196 - console.error('custom domain add error', err); 255 + logger.error('[Domain] Custom domain add error', err); 197 256 throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 198 257 } 199 258 }) ··· 208 267 } 209 268 210 269 // Verify DNS records (TXT + CNAME) 211 - console.log(`Verifying custom domain: ${domainInfo.domain}`); 270 + logger.debug(`[Domain] Verifying custom domain: ${domainInfo.domain}`); 212 271 const result = await verifyCustomDomain(domainInfo.domain, auth.did, id); 213 272 214 273 // Update verification status in database ··· 221 280 found: result.found 222 281 }; 223 282 } catch (err) { 224 - console.error('custom domain verify error', err); 283 + logger.error('[Domain] Custom domain verify error', err); 225 284 throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 226 285 } 227 286 }) ··· 244 303 245 304 return { success: true }; 246 305 } catch (err) { 247 - console.error('custom domain delete error', err); 306 + logger.error('[Domain] Custom domain delete error', err); 248 307 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 249 308 } 250 309 }) ··· 257 316 258 317 return { success: true }; 259 318 } catch (err) { 260 - console.error('wisp domain map error', err); 319 + logger.error('[Domain] Wisp domain map error', err); 261 320 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 262 321 } 263 322 }) ··· 277 336 } 278 337 279 338 // Update custom domain to point to this site 280 - await updateCustomDomainRkey(id, siteRkey || 'self'); 339 + await updateCustomDomainRkey(id, siteRkey); 281 340 282 341 return { success: true }; 283 342 } catch (err) { 284 - console.error('custom domain map error', err); 343 + logger.error('[Domain] Custom domain map error', err); 285 344 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 286 345 } 287 346 });
+60
src/routes/site.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { requireAuth } from '../lib/wisp-auth' 3 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 + import { Agent } from '@atproto/api' 5 + import { deleteSite } from '../lib/db' 6 + import { logger } from '../lib/logger' 7 + 8 + export const siteRoutes = (client: NodeOAuthClient) => 9 + new Elysia({ prefix: '/api/site' }) 10 + .derive(async ({ cookie }) => { 11 + const auth = await requireAuth(client, cookie) 12 + return { auth } 13 + }) 14 + .delete('/:rkey', async ({ params, auth }) => { 15 + const { rkey } = params 16 + 17 + if (!rkey) { 18 + return { 19 + success: false, 20 + error: 'Site rkey is required' 21 + } 22 + } 23 + 24 + try { 25 + // Create agent with OAuth session 26 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 27 + 28 + // Delete the record from AT Protocol 29 + try { 30 + await agent.com.atproto.repo.deleteRecord({ 31 + repo: auth.did, 32 + collection: 'place.wisp.fs', 33 + rkey: rkey 34 + }) 35 + logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`) 36 + } catch (err) { 37 + logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err) 38 + throw new Error('Failed to delete site from AT Protocol') 39 + } 40 + 41 + // Delete from database 42 + const result = await deleteSite(auth.did, rkey) 43 + if (!result.success) { 44 + throw new Error('Failed to delete site from database') 45 + } 46 + 47 + logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`) 48 + 49 + return { 50 + success: true, 51 + message: 'Site deleted successfully' 52 + } 53 + } catch (err) { 54 + logger.error('[Site] Delete error', err) 55 + return { 56 + success: false, 57 + error: err instanceof Error ? err.message : 'Failed to delete site' 58 + } 59 + } 60 + })
+8 -7
src/routes/user.ts
··· 4 4 import { Agent } from '@atproto/api' 5 5 import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db' 6 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 + import { logger } from '../lib/logger' 7 8 8 9 export const userRoutes = (client: NodeOAuthClient) => 9 10 new Elysia({ prefix: '/api/user' }) ··· 27 28 sitesCount: sites.length 28 29 } 29 30 } catch (err) { 30 - console.error('user/status error', err) 31 + logger.error('[User] Status error', err) 31 32 throw new Error('Failed to get user status') 32 33 } 33 34 }) ··· 41 42 const profile = await agent.getProfile({ actor: auth.did }) 42 43 handle = profile.data.handle 43 44 } catch (err) { 44 - console.error('Failed to fetch profile:', err) 45 + logger.error('[User] Failed to fetch profile', err) 45 46 } 46 47 47 48 return { ··· 49 50 handle 50 51 } 51 52 } catch (err) { 52 - console.error('user/info error', err) 53 + logger.error('[User] Info error', err) 53 54 throw new Error('Failed to get user info') 54 55 } 55 56 }) ··· 58 59 const sites = await getSitesByDid(auth.did) 59 60 return { sites } 60 61 } catch (err) { 61 - console.error('user/sites error', err) 62 + logger.error('[User] Sites error', err) 62 63 throw new Error('Failed to get sites') 63 64 } 64 65 }) ··· 78 79 customDomains 79 80 } 80 81 } catch (err) { 81 - console.error('user/domains error', err) 82 + logger.error('[User] Domains error', err) 82 83 throw new Error('Failed to get domains') 83 84 } 84 85 }) 85 86 .post('/sync', async ({ auth }) => { 86 87 try { 87 - console.log('[User] Manual sync requested for', auth.did) 88 + logger.debug('[User] Manual sync requested for', auth.did) 88 89 const result = await syncSitesFromPDS(auth.did, auth.session) 89 90 90 91 return { ··· 93 94 errors: result.errors 94 95 } 95 96 } catch (err) { 96 - console.error('user/sync error', err) 97 + logger.error('[User] Sync error', err) 97 98 throw new Error('Failed to sync sites') 98 99 } 99 100 })
+73 -62
src/routes/wisp.ts
··· 7 7 type FileUploadResult, 8 8 processUploadedFiles, 9 9 createManifest, 10 - updateFileBlobs 10 + updateFileBlobs, 11 + shouldCompressFile, 12 + compressFile 11 13 } from '../lib/wisp-utils' 12 14 import { upsertSite } from '../lib/db' 15 + import { logger } from '../lib/observability' 16 + import { validateRecord } from '../lexicons/types/place/wisp/fs' 17 + import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants' 13 18 14 - /** 15 - * Validate site name (rkey) according to AT Protocol specifications 16 - * - Must be 1-512 characters 17 - * - Can only contain: alphanumeric, dots, dashes, underscores, tildes, colons 18 - * - Cannot be just "." or ".." 19 - * - Cannot contain path traversal sequences 20 - */ 21 19 function isValidSiteName(siteName: string): boolean { 22 20 if (!siteName || typeof siteName !== 'string') return false; 23 21 ··· 79 77 createdAt: new Date().toISOString() 80 78 }; 81 79 80 + // Validate the manifest 81 + const validationResult = validateRecord(emptyManifest); 82 + if (!validationResult.success) { 83 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 84 + } 85 + 82 86 // Use site name as rkey 83 87 const rkey = siteName; 84 88 ··· 107 111 // Elysia gives us File objects directly, handle both single file and array 108 112 const fileArray = Array.isArray(files) ? files : [files]; 109 113 const uploadedFiles: UploadedFile[] = []; 114 + const skippedFiles: Array<{ name: string; reason: string }> = []; 110 115 111 - // Define allowed file extensions for static site hosting 112 - const allowedExtensions = new Set([ 113 - // HTML 114 - '.html', '.htm', 115 - // CSS 116 - '.css', 117 - // JavaScript 118 - '.js', '.mjs', '.jsx', '.ts', '.tsx', 119 - // Images 120 - '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 121 - // Fonts 122 - '.woff', '.woff2', '.ttf', '.otf', '.eot', 123 - // Documents 124 - '.pdf', '.txt', 125 - // JSON (for config files, but not .map files) 126 - '.json', 127 - // Audio/Video 128 - '.mp3', '.mp4', '.webm', '.ogg', '.wav', 129 - // Other web assets 130 - '.xml', '.rss', '.atom' 131 - ]); 132 116 133 - // Files to explicitly exclude 134 - const excludedFiles = new Set([ 135 - '.map', '.DS_Store', 'Thumbs.db' 136 - ]); 137 117 138 118 for (let i = 0; i < fileArray.length; i++) { 139 119 const file = fileArray[i]; 140 - const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 141 - 142 - // Skip excluded files 143 - if (excludedFiles.has(fileExtension)) { 144 - continue; 145 - } 146 - 147 - // Skip files that aren't in allowed extensions 148 - if (!allowedExtensions.has(fileExtension)) { 149 - continue; 150 - } 151 120 152 121 // Skip files that are too large (limit to 100MB per file) 153 - const maxSize = 100 * 1024 * 1024; // 100MB 122 + const maxSize = MAX_FILE_SIZE; // 100MB 154 123 if (file.size > maxSize) { 124 + skippedFiles.push({ 125 + name: file.name, 126 + reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)` 127 + }); 155 128 continue; 156 129 } 157 130 158 131 const arrayBuffer = await file.arrayBuffer(); 132 + const originalContent = Buffer.from(arrayBuffer); 133 + const originalMimeType = file.type || 'application/octet-stream'; 134 + 135 + // Compress and base64 encode ALL files 136 + const compressedContent = compressFile(originalContent); 137 + // Base64 encode the gzipped content to prevent PDS content sniffing 138 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 139 + const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 140 + logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 141 + 159 142 uploadedFiles.push({ 160 143 name: file.name, 161 - content: Buffer.from(arrayBuffer), 162 - mimeType: 'application/octet-stream', 163 - size: file.size 144 + content: base64Content, 145 + mimeType: originalMimeType, 146 + size: base64Content.length, 147 + compressed: true, 148 + originalMimeType 164 149 }); 165 150 } 166 151 167 152 // Check total size limit (300MB) 168 153 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 169 - const maxTotalSize = 300 * 1024 * 1024; // 300MB 154 + const maxTotalSize = MAX_SITE_SIZE; // 300MB 170 155 171 156 if (totalSize > maxTotalSize) { 172 157 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 173 158 } 174 159 160 + // Check file count limit (2000 files) 161 + if (uploadedFiles.length > MAX_FILE_COUNT) { 162 + throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`); 163 + } 164 + 175 165 if (uploadedFiles.length === 0) { 176 166 177 167 // Create empty manifest ··· 185 175 fileCount: 0, 186 176 createdAt: new Date().toISOString() 187 177 }; 178 + 179 + // Validate the manifest 180 + const validationResult = validateRecord(emptyManifest); 181 + if (!validationResult.success) { 182 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 183 + } 188 184 189 185 // Use site name as rkey 190 186 const rkey = siteName; ··· 204 200 cid: record.data.cid, 205 201 fileCount: 0, 206 202 siteName, 203 + skippedFiles, 207 204 message: 'Site created but no valid web files were found to upload' 208 205 }; 209 206 } ··· 211 208 // Process files into directory structure 212 209 const { directory, fileCount } = processUploadedFiles(uploadedFiles); 213 210 214 - // Upload files as blobs in parallel (always as octet-stream) 211 + // Upload files as blobs in parallel 212 + // For compressed files, we upload as octet-stream and store the original MIME type in metadata 213 + // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues 215 214 const uploadPromises = uploadedFiles.map(async (file, i) => { 216 215 try { 216 + // If compressed, always upload as octet-stream 217 + // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline 218 + const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') 219 + ? 'application/octet-stream' 220 + : file.mimeType; 221 + 222 + const compressionInfo = file.compressed ? ' (gzipped)' : ''; 223 + logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 224 + 217 225 const uploadResult = await agent.com.atproto.repo.uploadBlob( 218 226 file.content, 219 227 { 220 - encoding: 'application/octet-stream' 228 + encoding: uploadMimeType 221 229 } 222 230 ); 223 231 224 - const sentMimeType = file.mimeType; 225 232 const returnedBlobRef = uploadResult.data.blob; 226 233 227 234 // Use the blob ref exactly as returned from PDS 228 235 return { 229 236 result: { 230 237 hash: returnedBlobRef.ref.toString(), 231 - blobRef: returnedBlobRef 238 + blobRef: returnedBlobRef, 239 + ...(file.compressed && { 240 + encoding: 'gzip' as const, 241 + mimeType: file.originalMimeType || file.mimeType, 242 + base64: true 243 + }) 232 244 }, 233 245 filePath: file.name, 234 - sentMimeType, 246 + sentMimeType: file.mimeType, 235 247 returnedMimeType: returnedBlobRef.mimeType 236 248 }; 237 249 } catch (uploadError) { 238 - console.error(`โŒ Upload failed for ${file.name}:`, uploadError); 250 + logger.error('Upload failed for file', uploadError); 239 251 throw uploadError; 240 252 } 241 253 }); ··· 265 277 record: manifest 266 278 }); 267 279 } catch (putRecordError: any) { 268 - console.error('\nโŒ Failed to create record on PDS'); 269 - console.error('Error:', putRecordError.message); 280 + logger.error('Failed to create record on PDS', putRecordError); 270 281 271 282 throw putRecordError; 272 283 } ··· 279 290 uri: record.data.uri, 280 291 cid: record.data.cid, 281 292 fileCount, 282 - siteName 293 + siteName, 294 + skippedFiles, 295 + uploadedCount: uploadedFiles.length 283 296 }; 284 297 285 298 return result; 286 299 } catch (error) { 287 - console.error('โŒ Upload error:', error); 288 - console.error('Error details:', { 300 + logger.error('Upload error', error, { 289 301 message: error instanceof Error ? error.message : 'Unknown error', 290 - stack: error instanceof Error ? error.stack : undefined, 291 302 name: error instanceof Error ? error.name : undefined 292 303 }); 293 304 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 294 305 } 295 306 } 296 - ) 307 + )
+40
testDeploy/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Wisp.place Test Site</title> 7 + <style> 8 + body { 9 + font-family: system-ui, -apple-system, sans-serif; 10 + max-width: 800px; 11 + margin: 4rem auto; 12 + padding: 0 2rem; 13 + line-height: 1.6; 14 + } 15 + h1 { 16 + color: #333; 17 + } 18 + .info { 19 + background: #f0f0f0; 20 + padding: 1rem; 21 + border-radius: 8px; 22 + margin: 2rem 0; 23 + } 24 + </style> 25 + </head> 26 + <body> 27 + <h1>Hello from Wisp.place!</h1> 28 + <p>This is a test deployment using the wisp-cli and Tangled Spindles CI/CD.</p> 29 + 30 + <div class="info"> 31 + <h2>About this deployment</h2> 32 + <p>This site was deployed to the AT Protocol using:</p> 33 + <ul> 34 + <li>Wisp.place CLI (Rust)</li> 35 + <li>Tangled Spindles CI/CD</li> 36 + <li>AT Protocol for decentralized hosting</li> 37 + </ul> 38 + </div> 39 + </body> 40 + </html>