+8
.dockerignore
+8
.dockerignore
+4
-1
.gitignore
+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
+3
.gitmodules
+50
.tangled/workflows/deploy-wisp.yml
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+9
cli/src/mod.rs
+1230
cli/src/place_wisp/fs.rs
+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
+6
cli/src/place_wisp.rs
+63
crates.nix
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
public/admin/styles.css
···
1
+
@import "tailwindcss";
+23
public/components/ui/code-block.tsx
+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
+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
+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
+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
+1
public/editor/index.html
public/favicon.ico
public/favicon.ico
This is a binary file and will not be displayed.
+14
public/favicon.svg
+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
+1
public/index.html
+265
-250
public/index.tsx
+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
+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
+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
+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
+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
+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
+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
-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
-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
-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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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>