+8
.dockerignore
+8
.dockerignore
+1
.gitignore
+1
.gitignore
+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
-
*/
+179
bun.lock
+179
bun.lock
···
26
26
"lucide-react": "^0.546.0",
27
27
"react": "^19.2.0",
28
28
"react-dom": "^19.2.0",
29
+
"react-shiki": "^0.9.0",
29
30
"tailwind-merge": "^3.3.1",
30
31
"tailwindcss": "4",
31
32
"tw-animate-css": "^1.4.0",
···
279
280
280
281
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
281
282
283
+
"@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
284
+
285
+
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
286
+
287
+
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
288
+
289
+
"@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
290
+
291
+
"@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
292
+
293
+
"@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
294
+
295
+
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
296
+
282
297
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
283
298
284
299
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
291
306
292
307
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
293
308
309
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
310
+
311
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
312
+
313
+
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
314
+
315
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
316
+
317
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
318
+
319
+
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
320
+
294
321
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
295
322
296
323
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
298
325
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
299
326
300
327
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
328
+
329
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
330
+
331
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
301
332
302
333
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
303
334
···
347
378
348
379
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
349
380
381
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
382
+
350
383
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
351
384
385
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
386
+
387
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
388
+
389
+
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
390
+
391
+
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
392
+
352
393
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
353
394
354
395
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
362
403
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
363
404
364
405
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
406
+
407
+
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
365
408
366
409
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
367
410
···
378
421
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
379
422
380
423
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
424
+
425
+
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
381
426
382
427
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
383
428
429
+
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
430
+
384
431
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
385
432
386
433
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
387
434
388
435
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
436
+
437
+
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
389
438
390
439
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
391
440
···
406
455
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
407
456
408
457
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
458
+
459
+
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
409
460
410
461
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
411
462
···
453
504
454
505
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
455
506
507
+
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
508
+
509
+
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
510
+
511
+
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
512
+
513
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
514
+
456
515
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
457
516
458
517
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
463
522
464
523
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
465
524
525
+
"inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="],
526
+
466
527
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
467
528
468
529
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
469
530
470
531
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
471
532
533
+
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
534
+
535
+
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
536
+
472
537
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
473
538
539
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
540
+
474
541
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
475
542
543
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
544
+
476
545
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
477
546
478
547
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
···
480
549
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
481
550
482
551
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
552
+
553
+
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
483
554
484
555
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
485
556
···
487
558
488
559
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
489
560
561
+
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
562
+
563
+
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
564
+
565
+
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
566
+
567
+
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
568
+
569
+
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
570
+
571
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
572
+
573
+
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
574
+
575
+
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
576
+
490
577
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
491
578
492
579
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
493
580
494
581
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
495
582
583
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
584
+
585
+
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
586
+
587
+
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
588
+
589
+
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
590
+
591
+
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
592
+
593
+
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
594
+
595
+
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
596
+
597
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
598
+
599
+
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
600
+
601
+
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
602
+
603
+
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
604
+
605
+
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
606
+
607
+
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
608
+
609
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
610
+
611
+
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
612
+
613
+
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
614
+
615
+
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
616
+
617
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
618
+
619
+
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
620
+
621
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
622
+
623
+
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
624
+
496
625
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
497
626
498
627
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
517
646
518
647
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
519
648
649
+
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
650
+
651
+
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
652
+
520
653
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
654
+
655
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
521
656
522
657
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
523
658
···
541
676
542
677
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
543
678
679
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
680
+
544
681
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
545
682
546
683
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
···
563
700
564
701
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
565
702
703
+
"react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="],
704
+
566
705
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
567
706
568
707
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
569
708
570
709
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
571
710
711
+
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
712
+
713
+
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
714
+
715
+
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
716
+
572
717
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
573
718
574
719
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
···
589
734
590
735
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
591
736
737
+
"shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
738
+
592
739
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
593
740
594
741
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
600
747
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
601
748
602
749
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
750
+
751
+
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
603
752
604
753
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
605
754
···
609
758
610
759
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
611
760
761
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
762
+
612
763
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
613
764
614
765
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
615
766
767
+
"style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="],
768
+
769
+
"style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="],
770
+
616
771
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
617
772
618
773
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
···
630
785
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
631
786
632
787
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
788
+
789
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
633
790
634
791
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
635
792
···
651
808
652
809
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
653
810
811
+
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
812
+
813
+
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
814
+
815
+
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
816
+
817
+
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
818
+
819
+
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
820
+
654
821
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
655
822
656
823
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
···
661
828
662
829
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
663
830
831
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
832
+
833
+
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
834
+
664
835
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
665
836
666
837
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
···
677
848
678
849
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
679
850
851
+
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
852
+
680
853
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
681
854
682
855
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
683
856
684
857
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
685
858
859
+
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
860
+
861
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
862
+
686
863
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
687
864
688
865
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
692
869
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
693
870
694
871
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
872
+
873
+
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
695
874
696
875
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
697
876
}
+420
-25
claude.md
+420
-25
claude.md
···
1
-
Wisp.place - Decentralized Static Site Hosting
1
+
# Wisp.place - Codebase Overview
2
+
3
+
**Project URL**: https://wisp.place
4
+
5
+
A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution.
6
+
7
+
---
8
+
9
+
## ๐๏ธ Architecture Overview
10
+
11
+
### Multi-Part System
12
+
1. **Main Backend** (`/src`) - OAuth, site management, custom domains
13
+
2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
14
+
3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
15
+
4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
16
+
17
+
### Tech Stack
18
+
- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
19
+
- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
20
+
- **CLI**: Rust with Jacquard (AT Protocol library)
21
+
- **Database**: PostgreSQL for session/domain/site caching
22
+
- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
23
+
24
+
---
25
+
26
+
## ๐ Directory Structure
27
+
28
+
### `/src` - Main Backend Server
29
+
**Purpose**: Core server handling OAuth, site management, custom domains, admin features
30
+
31
+
**Key Routes**:
32
+
- `/api/auth/*` - OAuth signin/callback/logout/status
33
+
- `/api/domain/*` - Custom domain management (BYOD)
34
+
- `/wisp/*` - Site upload and management
35
+
- `/api/user/*` - User info and site listing
36
+
- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
37
+
38
+
**Key Files**:
39
+
- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
40
+
- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
41
+
- `lib/db.ts` - PostgreSQL schema and queries for all tables
42
+
- `lib/wisp-auth.ts` - Cookie-based authentication middleware
43
+
- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
44
+
- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
45
+
- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
46
+
- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
47
+
- `lib/admin-auth.ts` - Simple username/password admin authentication
48
+
- `lib/observability.ts` - Logging, error tracking, metrics collection
49
+
- `routes/auth.ts` - OAuth flow handlers
50
+
- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
51
+
- `routes/domain.ts` - Domain claiming/verification API
52
+
- `routes/user.ts` - User status/info/sites listing
53
+
- `routes/site.ts` - Site metadata and file retrieval
54
+
- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
55
+
56
+
### `/lexicons` & `src/lexicons/`
57
+
**Purpose**: AT Protocol Lexicon definitions for custom data types
58
+
59
+
**Key File**: `fs.json` - Defines `place.wisp.fs` record format
60
+
- **structure**: Virtual filesystem manifest with tree structure
61
+
- **site**: string identifier
62
+
- **root**: directory object containing entries
63
+
- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
64
+
- **directory**: array of entries (recursive)
65
+
- **entry**: name + node (file or directory)
66
+
67
+
**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
68
+
69
+
### `/hosting-service`
70
+
**Purpose**: Lightweight microservice that serves cached sites from disk
71
+
72
+
**Architecture**:
73
+
- Routes by domain lookup in PostgreSQL
74
+
- Caches site content locally on first access or firehose event
75
+
- Listens to AT Protocol firehose for new site records
76
+
- Automatically downloads and caches files from PDS
77
+
- SSRF-protected fetch (timeout, size limits, private IP blocking)
78
+
79
+
**Routes**:
80
+
1. Custom domains (`/*`) โ lookup custom_domains table
81
+
2. Wisp subdomains (`/*.wisp.place/*`) โ lookup domains table
82
+
3. DNS hash routing (`/hash.dns.wisp.place/*`) โ lookup custom_domains by hash
83
+
4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ fetch from PDS if not cached
84
+
85
+
**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
86
+
87
+
### `/cli`
88
+
**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
89
+
90
+
**Flow**:
91
+
1. Authenticate with handle + app password or OAuth
92
+
2. Walk directory tree, compress files
93
+
3. Upload blobs to PDS via agent
94
+
4. Create place.wisp.fs record with manifest
95
+
5. Store site in database cache
96
+
97
+
**Auth Methods**:
98
+
- `--password` flag for app password auth
99
+
- OAuth loopback server for browser-based auth
100
+
- Supports both (password preferred if provided)
101
+
102
+
---
103
+
104
+
## ๐ Key Concepts
105
+
106
+
### Custom Domains (BYOD - Bring Your Own Domain)
107
+
**Process**:
108
+
1. User claims custom domain via API
109
+
2. System generates hash (SHA256(domain + secret))
110
+
3. User adds DNS records:
111
+
- TXT at `_wisp.example.com` = their DID
112
+
- CNAME at `example.com` = `{hash}.dns.wisp.place`
113
+
4. Background worker checks verification every 10 minutes
114
+
5. Once verified, custom domain routes to their hosted sites
115
+
116
+
**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
117
+
118
+
### Wisp Subdomains
119
+
**Process**:
120
+
1. Handle claimed on first signup (e.g., alice โ alice.wisp.place)
121
+
2. Stored in `domains` table mapping domain โ DID
122
+
3. Served by hosting service
123
+
124
+
### Site Storage
125
+
**Locations**:
126
+
- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
127
+
- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
128
+
- **File Cache**: Hosting service caches downloaded files on disk
129
+
130
+
**Limits**:
131
+
- MAX_SITE_SIZE: 300MB total
132
+
- MAX_FILE_SIZE: 100MB per file
133
+
- MAX_FILE_COUNT: 2000 files
134
+
135
+
### File Compression Strategy
136
+
**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
137
+
138
+
**Process**:
139
+
1. All files gzip-compressed (level 9)
140
+
2. Compressed content base64-encoded
141
+
3. Uploaded as `application/octet-stream` MIME type
142
+
4. Blob metadata stores original MIME type + encoding flag
143
+
5. Hosting service decompresses on serve
144
+
145
+
---
146
+
147
+
## ๐ Data Flow
148
+
149
+
### User Registration โ Site Upload
150
+
```
151
+
1. OAuth signin โ state/session stored in DB
152
+
2. Cookie set with DID
153
+
3. Sync sites from PDS to cache DB
154
+
4. If no sites/domain โ redirect to onboarding
155
+
5. User creates site โ POST /wisp/upload-files
156
+
6. Files compressed, uploaded as blobs
157
+
7. place.wisp.fs record created
158
+
8. Site cached in DB
159
+
9. Hosting service notified via firehose
160
+
```
161
+
162
+
### Custom Domain Setup
163
+
```
164
+
1. User claims domain (DB check + allocation)
165
+
2. System generates hash
166
+
3. User adds DNS records (_wisp.domain TXT + CNAME)
167
+
4. Background worker verifies every 10 min
168
+
5. Hosting service routes based on verification status
169
+
```
170
+
171
+
### Site Access
172
+
```
173
+
Hosting Service:
174
+
1. Request arrives at custom domain or *.wisp.place
175
+
2. Domain lookup in PostgreSQL
176
+
3. Check cache for site files
177
+
4. If not cached:
178
+
- Fetch from PDS using DID + rkey
179
+
- Decompress files
180
+
- Save to disk cache
181
+
5. Serve files (with HTML path rewriting)
182
+
```
183
+
184
+
---
185
+
186
+
## ๐ ๏ธ Important Implementation Details
187
+
188
+
### OAuth Implementation
189
+
- **State & Session Storage**: PostgreSQL (with expiration)
190
+
- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
191
+
- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
192
+
- **Session Timeout**: 30 days
193
+
- **State Timeout**: 1 hour
194
+
195
+
### Security Headers
196
+
- X-Frame-Options: DENY
197
+
- X-Content-Type-Options: nosniff
198
+
- Strict-Transport-Security: max-age=31536000
199
+
- Content-Security-Policy (configured for Elysia + React)
200
+
- X-XSS-Protection: 1; mode=block
201
+
- Referrer-Policy: strict-origin-when-cross-origin
202
+
203
+
### Admin Authentication
204
+
- Simple username/password (hashed with bcrypt)
205
+
- Session-based cookie auth (24hr expiration)
206
+
- Separate `admin_session` cookie
207
+
- Initial setup prompted on startup
208
+
209
+
### Observability
210
+
- **Logging**: Structured logging with service tags + event types
211
+
- **Error Tracking**: Captures error context (message, stack, etc.)
212
+
- **Metrics**: Request counts, latencies, error rates
213
+
- **Log Levels**: debug, info, warn, error
214
+
- **Collection**: Centralized log collector with in-memory buffer
215
+
216
+
---
217
+
218
+
## ๐ Database Schema
219
+
220
+
### oauth_states
221
+
- key (primary key)
222
+
- data (JSON)
223
+
- created_at, expires_at (timestamps)
2
224
3
-
Architecture Overview
225
+
### oauth_sessions
226
+
- sub (primary key - subject/DID)
227
+
- data (JSON with OAuth session)
228
+
- updated_at, expires_at
4
229
5
-
Wisp.Place a two-service application that provides static site hosting on the AT
6
-
Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
230
+
### oauth_keys
231
+
- kid (primary key - key ID)
232
+
- jwk (JSON Web Key)
233
+
- created_at
234
+
235
+
### domains
236
+
- domain (primary key - e.g., alice.wisp.place)
237
+
- did (unique - user's DID)
238
+
- rkey (optional - record key)
239
+
- created_at
240
+
241
+
### custom_domains
242
+
- id (primary key - UUID)
243
+
- domain (unique - e.g., example.com)
244
+
- did (user's DID)
245
+
- rkey (optional)
246
+
- verified (boolean)
247
+
- last_verified_at (timestamp)
248
+
- created_at
249
+
250
+
### sites
251
+
- id, did, rkey, site_name
252
+
- created_at, updated_at
253
+
- Indexes on (did), (did, rkey), (rkey)
254
+
255
+
### admin_users
256
+
- username (primary key)
257
+
- password_hash (bcrypt)
258
+
- created_at
259
+
260
+
---
261
+
262
+
## ๐ Key Workflows
263
+
264
+
### Sign In Flow
265
+
1. POST /api/auth/signin with handle
266
+
2. System generates state token
267
+
3. Redirects to PDS OAuth endpoint
268
+
4. PDS redirects back to /api/auth/callback?code=X&state=Y
269
+
5. Validate state (CSRF protection)
270
+
6. Exchange code for session
271
+
7. Store session in DB, set DID cookie
272
+
8. Sync sites from PDS
273
+
9. Redirect to /editor or /onboarding
274
+
275
+
### File Upload Flow
276
+
1. POST /wisp/upload-files with siteName + files
277
+
2. Validate site name (rkey format rules)
278
+
3. For each file:
279
+
- Check size limits
280
+
- Read as ArrayBuffer
281
+
- Gzip compress
282
+
- Base64 encode
283
+
4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
284
+
5. Create manifest with all blob refs
285
+
6. putRecord() for place.wisp.fs with manifest
286
+
7. Upsert to sites table
287
+
8. Return URI + CID
7
288
8
-
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
9
-
- User-facing editor and API
10
-
- OAuth authentication (AT Protocol)
11
-
- File upload processing (gzip + base64 encoding)
12
-
- Domain management (subdomains + custom domains)
13
-
- DNS verification worker
14
-
- React frontend
289
+
### Domain Verification Flow
290
+
1. POST /api/custom-domains/claim
291
+
2. Generate hash = SHA256(domain + secret)
292
+
3. Store in custom_domains with verified=false
293
+
4. Return hash for user to configure DNS
294
+
5. Background worker periodically:
295
+
- Query custom_domains where verified=false
296
+
- Verify TXT record at _wisp.domain
297
+
- Verify CNAME points to hash.dns.wisp.place
298
+
- Update verified flag + last_verified_at
299
+
6. Hosting service routes when verified=true
15
300
16
-
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
17
-
- AT Protocol Firehose listener for real-time updates
18
-
- Serves hosted websites from local cache
19
-
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
20
-
- Distributed locking for multi-instance coordination
301
+
---
21
302
22
-
Tech Stack
303
+
## ๐จ Frontend Structure
23
304
24
-
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
25
-
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
305
+
### `/public`
306
+
- **index.tsx** - Landing page with sign-in form
307
+
- **editor/editor.tsx** - Site editor/management UI
308
+
- **admin/admin.tsx** - Admin dashboard
309
+
- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
310
+
- **styles/global.css** - Tailwind + custom styles
26
311
27
-
Key Features
312
+
### Page Flow
313
+
1. `/` - Landing page (sign in / get started)
314
+
2. `/editor` - Main app (requires auth)
315
+
3. `/admin` - Admin console (requires admin auth)
316
+
4. `/onboarding` - First-time user setup
28
317
29
-
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
30
-
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
31
-
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
32
-
- Real-time Sync: Firehose worker listens for site updates and caches files locally
33
-
- Atomic Updates: Safe cache swapping without downtime
318
+
---
319
+
320
+
## ๐ Notable Implementation Patterns
321
+
322
+
### File Handling
323
+
- Files stored as base64-encoded gzip in PDS blobs
324
+
- Metadata preserves original MIME type
325
+
- Hosting service decompresses on serve
326
+
- Workaround for PDS image pipeline issues with HTML
327
+
328
+
### Error Handling
329
+
- Comprehensive logging with context
330
+
- Graceful degradation (e.g., site sync failure doesn't break auth)
331
+
- Structured error responses with details
332
+
333
+
### Performance
334
+
- Site sync: Batch fetch up to 100 records per request
335
+
- Blob upload: Parallel promises for all files
336
+
- DNS verification: Batched background worker (10 min intervals)
337
+
- Caching: Two-tier (DB + disk in hosting service)
338
+
339
+
### Validation
340
+
- Lexicon validation on manifest creation
341
+
- Record type checking
342
+
- Domain format validation
343
+
- Site name format validation (AT Protocol rkey rules)
344
+
- File size limits enforced before upload
345
+
346
+
---
347
+
348
+
## ๐ Known Quirks & Workarounds
349
+
350
+
1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
351
+
352
+
2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
353
+
354
+
3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
355
+
356
+
4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
357
+
358
+
5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
359
+
360
+
---
361
+
362
+
## ๐ Environment Variables
363
+
364
+
- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
365
+
- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
366
+
- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
367
+
- `NODE_ENV` - production/development
368
+
- `HOSTING_PORT` - Hosting service port (default: 3001)
369
+
- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
370
+
371
+
---
372
+
373
+
## ๐งโ๐ป Development Notes
374
+
375
+
### Adding New Features
376
+
1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
377
+
2. **DB changes**: Add migration in db.ts
378
+
3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
379
+
4. **Admin features**: Add to /api/admin endpoints
380
+
381
+
### Testing
382
+
- Run with `bun test`
383
+
- CSRF tests in lib/csrf.test.ts
384
+
- Utility tests in lib/wisp-utils.test.ts
385
+
386
+
### Debugging
387
+
- Check logs via `/api/admin/logs` (requires admin auth)
388
+
- DNS verification manual trigger: POST /api/admin/verify-dns
389
+
- Health check: GET /api/health (includes DNS verifier status)
390
+
391
+
---
392
+
393
+
## ๐ Deployment Considerations
394
+
395
+
1. **Secrets**: Admin password, OAuth keys, database credentials
396
+
2. **HTTPS**: Required (HSTS header enforces it)
397
+
3. **CDN**: Custom domains require DNS configuration
398
+
4. **Scaling**:
399
+
- Main server: Horizontal scaling with session DB
400
+
- Hosting service: Independent scaling, disk cache per instance
401
+
5. **Backups**: PostgreSQL database critical; firehose provides recovery
402
+
403
+
---
404
+
405
+
## ๐ Related Technologies
406
+
407
+
- **AT Protocol**: Decentralized identity, OAuth 2.0
408
+
- **Jacquard**: Rust library for AT Protocol interactions
409
+
- **Elysia**: Bun web framework (similar to Express/Hono)
410
+
- **Lexicon**: AT Protocol's schema definition language
411
+
- **Firehose**: Real-time event stream of repo changes
412
+
- **PDS**: Personal Data Server (where users' data stored)
413
+
414
+
---
415
+
416
+
## ๐ฏ Project Goals
417
+
418
+
โ
Decentralized site hosting (data owned by users)
419
+
โ
Custom domain support with DNS verification
420
+
โ
Fast CDN distribution via hosting service
421
+
โ
Developer tools (CLI + API)
422
+
โ
Admin dashboard for monitoring
423
+
โ
Zero user data retention (sites in PDS, sessions in DB only)
424
+
425
+
---
426
+
427
+
**Last Updated**: November 2025
428
+
**Status**: Active development
+24
cli/.gitignore
+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/
+100
-301
cli/Cargo.lock
+100
-301
cli/Cargo.lock
···
151
151
]
152
152
153
153
[[package]]
154
-
name = "async-lock"
155
-
version = "3.4.1"
156
-
source = "registry+https://github.com/rust-lang/crates.io-index"
157
-
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
158
-
dependencies = [
159
-
"event-listener",
160
-
"event-listener-strategy",
161
-
"pin-project-lite",
162
-
]
163
-
164
-
[[package]]
165
154
name = "async-trait"
166
155
version = "0.1.89"
167
156
source = "registry+https://github.com/rust-lang/crates.io-index"
···
548
537
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
549
538
550
539
[[package]]
551
-
name = "concurrent-queue"
552
-
version = "2.5.0"
553
-
source = "registry+https://github.com/rust-lang/crates.io-index"
554
-
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
555
-
dependencies = [
556
-
"crossbeam-utils",
557
-
]
558
-
559
-
[[package]]
560
540
name = "const-oid"
561
541
version = "0.9.6"
562
542
source = "registry+https://github.com/rust-lang/crates.io-index"
···
573
553
version = "0.9.4"
574
554
source = "registry+https://github.com/rust-lang/crates.io-index"
575
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"
576
566
dependencies = [
577
567
"core-foundation-sys",
578
568
"libc",
···
616
606
version = "0.5.15"
617
607
source = "registry+https://github.com/rust-lang/crates.io-index"
618
608
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
619
-
dependencies = [
620
-
"crossbeam-utils",
621
-
]
622
-
623
-
[[package]]
624
-
name = "crossbeam-epoch"
625
-
version = "0.9.18"
626
-
source = "registry+https://github.com/rust-lang/crates.io-index"
627
-
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
628
609
dependencies = [
629
610
"crossbeam-utils",
630
611
]
···
891
872
]
892
873
893
874
[[package]]
894
-
name = "event-listener"
895
-
version = "5.4.1"
896
-
source = "registry+https://github.com/rust-lang/crates.io-index"
897
-
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
898
-
dependencies = [
899
-
"concurrent-queue",
900
-
"parking",
901
-
"pin-project-lite",
902
-
]
903
-
904
-
[[package]]
905
-
name = "event-listener-strategy"
906
-
version = "0.5.4"
907
-
source = "registry+https://github.com/rust-lang/crates.io-index"
908
-
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
909
-
dependencies = [
910
-
"event-listener",
911
-
"pin-project-lite",
912
-
]
913
-
914
-
[[package]]
915
875
name = "fastrand"
916
876
version = "2.3.0"
917
877
source = "registry+https://github.com/rust-lang/crates.io-index"
···
962
922
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
963
923
964
924
[[package]]
965
-
name = "foreign-types"
966
-
version = "0.3.2"
967
-
source = "registry+https://github.com/rust-lang/crates.io-index"
968
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
969
-
dependencies = [
970
-
"foreign-types-shared",
971
-
]
972
-
973
-
[[package]]
974
-
name = "foreign-types-shared"
975
-
version = "0.1.1"
976
-
source = "registry+https://github.com/rust-lang/crates.io-index"
977
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
978
-
979
-
[[package]]
980
925
name = "form_urlencoded"
981
926
version = "1.2.2"
982
927
source = "registry+https://github.com/rust-lang/crates.io-index"
···
996
941
]
997
942
998
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]]
999
959
name = "futures-channel"
1000
960
version = "0.3.31"
1001
961
source = "registry+https://github.com/rust-lang/crates.io-index"
1002
962
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
1003
963
dependencies = [
1004
964
"futures-core",
965
+
"futures-sink",
1005
966
]
1006
967
1007
968
[[package]]
···
1011
972
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
1012
973
1013
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]]
1014
986
name = "futures-io"
1015
987
version = "0.3.31"
1016
988
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1045
1017
source = "registry+https://github.com/rust-lang/crates.io-index"
1046
1018
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
1047
1019
dependencies = [
1020
+
"futures-channel",
1048
1021
"futures-core",
1049
1022
"futures-io",
1050
1023
"futures-macro",
···
1253
1226
]
1254
1227
1255
1228
[[package]]
1256
-
name = "home"
1257
-
version = "0.5.12"
1258
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1259
-
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
1260
-
dependencies = [
1261
-
"windows-sys 0.61.2",
1262
-
]
1263
-
1264
-
[[package]]
1265
1229
name = "html5ever"
1266
1230
version = "0.27.0"
1267
1231
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1361
1325
]
1362
1326
1363
1327
[[package]]
1364
-
name = "hyper-tls"
1365
-
version = "0.6.0"
1366
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1367
-
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1368
-
dependencies = [
1369
-
"bytes",
1370
-
"http-body-util",
1371
-
"hyper",
1372
-
"hyper-util",
1373
-
"native-tls",
1374
-
"tokio",
1375
-
"tokio-native-tls",
1376
-
"tower-service",
1377
-
]
1378
-
1379
-
[[package]]
1380
1328
name = "hyper-util"
1381
1329
version = "0.1.17"
1382
1330
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1634
1582
1635
1583
[[package]]
1636
1584
name = "jacquard"
1637
-
version = "0.8.0"
1585
+
version = "0.9.0"
1586
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1638
1587
dependencies = [
1639
1588
"bytes",
1640
1589
"getrandom 0.2.16",
···
1661
1610
1662
1611
[[package]]
1663
1612
name = "jacquard-api"
1664
-
version = "0.8.0"
1613
+
version = "0.9.0"
1614
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1665
1615
dependencies = [
1666
1616
"bon",
1667
1617
"bytes",
···
1678
1628
1679
1629
[[package]]
1680
1630
name = "jacquard-common"
1681
-
version = "0.8.0"
1631
+
version = "0.9.0"
1632
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1682
1633
dependencies = [
1683
1634
"base64 0.22.1",
1684
1635
"bon",
···
1714
1665
1715
1666
[[package]]
1716
1667
name = "jacquard-derive"
1717
-
version = "0.8.0"
1668
+
version = "0.9.0"
1669
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1718
1670
dependencies = [
1719
1671
"heck 0.5.0",
1720
1672
"jacquard-lexicon",
···
1725
1677
1726
1678
[[package]]
1727
1679
name = "jacquard-identity"
1728
-
version = "0.8.0"
1680
+
version = "0.9.1"
1681
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1729
1682
dependencies = [
1730
1683
"bon",
1731
1684
"bytes",
···
1735
1688
"jacquard-common",
1736
1689
"jacquard-lexicon",
1737
1690
"miette",
1738
-
"moka",
1691
+
"mini-moka",
1739
1692
"percent-encoding",
1740
1693
"reqwest",
1741
1694
"serde",
···
1750
1703
1751
1704
[[package]]
1752
1705
name = "jacquard-lexicon"
1753
-
version = "0.8.0"
1706
+
version = "0.9.1"
1707
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1754
1708
dependencies = [
1755
1709
"cid",
1756
1710
"dashmap",
···
1775
1729
1776
1730
[[package]]
1777
1731
name = "jacquard-oauth"
1778
-
version = "0.8.0"
1732
+
version = "0.9.0"
1733
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1779
1734
dependencies = [
1780
1735
"base64 0.22.1",
1781
1736
"bytes",
···
1982
1937
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
1983
1938
1984
1939
[[package]]
1985
-
name = "malloc_buf"
1986
-
version = "0.0.6"
1987
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1988
-
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
1989
-
dependencies = [
1990
-
"libc",
1991
-
]
1992
-
1993
-
[[package]]
1994
1940
name = "markup5ever"
1995
1941
version = "0.12.1"
1996
1942
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2080
2026
]
2081
2027
2082
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]]
2083
2043
name = "minimal-lexical"
2084
2044
version = "0.2.1"
2085
2045
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2107
2067
]
2108
2068
2109
2069
[[package]]
2110
-
name = "moka"
2111
-
version = "0.12.11"
2112
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2113
-
checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077"
2114
-
dependencies = [
2115
-
"async-lock",
2116
-
"crossbeam-channel",
2117
-
"crossbeam-epoch",
2118
-
"crossbeam-utils",
2119
-
"equivalent",
2120
-
"event-listener",
2121
-
"futures-util",
2122
-
"parking_lot",
2123
-
"portable-atomic",
2124
-
"rustc_version",
2125
-
"smallvec",
2126
-
"tagptr",
2127
-
"uuid",
2128
-
]
2129
-
2130
-
[[package]]
2131
2070
name = "multibase"
2132
2071
version = "0.9.2"
2133
2072
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2169
2108
]
2170
2109
2171
2110
[[package]]
2172
-
name = "native-tls"
2173
-
version = "0.2.14"
2174
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2175
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
2176
-
dependencies = [
2177
-
"libc",
2178
-
"log",
2179
-
"openssl",
2180
-
"openssl-probe",
2181
-
"openssl-sys",
2182
-
"schannel",
2183
-
"security-framework",
2184
-
"security-framework-sys",
2185
-
"tempfile",
2186
-
]
2187
-
2188
-
[[package]]
2189
2111
name = "ndk-context"
2190
2112
version = "0.1.1"
2191
2113
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2279
2201
]
2280
2202
2281
2203
[[package]]
2282
-
name = "objc"
2283
-
version = "0.2.7"
2204
+
name = "objc2"
2205
+
version = "0.6.3"
2284
2206
source = "registry+https://github.com/rust-lang/crates.io-index"
2285
-
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
2207
+
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
2286
2208
dependencies = [
2287
-
"malloc_buf",
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",
2288
2226
]
2289
2227
2290
2228
[[package]]
···
2309
2247
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2310
2248
2311
2249
[[package]]
2312
-
name = "openssl"
2313
-
version = "0.10.74"
2314
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2315
-
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
2316
-
dependencies = [
2317
-
"bitflags",
2318
-
"cfg-if",
2319
-
"foreign-types",
2320
-
"libc",
2321
-
"once_cell",
2322
-
"openssl-macros",
2323
-
"openssl-sys",
2324
-
]
2325
-
2326
-
[[package]]
2327
-
name = "openssl-macros"
2328
-
version = "0.1.1"
2329
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2330
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2331
-
dependencies = [
2332
-
"proc-macro2",
2333
-
"quote",
2334
-
"syn 2.0.108",
2335
-
]
2336
-
2337
-
[[package]]
2338
-
name = "openssl-probe"
2339
-
version = "0.1.6"
2340
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2341
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
2342
-
2343
-
[[package]]
2344
-
name = "openssl-sys"
2345
-
version = "0.9.110"
2346
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2347
-
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
2348
-
dependencies = [
2349
-
"cc",
2350
-
"libc",
2351
-
"pkg-config",
2352
-
"vcpkg",
2353
-
]
2354
-
2355
-
[[package]]
2356
2250
name = "option-ext"
2357
2251
version = "0.2.0"
2358
2252
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2409
2303
"elliptic-curve",
2410
2304
"primeorder",
2411
2305
]
2412
-
2413
-
[[package]]
2414
-
name = "parking"
2415
-
version = "2.2.1"
2416
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2417
-
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2418
2306
2419
2307
[[package]]
2420
2308
name = "parking_lot"
···
2524
2412
"der",
2525
2413
"spki",
2526
2414
]
2527
-
2528
-
[[package]]
2529
-
name = "pkg-config"
2530
-
version = "0.3.32"
2531
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2532
-
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
2533
-
2534
-
[[package]]
2535
-
name = "portable-atomic"
2536
-
version = "1.11.1"
2537
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2538
-
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
2539
2415
2540
2416
[[package]]
2541
2417
name = "potential_utf"
···
2774
2650
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
2775
2651
2776
2652
[[package]]
2777
-
name = "raw-window-handle"
2778
-
version = "0.5.2"
2779
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2780
-
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
2781
-
2782
-
[[package]]
2783
2653
name = "redox_syscall"
2784
2654
version = "0.5.18"
2785
2655
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2866
2736
"http-body-util",
2867
2737
"hyper",
2868
2738
"hyper-rustls",
2869
-
"hyper-tls",
2870
2739
"hyper-util",
2871
2740
"js-sys",
2872
2741
"log",
2873
2742
"mime",
2874
-
"native-tls",
2875
2743
"percent-encoding",
2876
2744
"pin-project-lite",
2877
2745
"quinn",
···
2882
2750
"serde_urlencoded",
2883
2751
"sync_wrapper",
2884
2752
"tokio",
2885
-
"tokio-native-tls",
2886
2753
"tokio-rustls",
2887
2754
"tokio-util",
2888
2755
"tower",
···
2983
2850
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
2984
2851
2985
2852
[[package]]
2986
-
name = "rustc_version"
2987
-
version = "0.4.1"
2988
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2989
-
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2990
-
dependencies = [
2991
-
"semver",
2992
-
]
2993
-
2994
-
[[package]]
2995
2853
name = "rustix"
2996
2854
version = "1.1.2"
2997
2855
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3067
2925
]
3068
2926
3069
2927
[[package]]
3070
-
name = "schannel"
3071
-
version = "0.1.28"
3072
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3073
-
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
3074
-
dependencies = [
3075
-
"windows-sys 0.61.2",
3076
-
]
3077
-
3078
-
[[package]]
3079
2928
name = "schemars"
3080
2929
version = "0.9.0"
3081
2930
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3120
2969
]
3121
2970
3122
2971
[[package]]
3123
-
name = "security-framework"
3124
-
version = "2.11.1"
3125
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3126
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
3127
-
dependencies = [
3128
-
"bitflags",
3129
-
"core-foundation",
3130
-
"core-foundation-sys",
3131
-
"libc",
3132
-
"security-framework-sys",
3133
-
]
3134
-
3135
-
[[package]]
3136
-
name = "security-framework-sys"
3137
-
version = "2.15.0"
3138
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3139
-
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
3140
-
dependencies = [
3141
-
"core-foundation-sys",
3142
-
"libc",
3143
-
]
3144
-
3145
-
[[package]]
3146
-
name = "semver"
3147
-
version = "1.0.27"
3148
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3149
-
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
3150
-
3151
-
[[package]]
3152
2972
name = "serde"
3153
2973
version = "1.0.228"
3154
2974
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3540
3360
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
3541
3361
dependencies = [
3542
3362
"bitflags",
3543
-
"core-foundation",
3363
+
"core-foundation 0.9.4",
3544
3364
"system-configuration-sys",
3545
3365
]
3546
3366
···
3752
3572
]
3753
3573
3754
3574
[[package]]
3755
-
name = "tokio-native-tls"
3756
-
version = "0.3.1"
3757
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3758
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3759
-
dependencies = [
3760
-
"native-tls",
3761
-
"tokio",
3762
-
]
3763
-
3764
-
[[package]]
3765
3575
name = "tokio-rustls"
3766
3576
version = "0.26.4"
3767
3577
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3872
3682
]
3873
3683
3874
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]]
3875
3691
name = "try-lock"
3876
3692
version = "0.2.5"
3877
3693
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3977
3793
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3978
3794
3979
3795
[[package]]
3980
-
name = "uuid"
3981
-
version = "1.18.1"
3982
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3983
-
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
3984
-
dependencies = [
3985
-
"getrandom 0.3.4",
3986
-
"js-sys",
3987
-
"wasm-bindgen",
3988
-
]
3989
-
3990
-
[[package]]
3991
-
name = "vcpkg"
3992
-
version = "0.2.15"
3993
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3994
-
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3995
-
3996
-
[[package]]
3997
3796
name = "version_check"
3998
3797
version = "0.9.5"
3999
3798
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4126
3925
4127
3926
[[package]]
4128
3927
name = "webbrowser"
4129
-
version = "0.8.15"
3928
+
version = "1.0.6"
4130
3929
source = "registry+https://github.com/rust-lang/crates.io-index"
4131
-
checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b"
3930
+
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
4132
3931
dependencies = [
4133
-
"core-foundation",
4134
-
"home",
3932
+
"core-foundation 0.10.1",
4135
3933
"jni",
4136
3934
"log",
4137
3935
"ndk-context",
4138
-
"objc",
4139
-
"raw-window-handle",
3936
+
"objc2",
3937
+
"objc2-foundation",
4140
3938
"url",
4141
3939
"web-sys",
4142
3940
]
···
4577
4375
"bytes",
4578
4376
"clap",
4579
4377
"flate2",
4378
+
"futures",
4580
4379
"jacquard",
4581
4380
"jacquard-api",
4582
4381
"jacquard-common",
+10
-8
cli/Cargo.toml
+10
-8
cli/Cargo.toml
···
8
8
place_wisp = []
9
9
10
10
[dependencies]
11
-
jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] }
12
-
jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" }
13
-
jacquard-api = { path = "jacquard/crates/jacquard-api" }
14
-
jacquard-common = { path = "jacquard/crates/jacquard-common" }
15
-
jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] }
16
-
jacquard-derive = { path = "jacquard/crates/jacquard-derive" }
17
-
jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" }
11
+
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
12
+
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
13
+
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
14
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
15
+
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
16
+
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
17
+
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
18
18
clap = { version = "4.5.51", features = ["derive"] }
19
19
tokio = { version = "1.48", features = ["full"] }
20
20
miette = { version = "7.6.0", features = ["fancy"] }
21
21
serde_json = "1.0.145"
22
22
serde = { version = "1.0", features = ["derive"] }
23
23
shellexpand = "3.1.1"
24
-
reqwest = "0.12"
24
+
#reqwest = "0.12"
25
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
25
26
rustversion = "1.0"
26
27
flate2 = "1.0"
27
28
base64 = "0.22"
28
29
walkdir = "2.5"
29
30
mime_guess = "2.0"
30
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
+95
-23
cli/src/main.rs
+95
-23
cli/src/main.rs
···
3
3
4
4
use clap::Parser;
5
5
use jacquard::CowStr;
6
-
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt};
6
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
7
7
use jacquard::oauth::client::OAuthClient;
8
8
use jacquard::oauth::loopback::LoopbackConfig;
9
9
use jacquard::prelude::IdentityResolver;
···
15
15
use flate2::write::GzEncoder;
16
16
use std::io::Write;
17
17
use base64::Engine;
18
+
use futures::stream::{self, StreamExt};
18
19
19
20
use place_wisp::fs::*;
20
21
···
32
33
#[arg(short, long)]
33
34
site: Option<String>,
34
35
35
-
/// Path to auth store file (will be created if missing)
36
+
/// Path to auth store file (will be created if missing, only used with OAuth)
36
37
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
37
38
store: String,
39
+
40
+
/// App Password for authentication (alternative to OAuth)
41
+
#[arg(long)]
42
+
password: Option<CowStr<'static>>,
38
43
}
39
44
40
45
#[tokio::main]
41
46
async fn main() -> miette::Result<()> {
42
47
let args = Args::parse();
43
48
44
-
let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
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));
45
80
let session = oauth
46
-
.login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
81
+
.login_with_local_server(input, Default::default(), LoopbackConfig::default())
47
82
.await?;
48
83
49
84
let agent: Agent<_> = Agent::from(session);
85
+
deploy_site(&agent, path, site).await
86
+
}
50
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<()> {
51
94
// Verify the path exists
52
-
if !args.path.exists() {
53
-
return Err(miette::miette!("Path does not exist: {}", args.path.display()));
95
+
if !path.exists() {
96
+
return Err(miette::miette!("Path does not exist: {}", path.display()));
54
97
}
55
98
56
99
// Get site name
57
-
let site_name = args.site.unwrap_or_else(|| {
58
-
args.path
100
+
let site_name = site.unwrap_or_else(|| {
101
+
path
59
102
.file_name()
60
103
.and_then(|n| n.to_str())
61
104
.unwrap_or("site")
···
65
108
println!("Deploying site '{}'...", site_name);
66
109
67
110
// Build directory tree
68
-
let root_dir = build_directory(&agent, &args.path).await?;
111
+
let root_dir = build_directory(agent, &path).await?;
69
112
70
113
// Count total files
71
114
let file_count = count_files(&root_dir);
···
102
145
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
103
146
{
104
147
Box::pin(async move {
105
-
let mut entries = Vec::new();
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();
106
157
107
-
for entry in std::fs::read_dir(dir_path).into_diagnostic()? {
108
-
let entry = entry.into_diagnostic()?;
158
+
for entry in dir_entries {
109
159
let path = entry.path();
110
160
let name = entry.file_name();
111
161
let name_str = name.to_str()
112
-
.ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?;
162
+
.ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?
163
+
.to_string();
113
164
114
165
// Skip hidden files
115
166
if name_str.starts_with('.') {
···
119
170
let metadata = entry.metadata().into_diagnostic()?;
120
171
121
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 {
122
182
let file_node = process_file(agent, &path).await?;
123
-
entries.push(Entry::new()
124
-
.name(CowStr::from(name_str.to_string()))
183
+
Ok::<_, miette::Report>(Entry::new()
184
+
.name(CowStr::from(name))
125
185
.node(EntryNode::File(Box::new(file_node)))
126
-
.build());
127
-
} else if metadata.is_dir() {
128
-
let subdir = build_directory(agent, &path).await?;
129
-
entries.push(Entry::new()
130
-
.name(CowStr::from(name_str.to_string()))
131
-
.node(EntryNode::Directory(Box::new(subdir)))
132
-
.build());
133
-
}
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());
134
202
}
203
+
204
+
// Combine file and directory entries
205
+
let mut entries = file_entries;
206
+
entries.extend(dir_entries);
135
207
136
208
Ok(Directory::new()
137
209
.r#type(CowStr::from("directory"))
-14
cli/test_headers.rs
-14
cli/test_headers.rs
···
1
-
use http::Request;
2
-
3
-
fn main() {
4
-
let builder = Request::builder()
5
-
.header(http::header::CONTENT_TYPE, "*/*")
6
-
.header(http::header::CONTENT_TYPE, "application/octet-stream");
7
-
8
-
let req = builder.body(()).unwrap();
9
-
10
-
println!("Content-Type headers:");
11
-
for value in req.headers().get_all(http::header::CONTENT_TYPE) {
12
-
println!(" {:?}", value);
13
-
}
14
-
}
+63
crates.nix
+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
+
}
+14
-192
hosting-service/bun.lock
+14
-192
hosting-service/bun.lock
···
7
7
"@atproto/api": "^0.17.4",
8
8
"@atproto/identity": "^0.4.9",
9
9
"@atproto/lexicon": "^0.5.1",
10
-
"@atproto/sync": "^0.1.35",
10
+
"@atproto/sync": "^0.1.36",
11
11
"@atproto/xrpc": "^0.7.5",
12
-
"@elysiajs/node": "^1.4.1",
13
-
"@elysiajs/opentelemetry": "latest",
14
-
"elysia": "latest",
12
+
"@hono/node-server": "^1.19.6",
13
+
"hono": "^4.10.4",
15
14
"mime-types": "^2.1.35",
16
15
"multiformats": "^13.4.1",
17
16
"postgres": "^3.4.5",
18
17
},
19
18
"devDependencies": {
19
+
"@types/bun": "^1.3.1",
20
20
"@types/mime-types": "^2.1.4",
21
21
"@types/node": "^22.10.5",
22
22
"tsx": "^4.19.2",
···
38
38
39
39
"@atproto/repo": ["@atproto/repo@0.8.10", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, ""],
40
40
41
-
"@atproto/sync": ["@atproto/sync@0.1.35", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, ""],
41
+
"@atproto/sync": ["@atproto/sync@0.1.36", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-HyF835Bmn8ps9BuXkmGjRrbgfv4K3fJdfEvXimEhTCntqIxQg0ttmOYDg/WBBmIRfkCB5ab+wS1PCGN8trr+FQ=="],
42
42
43
43
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""],
44
44
···
46
46
47
47
"@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, ""],
48
48
49
-
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
50
-
51
49
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
52
50
53
-
"@elysiajs/node": ["@elysiajs/node@1.4.1", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.8.9" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-2wAALwHK3IYi1XJPnxfp1xJsvps5FqqcQqe+QXjYlGQvsmSG+vI5wNDIuvIlB+6p9NE/laLbqV0aFromf3X7yg=="],
54
-
55
-
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
56
-
57
51
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
58
52
59
53
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
···
106
100
107
101
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
108
102
109
-
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
110
-
111
-
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
103
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
112
104
113
105
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""],
114
106
115
-
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
116
-
117
107
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""],
118
108
119
109
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""],
120
110
121
-
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
122
-
123
-
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
124
-
125
-
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
126
-
127
-
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
128
-
129
-
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
130
-
131
-
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="],
132
-
133
-
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="],
134
-
135
-
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
136
-
137
-
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
138
-
139
-
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
140
-
141
-
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="],
142
-
143
-
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="],
144
-
145
-
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="],
146
-
147
-
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="],
148
-
149
-
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="],
150
-
151
-
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
152
-
153
-
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
154
-
155
-
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
156
-
157
-
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
158
-
159
-
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
160
-
161
-
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
162
-
163
-
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
164
-
165
-
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
166
-
167
-
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
168
-
169
-
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
170
-
171
-
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="],
172
-
173
-
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
174
-
175
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
176
-
177
-
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
178
-
179
-
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
180
-
181
-
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
182
-
183
-
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
184
-
185
-
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
186
-
187
-
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
188
-
189
-
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
190
-
191
-
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
192
-
193
-
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
194
-
195
-
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
196
-
197
-
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
198
-
199
-
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
200
-
201
-
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
111
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
202
112
203
113
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
204
114
205
115
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
206
116
207
-
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
117
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
208
118
209
119
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""],
210
120
211
121
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""],
212
122
213
-
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
214
-
215
-
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
216
-
217
-
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
218
-
219
-
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
220
-
221
123
"array-flatten": ["array-flatten@1.1.1", "", {}, ""],
222
124
223
125
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""],
···
229
131
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, ""],
230
132
231
133
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""],
134
+
135
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
232
136
233
137
"bytes": ["bytes@3.1.2", "", {}, ""],
234
138
···
242
146
243
147
"cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""],
244
148
245
-
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
246
-
247
-
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
248
-
249
-
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
250
-
251
-
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
252
-
253
149
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""],
254
150
255
151
"content-type": ["content-type@1.0.5", "", {}, ""],
256
152
257
-
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
153
+
"cookie": ["cookie@0.7.1", "", {}, ""],
258
154
259
155
"cookie-signature": ["cookie-signature@1.0.6", "", {}, ""],
260
156
261
-
"crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="],
157
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
262
158
263
159
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""],
264
160
···
272
168
273
169
"ee-first": ["ee-first@1.1.1", "", {}, ""],
274
170
275
-
"elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "exact-mirror": ">= 0.0.9", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="],
276
-
277
-
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
278
-
279
171
"encodeurl": ["encodeurl@2.0.0", "", {}, ""],
280
172
281
173
"es-define-property": ["es-define-property@1.0.1", "", {}, ""],
···
286
178
287
179
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": "bin/esbuild" }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
288
180
289
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
290
-
291
181
"escape-html": ["escape-html@1.0.3", "", {}, ""],
292
182
293
183
"etag": ["etag@1.8.1", "", {}, ""],
···
297
187
"eventemitter3": ["eventemitter3@4.0.7", "", {}, ""],
298
188
299
189
"events": ["events@3.3.0", "", {}, ""],
300
-
301
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
302
190
303
191
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, ""],
304
192
305
-
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
306
-
307
193
"fast-redact": ["fast-redact@3.5.0", "", {}, ""],
308
-
309
-
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
310
-
311
-
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
312
194
313
195
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, ""],
314
196
···
320
202
321
203
"function-bind": ["function-bind@1.1.2", "", {}, ""],
322
204
323
-
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
324
-
325
205
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, ""],
326
206
327
207
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""],
···
335
215
"has-symbols": ["has-symbols@1.1.0", "", {}, ""],
336
216
337
217
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""],
218
+
219
+
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
338
220
339
221
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, ""],
340
222
···
342
224
343
225
"ieee754": ["ieee754@1.2.1", "", {}, ""],
344
226
345
-
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
346
-
347
227
"inherits": ["inherits@2.0.4", "", {}, ""],
348
228
349
229
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""],
350
230
351
-
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
352
-
353
-
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
354
-
355
231
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""],
356
232
357
-
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
358
-
359
-
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
360
-
361
233
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""],
362
234
363
235
"media-typer": ["media-typer@0.3.0", "", {}, ""],
364
-
365
-
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
366
236
367
237
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""],
368
238
···
374
244
375
245
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""],
376
246
377
-
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
378
-
379
247
"ms": ["ms@2.0.0", "", {}, ""],
380
248
381
249
"multiformats": ["multiformats@13.4.1", "", {}, ""],
···
389
257
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""],
390
258
391
259
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""],
392
-
393
-
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
394
260
395
261
"p-finally": ["p-finally@1.0.0", "", {}, ""],
396
262
···
400
266
401
267
"parseurl": ["parseurl@1.3.3", "", {}, ""],
402
268
403
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
404
-
405
269
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""],
406
270
407
271
"pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": "bin.js" }, ""],
···
416
280
417
281
"process-warning": ["process-warning@3.0.0", "", {}, ""],
418
282
419
-
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
420
-
421
283
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""],
422
284
423
285
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, ""],
···
434
296
435
297
"real-require": ["real-require@0.2.0", "", {}, ""],
436
298
437
-
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
438
-
439
-
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
440
-
441
-
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
442
-
443
299
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
444
300
445
301
"safe-buffer": ["safe-buffer@5.2.1", "", {}, ""],
···
454
310
455
311
"setprototypeof": ["setprototypeof@1.2.0", "", {}, ""],
456
312
457
-
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
458
-
459
313
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""],
460
314
461
315
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""],
···
468
322
469
323
"split2": ["split2@4.2.0", "", {}, ""],
470
324
471
-
"srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="],
472
-
473
325
"statuses": ["statuses@2.0.1", "", {}, ""],
474
-
475
-
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
476
326
477
327
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""],
478
328
479
-
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
480
-
481
-
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
482
-
483
-
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
484
-
485
329
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""],
486
330
487
331
"tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""],
488
332
489
333
"toidentifier": ["toidentifier@1.0.1", "", {}, ""],
490
334
491
-
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
492
-
493
335
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
494
336
495
337
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""],
496
-
497
-
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
498
338
499
339
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""],
500
340
···
508
348
509
349
"vary": ["vary@1.1.2", "", {}, ""],
510
350
511
-
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
512
-
513
351
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""],
514
352
515
-
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
516
-
517
-
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
518
-
519
-
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
520
-
521
353
"zod": ["zod@3.25.76", "", {}, ""],
522
354
523
355
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""],
···
534
366
535
367
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""],
536
368
537
-
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
538
-
539
-
"express/cookie": ["cookie@0.7.1", "", {}, ""],
540
-
541
-
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
542
-
543
369
"send/encodeurl": ["encodeurl@1.0.2", "", {}, ""],
544
370
545
371
"send/ms": ["ms@2.1.3", "", {}, ""],
546
372
547
373
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
548
-
549
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
550
-
551
-
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
552
374
}
553
375
}
+8
-5
hosting-service/package.json
+8
-5
hosting-service/package.json
···
3
3
"version": "1.0.0",
4
4
"type": "module",
5
5
"scripts": {
6
-
"dev": "tsx watch src/index.ts",
7
-
"start": "node --loader tsx src/index.ts"
6
+
"dev": "tsx --env-file=.env watch src/index.ts",
7
+
"build": "tsc",
8
+
"start": "tsx --env-file=.env src/index.ts",
9
+
"backfill": "tsx --env-file=.env src/index.ts --backfill"
8
10
},
9
11
"dependencies": {
10
12
"@atproto/api": "^0.17.4",
11
13
"@atproto/identity": "^0.4.9",
12
14
"@atproto/lexicon": "^0.5.1",
13
-
"@atproto/sync": "^0.1.35",
15
+
"@atproto/sync": "^0.1.36",
14
16
"@atproto/xrpc": "^0.7.5",
15
-
"@elysiajs/opentelemetry": "latest",
16
-
"elysia": "latest",
17
+
"@hono/node-server": "^1.19.6",
18
+
"hono": "^4.10.4",
17
19
"mime-types": "^2.1.35",
18
20
"multiformats": "^13.4.1",
19
21
"postgres": "^3.4.5"
20
22
},
21
23
"devDependencies": {
24
+
"@types/bun": "^1.3.1",
22
25
"@types/mime-types": "^2.1.4",
23
26
"@types/node": "^22.10.5",
24
27
"tsx": "^4.19.2"
+33
-10
hosting-service/src/index.ts
+33
-10
hosting-service/src/index.ts
···
1
1
import app from './server';
2
+
import { serve } from '@hono/node-server';
2
3
import { FirehoseWorker } from './lib/firehose';
3
4
import { logger } from './lib/observability';
4
5
import { mkdirSync, existsSync } from 'fs';
6
+
import { backfillCache } from './lib/backfill';
5
7
6
8
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
7
-
const CACHE_DIR = './cache/sites';
9
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
10
+
11
+
// Parse CLI arguments
12
+
const args = process.argv.slice(2);
13
+
const hasBackfillFlag = args.includes('--backfill');
14
+
const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true';
8
15
9
16
// Ensure cache directory exists
10
17
if (!existsSync(CACHE_DIR)) {
···
18
25
});
19
26
20
27
firehose.start();
28
+
29
+
// Run backfill if requested
30
+
if (backfillOnStartup) {
31
+
console.log('๐ Backfill requested, starting cache backfill...');
32
+
backfillCache({
33
+
skipExisting: true,
34
+
concurrency: 3,
35
+
}).then((stats) => {
36
+
console.log('โ
Cache backfill completed');
37
+
}).catch((err) => {
38
+
console.error('โ Cache backfill error:', err);
39
+
});
40
+
}
21
41
22
42
// Add health check endpoint
23
-
app.get('/health', () => {
43
+
app.get('/health', (c) => {
24
44
const firehoseHealth = firehose.getHealth();
25
-
return {
45
+
return c.json({
26
46
status: 'ok',
27
47
firehose: firehoseHealth,
28
-
};
48
+
});
49
+
});
50
+
51
+
// Start HTTP server with Node.js adapter
52
+
const server = serve({
53
+
fetch: app.fetch,
54
+
port: PORT,
29
55
});
30
56
31
-
// Start HTTP server
32
-
app.listen(PORT, () => {
33
-
console.log(`
57
+
console.log(`
34
58
Wisp Hosting Service
35
59
36
60
Server: http://localhost:${PORT}
···
38
62
Cache: ${CACHE_DIR}
39
63
Firehose: Connected to Firehose
40
64
`);
41
-
});
42
65
43
66
// Graceful shutdown
44
67
process.on('SIGINT', async () => {
45
68
console.log('\n๐ Shutting down...');
46
69
firehose.stop();
47
-
app.stop();
70
+
server.close();
48
71
process.exit(0);
49
72
});
50
73
51
74
process.on('SIGTERM', async () => {
52
75
console.log('\n๐ Shutting down...');
53
76
firehose.stop();
54
-
app.stop();
77
+
server.close();
55
78
process.exit(0);
56
79
});
+1
-1
hosting-service/src/lexicon/types/place/wisp/fs.ts
+1
-1
hosting-service/src/lexicon/types/place/wisp/fs.ts
···
2
2
* GENERATED CODE - DO NOT MODIFY
3
3
*/
4
4
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
5
+
import { CID } from 'multiformats'
6
6
import { validate as _validate } from '../../../lexicons'
7
7
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
8
+136
hosting-service/src/lib/backfill.ts
+136
hosting-service/src/lib/backfill.ts
···
1
+
import { getAllSites } from './db';
2
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
3
+
import { logger } from './observability';
4
+
5
+
export interface BackfillOptions {
6
+
skipExisting?: boolean; // Skip sites already in cache
7
+
concurrency?: number; // Number of sites to cache concurrently
8
+
maxSites?: number; // Maximum number of sites to backfill (for testing)
9
+
}
10
+
11
+
export interface BackfillStats {
12
+
total: number;
13
+
cached: number;
14
+
skipped: number;
15
+
failed: number;
16
+
duration: number;
17
+
}
18
+
19
+
/**
20
+
* Backfill all sites from the database into the local cache
21
+
*/
22
+
export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> {
23
+
const {
24
+
skipExisting = true,
25
+
concurrency = 3,
26
+
maxSites,
27
+
} = options;
28
+
29
+
const startTime = Date.now();
30
+
const stats: BackfillStats = {
31
+
total: 0,
32
+
cached: 0,
33
+
skipped: 0,
34
+
failed: 0,
35
+
duration: 0,
36
+
};
37
+
38
+
logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites });
39
+
console.log(`
40
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
41
+
โ CACHE BACKFILL STARTING โ
42
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
43
+
`);
44
+
45
+
try {
46
+
// Get all sites from database
47
+
let sites = await getAllSites();
48
+
stats.total = sites.length;
49
+
50
+
logger.info(`Found ${sites.length} sites in database`);
51
+
console.log(`๐ Found ${sites.length} sites in database`);
52
+
53
+
// Limit if specified
54
+
if (maxSites && maxSites > 0) {
55
+
sites = sites.slice(0, maxSites);
56
+
console.log(`โ๏ธ Limited to ${maxSites} sites for backfill`);
57
+
}
58
+
59
+
// Process sites in batches
60
+
const batches: typeof sites[] = [];
61
+
for (let i = 0; i < sites.length; i += concurrency) {
62
+
batches.push(sites.slice(i, i + concurrency));
63
+
}
64
+
65
+
let processed = 0;
66
+
for (const batch of batches) {
67
+
await Promise.all(
68
+
batch.map(async (site) => {
69
+
try {
70
+
// Check if already cached
71
+
if (skipExisting && isCached(site.did, site.rkey)) {
72
+
stats.skipped++;
73
+
processed++;
74
+
logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey });
75
+
console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`);
76
+
return;
77
+
}
78
+
79
+
// Fetch site record
80
+
const siteData = await fetchSiteRecord(site.did, site.rkey);
81
+
if (!siteData) {
82
+
stats.failed++;
83
+
processed++;
84
+
logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey });
85
+
console.log(`โ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`);
86
+
return;
87
+
}
88
+
89
+
// Get PDS endpoint
90
+
const pdsEndpoint = await getPdsForDid(site.did);
91
+
if (!pdsEndpoint) {
92
+
stats.failed++;
93
+
processed++;
94
+
logger.error('PDS not found during backfill', null, { did: site.did });
95
+
console.log(`โ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`);
96
+
return;
97
+
}
98
+
99
+
// Download and cache site
100
+
await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid);
101
+
stats.cached++;
102
+
processed++;
103
+
logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey });
104
+
console.log(`โ
[${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`);
105
+
} catch (err) {
106
+
stats.failed++;
107
+
processed++;
108
+
logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey });
109
+
console.log(`โ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`);
110
+
}
111
+
})
112
+
);
113
+
}
114
+
115
+
stats.duration = Date.now() - startTime;
116
+
117
+
console.log(`
118
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
119
+
โ CACHE BACKFILL COMPLETED โ
120
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
121
+
122
+
๐ Total Sites: ${stats.total}
123
+
โ
Cached: ${stats.cached}
124
+
โญ๏ธ Skipped: ${stats.skipped}
125
+
โ Failed: ${stats.failed}
126
+
โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s
127
+
`);
128
+
129
+
logger.info('Cache backfill completed', stats);
130
+
} catch (err) {
131
+
logger.error('Cache backfill failed', err);
132
+
console.error('โ Cache backfill failed:', err);
133
+
}
134
+
135
+
return stats;
136
+
}
+27
-86
hosting-service/src/lib/db.ts
+27
-86
hosting-service/src/lib/db.ts
···
1
1
import postgres from 'postgres';
2
+
import { createHash } from 'crypto';
2
3
3
4
const sql = postgres(
4
5
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
···
21
22
verified: boolean;
22
23
}
23
24
24
-
// In-memory cache with TTL
25
-
interface CacheEntry<T> {
26
-
data: T;
27
-
expiry: number;
28
-
}
29
-
30
-
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
31
-
32
-
class SimpleCache<T> {
33
-
private cache = new Map<string, CacheEntry<T>>();
34
-
35
-
get(key: string): T | null {
36
-
const entry = this.cache.get(key);
37
-
if (!entry) return null;
38
25
39
-
if (Date.now() > entry.expiry) {
40
-
this.cache.delete(key);
41
-
return null;
42
-
}
43
-
44
-
return entry.data;
45
-
}
46
-
47
-
set(key: string, data: T): void {
48
-
this.cache.set(key, {
49
-
data,
50
-
expiry: Date.now() + CACHE_TTL_MS,
51
-
});
52
-
}
53
-
54
-
// Periodic cleanup to prevent memory leaks
55
-
cleanup(): void {
56
-
const now = Date.now();
57
-
for (const [key, entry] of this.cache.entries()) {
58
-
if (now > entry.expiry) {
59
-
this.cache.delete(key);
60
-
}
61
-
}
62
-
}
63
-
}
64
-
65
-
// Create cache instances
66
-
const wispDomainCache = new SimpleCache<DomainLookup | null>();
67
-
const customDomainCache = new SimpleCache<CustomDomainLookup | null>();
68
-
const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>();
69
-
70
-
// Run cleanup every 5 minutes
71
-
setInterval(() => {
72
-
wispDomainCache.cleanup();
73
-
customDomainCache.cleanup();
74
-
customDomainHashCache.cleanup();
75
-
}, 5 * 60 * 1000);
76
26
77
27
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
78
28
const key = domain.toLowerCase();
79
29
80
-
// Check cache first
81
-
const cached = wispDomainCache.get(key);
82
-
if (cached !== null) {
83
-
return cached;
84
-
}
85
-
86
30
// Query database
87
31
const result = await sql<DomainLookup[]>`
88
32
SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1
89
33
`;
90
34
const data = result[0] || null;
91
-
92
-
// Store in cache
93
-
wispDomainCache.set(key, data);
94
35
95
36
return data;
96
37
}
···
98
39
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
99
40
const key = domain.toLowerCase();
100
41
101
-
// Check cache first
102
-
const cached = customDomainCache.get(key);
103
-
if (cached !== null) {
104
-
return cached;
105
-
}
106
-
107
42
// Query database
108
43
const result = await sql<CustomDomainLookup[]>`
109
44
SELECT id, domain, did, rkey, verified FROM custom_domains
···
111
46
`;
112
47
const data = result[0] || null;
113
48
114
-
// Store in cache
115
-
customDomainCache.set(key, data);
116
-
117
49
return data;
118
50
}
119
51
120
52
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
121
-
// Check cache first
122
-
const cached = customDomainHashCache.get(hash);
123
-
if (cached !== null) {
124
-
return cached;
125
-
}
126
-
127
53
// Query database
128
54
const result = await sql<CustomDomainLookup[]>`
129
55
SELECT id, domain, did, rkey, verified FROM custom_domains
130
56
WHERE id = ${hash} AND verified = true LIMIT 1
131
57
`;
132
58
const data = result[0] || null;
133
-
134
-
// Store in cache
135
-
customDomainHashCache.set(hash, data);
136
59
137
60
return data;
138
61
}
···
158
81
}
159
82
}
160
83
84
+
export interface SiteRecord {
85
+
did: string;
86
+
rkey: string;
87
+
display_name?: string;
88
+
}
89
+
90
+
export async function getAllSites(): Promise<SiteRecord[]> {
91
+
try {
92
+
const result = await sql<SiteRecord[]>`
93
+
SELECT did, rkey, display_name FROM sites
94
+
ORDER BY created_at DESC
95
+
`;
96
+
return result;
97
+
} catch (err) {
98
+
console.error('Failed to get all sites', err);
99
+
return [];
100
+
}
101
+
}
102
+
161
103
/**
162
104
* Generate a numeric lock ID from a string key
163
105
* PostgreSQL advisory locks use bigint (64-bit signed integer)
164
106
*/
165
107
function stringToLockId(key: string): bigint {
166
-
let hash = 0n;
167
-
for (let i = 0; i < key.length; i++) {
168
-
const char = BigInt(key.charCodeAt(i));
169
-
hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
170
-
}
171
-
return hash;
108
+
const hash = createHash('sha256').update(key).digest('hex');
109
+
// Take first 16 hex characters (64 bits) and convert to bigint
110
+
const hashNum = BigInt('0x' + hash.substring(0, 16));
111
+
// Keep within signed int64 range
112
+
return hashNum & 0x7FFFFFFFFFFFFFFFn;
172
113
}
173
114
174
115
/**
···
180
121
const lockId = stringToLockId(key);
181
122
182
123
try {
183
-
const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
124
+
const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`;
184
125
return result[0]?.acquired === true;
185
126
} catch (err) {
186
127
console.error('Failed to acquire lock', { key, error: err });
···
195
136
const lockId = stringToLockId(key);
196
137
197
138
try {
198
-
await sql`SELECT pg_advisory_unlock(${lockId})`;
139
+
await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
199
140
} catch (err) {
200
141
console.error('Failed to release lock', { key, error: err });
201
142
}
+259
-219
hosting-service/src/lib/firehose.ts
+259
-219
hosting-service/src/lib/firehose.ts
···
1
-
import { existsSync, rmSync } from 'fs';
2
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
3
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
4
-
import { safeFetch } from './safe-fetch';
5
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
6
-
import { Firehose } from '@atproto/sync';
7
-
import { IdResolver } from '@atproto/identity';
1
+
import { existsSync, rmSync } from 'fs'
2
+
import {
3
+
getPdsForDid,
4
+
downloadAndCacheSite,
5
+
extractBlobCid,
6
+
fetchSiteRecord
7
+
} from './utils'
8
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
9
+
import { safeFetch } from './safe-fetch'
10
+
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
11
+
import { Firehose } from '@atproto/sync'
12
+
import { IdResolver } from '@atproto/identity'
8
13
9
-
const CACHE_DIR = './cache/sites';
14
+
const CACHE_DIR = './cache/sites'
10
15
11
16
export class FirehoseWorker {
12
-
private firehose: Firehose | null = null;
13
-
private idResolver: IdResolver;
14
-
private isShuttingDown = false;
15
-
private lastEventTime = Date.now();
17
+
private firehose: Firehose | null = null
18
+
private idResolver: IdResolver
19
+
private isShuttingDown = false
20
+
private lastEventTime = Date.now()
16
21
17
-
constructor(
18
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
19
-
) {
20
-
this.idResolver = new IdResolver();
21
-
}
22
+
constructor(
23
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
24
+
) {
25
+
this.idResolver = new IdResolver()
26
+
}
22
27
23
-
private log(msg: string, data?: Record<string, unknown>) {
24
-
const log = this.logger || console.log;
25
-
log(`[FirehoseWorker] ${msg}`, data || {});
26
-
}
28
+
private log(msg: string, data?: Record<string, unknown>) {
29
+
const log = this.logger || console.log
30
+
log(`[FirehoseWorker] ${msg}`, data || {})
31
+
}
27
32
28
-
start() {
29
-
this.log('Starting firehose worker');
30
-
this.connect();
31
-
}
33
+
start() {
34
+
this.log('Starting firehose worker')
35
+
this.connect()
36
+
}
32
37
33
-
stop() {
34
-
this.log('Stopping firehose worker');
35
-
this.isShuttingDown = true;
38
+
stop() {
39
+
this.log('Stopping firehose worker')
40
+
this.isShuttingDown = true
36
41
37
-
if (this.firehose) {
38
-
this.firehose.destroy();
39
-
this.firehose = null;
40
-
}
41
-
}
42
+
if (this.firehose) {
43
+
this.firehose.destroy()
44
+
this.firehose = null
45
+
}
46
+
}
42
47
43
-
private connect() {
44
-
if (this.isShuttingDown) return;
48
+
private connect() {
49
+
if (this.isShuttingDown) return
45
50
46
-
this.log('Connecting to AT Protocol firehose');
51
+
this.log('Connecting to AT Protocol firehose')
47
52
48
-
this.firehose = new Firehose({
49
-
idResolver: this.idResolver,
50
-
service: 'wss://bsky.network',
51
-
filterCollections: ['place.wisp.fs'],
52
-
handleEvent: async (evt) => {
53
-
this.lastEventTime = Date.now();
53
+
this.firehose = new Firehose({
54
+
idResolver: this.idResolver,
55
+
service: 'wss://bsky.network',
56
+
filterCollections: ['place.wisp.fs'],
57
+
handleEvent: async (evt: any) => {
58
+
this.lastEventTime = Date.now()
54
59
55
-
// Watch for write events
56
-
if (evt.event === 'create' || evt.event === 'update') {
57
-
const record = evt.record;
60
+
// Watch for write events
61
+
if (evt.event === 'create' || evt.event === 'update') {
62
+
const record = evt.record
58
63
59
-
// If the write is a valid place.wisp.fs record
60
-
if (
61
-
evt.collection === 'place.wisp.fs' &&
62
-
isRecord(record) &&
63
-
validateRecord(record).success
64
-
) {
65
-
this.log('Received place.wisp.fs event', {
66
-
did: evt.did,
67
-
event: evt.event,
68
-
rkey: evt.rkey,
69
-
});
64
+
// If the write is a valid place.wisp.fs record
65
+
if (
66
+
evt.collection === 'place.wisp.fs' &&
67
+
isRecord(record) &&
68
+
validateRecord(record).success
69
+
) {
70
+
this.log('Received place.wisp.fs event', {
71
+
did: evt.did,
72
+
event: evt.event,
73
+
rkey: evt.rkey
74
+
})
70
75
71
-
try {
72
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
73
-
} catch (err) {
74
-
this.log('Error handling event', {
75
-
did: evt.did,
76
-
event: evt.event,
77
-
rkey: evt.rkey,
78
-
error: err instanceof Error ? err.message : String(err),
79
-
});
80
-
}
81
-
}
82
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
83
-
this.log('Received delete event', {
84
-
did: evt.did,
85
-
rkey: evt.rkey,
86
-
});
76
+
try {
77
+
await this.handleCreateOrUpdate(
78
+
evt.did,
79
+
evt.rkey,
80
+
record,
81
+
evt.cid?.toString()
82
+
)
83
+
} catch (err) {
84
+
this.log('Error handling event', {
85
+
did: evt.did,
86
+
event: evt.event,
87
+
rkey: evt.rkey,
88
+
error:
89
+
err instanceof Error
90
+
? err.message
91
+
: String(err)
92
+
})
93
+
}
94
+
}
95
+
} else if (
96
+
evt.event === 'delete' &&
97
+
evt.collection === 'place.wisp.fs'
98
+
) {
99
+
this.log('Received delete event', {
100
+
did: evt.did,
101
+
rkey: evt.rkey
102
+
})
87
103
88
-
try {
89
-
await this.handleDelete(evt.did, evt.rkey);
90
-
} catch (err) {
91
-
this.log('Error handling delete', {
92
-
did: evt.did,
93
-
rkey: evt.rkey,
94
-
error: err instanceof Error ? err.message : String(err),
95
-
});
96
-
}
97
-
}
98
-
},
99
-
onError: (err) => {
100
-
this.log('Firehose error', {
101
-
error: err instanceof Error ? err.message : String(err),
102
-
stack: err instanceof Error ? err.stack : undefined,
103
-
fullError: err,
104
-
});
105
-
console.error('Full firehose error:', err);
106
-
},
107
-
});
104
+
try {
105
+
await this.handleDelete(evt.did, evt.rkey)
106
+
} catch (err) {
107
+
this.log('Error handling delete', {
108
+
did: evt.did,
109
+
rkey: evt.rkey,
110
+
error:
111
+
err instanceof Error ? err.message : String(err)
112
+
})
113
+
}
114
+
}
115
+
},
116
+
onError: (err: any) => {
117
+
this.log('Firehose error', {
118
+
error: err instanceof Error ? err.message : String(err),
119
+
stack: err instanceof Error ? err.stack : undefined,
120
+
fullError: err
121
+
})
122
+
console.error('Full firehose error:', err)
123
+
}
124
+
})
108
125
109
-
this.firehose.start();
110
-
this.log('Firehose started');
111
-
}
126
+
this.firehose.start()
127
+
this.log('Firehose started')
128
+
}
112
129
113
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
114
-
this.log('Processing create/update', { did, site });
130
+
private async handleCreateOrUpdate(
131
+
did: string,
132
+
site: string,
133
+
record: any,
134
+
eventCid?: string
135
+
) {
136
+
this.log('Processing create/update', { did, site })
115
137
116
-
// Record is already validated in handleEvent
117
-
const fsRecord = record;
138
+
// Record is already validated in handleEvent
139
+
const fsRecord = record
118
140
119
-
const pdsEndpoint = await getPdsForDid(did);
120
-
if (!pdsEndpoint) {
121
-
this.log('Could not resolve PDS for DID', { did });
122
-
return;
123
-
}
141
+
const pdsEndpoint = await getPdsForDid(did)
142
+
if (!pdsEndpoint) {
143
+
this.log('Could not resolve PDS for DID', { did })
144
+
return
145
+
}
124
146
125
-
this.log('Resolved PDS', { did, pdsEndpoint });
147
+
this.log('Resolved PDS', { did, pdsEndpoint })
126
148
127
-
// Verify record exists on PDS and fetch its CID
128
-
let verifiedCid: string;
129
-
try {
130
-
const result = await fetchSiteRecord(did, site);
149
+
// Verify record exists on PDS and fetch its CID
150
+
let verifiedCid: string
151
+
try {
152
+
const result = await fetchSiteRecord(did, site)
131
153
132
-
if (!result) {
133
-
this.log('Record not found on PDS, skipping cache', { did, site });
134
-
return;
135
-
}
154
+
if (!result) {
155
+
this.log('Record not found on PDS, skipping cache', {
156
+
did,
157
+
site
158
+
})
159
+
return
160
+
}
136
161
137
-
verifiedCid = result.cid;
162
+
verifiedCid = result.cid
138
163
139
-
// Verify event CID matches PDS CID (prevent cache poisoning)
140
-
if (eventCid && eventCid !== verifiedCid) {
141
-
this.log('CID mismatch detected - potential spoofed event', {
142
-
did,
143
-
site,
144
-
eventCid,
145
-
verifiedCid
146
-
});
147
-
return;
148
-
}
164
+
// Verify event CID matches PDS CID (prevent cache poisoning)
165
+
if (eventCid && eventCid !== verifiedCid) {
166
+
this.log('CID mismatch detected - potential spoofed event', {
167
+
did,
168
+
site,
169
+
eventCid,
170
+
verifiedCid
171
+
})
172
+
return
173
+
}
149
174
150
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
151
-
} catch (err) {
152
-
this.log('Failed to verify record on PDS', {
153
-
did,
154
-
site,
155
-
error: err instanceof Error ? err.message : String(err),
156
-
});
157
-
return;
158
-
}
175
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
176
+
} catch (err) {
177
+
this.log('Failed to verify record on PDS', {
178
+
did,
179
+
site,
180
+
error: err instanceof Error ? err.message : String(err)
181
+
})
182
+
return
183
+
}
159
184
160
-
// Cache the record with verified CID (uses atomic swap internally)
161
-
// All instances cache locally for edge serving
162
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
185
+
// Cache the record with verified CID (uses atomic swap internally)
186
+
// All instances cache locally for edge serving
187
+
await downloadAndCacheSite(
188
+
did,
189
+
site,
190
+
fsRecord,
191
+
pdsEndpoint,
192
+
verifiedCid
193
+
)
163
194
164
-
// Acquire distributed lock only for database write to prevent duplicate writes
165
-
const lockKey = `db:upsert:${did}:${site}`;
166
-
const lockAcquired = await tryAcquireLock(lockKey);
195
+
// Acquire distributed lock only for database write to prevent duplicate writes
196
+
const lockKey = `db:upsert:${did}:${site}`
197
+
const lockAcquired = await tryAcquireLock(lockKey)
167
198
168
-
if (!lockAcquired) {
169
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
170
-
this.log('Successfully processed create/update (cached locally)', { did, site });
171
-
return;
172
-
}
199
+
if (!lockAcquired) {
200
+
this.log('Another instance is writing to DB, skipping upsert', {
201
+
did,
202
+
site
203
+
})
204
+
this.log('Successfully processed create/update (cached locally)', {
205
+
did,
206
+
site
207
+
})
208
+
return
209
+
}
173
210
174
-
try {
175
-
// Upsert site to database (only one instance does this)
176
-
await upsertSite(did, site, fsRecord.site);
177
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
178
-
} finally {
179
-
// Always release lock, even if DB write fails
180
-
await releaseLock(lockKey);
181
-
}
182
-
}
211
+
try {
212
+
// Upsert site to database (only one instance does this)
213
+
await upsertSite(did, site, fsRecord.site)
214
+
this.log(
215
+
'Successfully processed create/update (cached + DB updated)',
216
+
{ did, site }
217
+
)
218
+
} finally {
219
+
// Always release lock, even if DB write fails
220
+
await releaseLock(lockKey)
221
+
}
222
+
}
183
223
184
-
private async handleDelete(did: string, site: string) {
185
-
this.log('Processing delete', { did, site });
224
+
private async handleDelete(did: string, site: string) {
225
+
this.log('Processing delete', { did, site })
186
226
187
-
// All instances should delete their local cache (no lock needed)
188
-
const pdsEndpoint = await getPdsForDid(did);
189
-
if (!pdsEndpoint) {
190
-
this.log('Could not resolve PDS for DID', { did });
191
-
return;
192
-
}
227
+
// All instances should delete their local cache (no lock needed)
228
+
const pdsEndpoint = await getPdsForDid(did)
229
+
if (!pdsEndpoint) {
230
+
this.log('Could not resolve PDS for DID', { did })
231
+
return
232
+
}
193
233
194
-
// Verify record is actually deleted from PDS
195
-
try {
196
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
197
-
const recordRes = await safeFetch(recordUrl);
234
+
// Verify record is actually deleted from PDS
235
+
try {
236
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
237
+
const recordRes = await safeFetch(recordUrl)
198
238
199
-
if (recordRes.ok) {
200
-
this.log('Record still exists on PDS, not deleting cache', {
201
-
did,
202
-
site,
203
-
});
204
-
return;
205
-
}
239
+
if (recordRes.ok) {
240
+
this.log('Record still exists on PDS, not deleting cache', {
241
+
did,
242
+
site
243
+
})
244
+
return
245
+
}
206
246
207
-
this.log('Verified record is deleted from PDS', {
208
-
did,
209
-
site,
210
-
status: recordRes.status,
211
-
});
212
-
} catch (err) {
213
-
this.log('Error verifying deletion on PDS', {
214
-
did,
215
-
site,
216
-
error: err instanceof Error ? err.message : String(err),
217
-
});
218
-
}
247
+
this.log('Verified record is deleted from PDS', {
248
+
did,
249
+
site,
250
+
status: recordRes.status
251
+
})
252
+
} catch (err) {
253
+
this.log('Error verifying deletion on PDS', {
254
+
did,
255
+
site,
256
+
error: err instanceof Error ? err.message : String(err)
257
+
})
258
+
}
219
259
220
-
// Delete cache
221
-
this.deleteCache(did, site);
260
+
// Delete cache
261
+
this.deleteCache(did, site)
222
262
223
-
this.log('Successfully processed delete', { did, site });
224
-
}
263
+
this.log('Successfully processed delete', { did, site })
264
+
}
225
265
226
-
private deleteCache(did: string, site: string) {
227
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
266
+
private deleteCache(did: string, site: string) {
267
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
228
268
229
-
if (!existsSync(cacheDir)) {
230
-
this.log('Cache directory does not exist, nothing to delete', {
231
-
did,
232
-
site,
233
-
});
234
-
return;
235
-
}
269
+
if (!existsSync(cacheDir)) {
270
+
this.log('Cache directory does not exist, nothing to delete', {
271
+
did,
272
+
site
273
+
})
274
+
return
275
+
}
236
276
237
-
try {
238
-
rmSync(cacheDir, { recursive: true, force: true });
239
-
this.log('Cache deleted', { did, site, path: cacheDir });
240
-
} catch (err) {
241
-
this.log('Failed to delete cache', {
242
-
did,
243
-
site,
244
-
path: cacheDir,
245
-
error: err instanceof Error ? err.message : String(err),
246
-
});
247
-
}
248
-
}
277
+
try {
278
+
rmSync(cacheDir, { recursive: true, force: true })
279
+
this.log('Cache deleted', { did, site, path: cacheDir })
280
+
} catch (err) {
281
+
this.log('Failed to delete cache', {
282
+
did,
283
+
site,
284
+
path: cacheDir,
285
+
error: err instanceof Error ? err.message : String(err)
286
+
})
287
+
}
288
+
}
249
289
250
-
getHealth() {
251
-
const isConnected = this.firehose !== null;
252
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
290
+
getHealth() {
291
+
const isConnected = this.firehose !== null
292
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
253
293
254
-
return {
255
-
connected: isConnected,
256
-
lastEventTime: this.lastEventTime,
257
-
timeSinceLastEvent,
258
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
259
-
};
260
-
}
294
+
return {
295
+
connected: isConnected,
296
+
lastEventTime: this.lastEventTime,
297
+
timeSinceLastEvent,
298
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
299
+
}
300
+
}
261
301
}
+434
-84
hosting-service/src/lib/html-rewriter.test.ts
+434
-84
hosting-service/src/lib/html-rewriter.test.ts
···
1
-
/**
2
-
* Simple tests for HTML path rewriter
3
-
* Run with: bun test
4
-
*/
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
3
+
4
+
describe('rewriteHtmlPaths', () => {
5
+
const basePath = '/identifier/site/'
6
+
7
+
describe('absolute paths', () => {
8
+
test('rewrites absolute paths with leading slash', () => {
9
+
const html = '<img src="/image.png">'
10
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
11
+
expect(result).toBe('<img src="/identifier/site/image.png">')
12
+
})
13
+
14
+
test('rewrites nested absolute paths', () => {
15
+
const html = '<link href="/css/style.css">'
16
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
17
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
18
+
})
19
+
})
20
+
21
+
describe('relative paths from root document', () => {
22
+
test('rewrites relative paths with ./ prefix', () => {
23
+
const html = '<img src="./image.png">'
24
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
25
+
expect(result).toBe('<img src="/identifier/site/image.png">')
26
+
})
27
+
28
+
test('rewrites relative paths without prefix', () => {
29
+
const html = '<img src="image.png">'
30
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
31
+
expect(result).toBe('<img src="/identifier/site/image.png">')
32
+
})
33
+
34
+
test('rewrites relative paths with ../ (should stay at root)', () => {
35
+
const html = '<img src="../image.png">'
36
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
37
+
expect(result).toBe('<img src="/identifier/site/image.png">')
38
+
})
39
+
})
40
+
41
+
describe('relative paths from nested documents', () => {
42
+
test('rewrites relative path from nested document', () => {
43
+
const html = '<img src="./photo.jpg">'
44
+
const result = rewriteHtmlPaths(
45
+
html,
46
+
basePath,
47
+
'folder1/folder2/index.html'
48
+
)
49
+
expect(result).toBe(
50
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
51
+
)
52
+
})
53
+
54
+
test('rewrites plain filename from nested document', () => {
55
+
const html = '<script src="app.js"></script>'
56
+
const result = rewriteHtmlPaths(
57
+
html,
58
+
basePath,
59
+
'folder1/folder2/index.html'
60
+
)
61
+
expect(result).toBe(
62
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
63
+
)
64
+
})
65
+
66
+
test('rewrites ../ to go up one level', () => {
67
+
const html = '<img src="../image.png">'
68
+
const result = rewriteHtmlPaths(
69
+
html,
70
+
basePath,
71
+
'folder1/folder2/folder3/index.html'
72
+
)
73
+
expect(result).toBe(
74
+
'<img src="/identifier/site/folder1/folder2/image.png">'
75
+
)
76
+
})
77
+
78
+
test('rewrites multiple ../ to go up multiple levels', () => {
79
+
const html = '<link href="../../css/style.css">'
80
+
const result = rewriteHtmlPaths(
81
+
html,
82
+
basePath,
83
+
'folder1/folder2/folder3/index.html'
84
+
)
85
+
expect(result).toBe(
86
+
'<link href="/identifier/site/folder1/css/style.css">'
87
+
)
88
+
})
89
+
90
+
test('rewrites ../ with additional path segments', () => {
91
+
const html = '<img src="../assets/logo.png">'
92
+
const result = rewriteHtmlPaths(
93
+
html,
94
+
basePath,
95
+
'pages/about/index.html'
96
+
)
97
+
expect(result).toBe(
98
+
'<img src="/identifier/site/pages/assets/logo.png">'
99
+
)
100
+
})
101
+
102
+
test('handles complex nested relative paths', () => {
103
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
104
+
const result = rewriteHtmlPaths(
105
+
html,
106
+
basePath,
107
+
'pages/blog/post/index.html'
108
+
)
109
+
expect(result).toBe(
110
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
111
+
)
112
+
})
113
+
114
+
test('handles ../ going past root (stays at root)', () => {
115
+
const html = '<img src="../../../image.png">'
116
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
117
+
expect(result).toBe('<img src="/identifier/site/image.png">')
118
+
})
119
+
})
120
+
121
+
describe('external URLs and special schemes', () => {
122
+
test('does not rewrite http URLs', () => {
123
+
const html = '<img src="http://example.com/image.png">'
124
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
125
+
expect(result).toBe('<img src="http://example.com/image.png">')
126
+
})
5
127
6
-
import { test, expect } from 'bun:test';
7
-
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
128
+
test('does not rewrite https URLs', () => {
129
+
const html = '<link href="https://cdn.example.com/style.css">'
130
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
131
+
expect(result).toBe(
132
+
'<link href="https://cdn.example.com/style.css">'
133
+
)
134
+
})
8
135
9
-
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
10
-
const html = '<img src="/logo.png">';
11
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
12
-
expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">');
13
-
});
136
+
test('does not rewrite protocol-relative URLs', () => {
137
+
const html = '<script src="//cdn.example.com/script.js"></script>'
138
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
139
+
expect(result).toBe(
140
+
'<script src="//cdn.example.com/script.js"></script>'
141
+
)
142
+
})
14
143
15
-
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
16
-
const html = '<link rel="stylesheet" href="/style.css">';
17
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
18
-
expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">');
19
-
});
144
+
test('does not rewrite data URIs', () => {
145
+
const html =
146
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
147
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
148
+
expect(result).toBe(
149
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
150
+
)
151
+
})
20
152
21
-
test('rewriteHtmlPaths - preserves external URLs', () => {
22
-
const html = '<img src="https://example.com/logo.png">';
23
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
24
-
expect(result).toBe('<img src="https://example.com/logo.png">');
25
-
});
153
+
test('does not rewrite mailto links', () => {
154
+
const html = '<a href="mailto:test@example.com">Email</a>'
155
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
156
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
157
+
})
26
158
27
-
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
28
-
const html = '<script src="//cdn.example.com/script.js"></script>';
29
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
30
-
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
31
-
});
159
+
test('does not rewrite tel links', () => {
160
+
const html = '<a href="tel:+1234567890">Call</a>'
161
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
162
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
163
+
})
164
+
})
32
165
33
-
test('rewriteHtmlPaths - preserves data URIs', () => {
34
-
const html = '<img src="data:image/png;base64,abc123">';
35
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
36
-
expect(result).toBe('<img src="data:image/png;base64,abc123">');
37
-
});
166
+
describe('different HTML attributes', () => {
167
+
test('rewrites src attribute', () => {
168
+
const html = '<img src="/image.png">'
169
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
170
+
expect(result).toBe('<img src="/identifier/site/image.png">')
171
+
})
38
172
39
-
test('rewriteHtmlPaths - preserves anchors', () => {
40
-
const html = '<a href="/#section">Jump</a>';
41
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
42
-
expect(result).toBe('<a href="/#section">Jump</a>');
43
-
});
173
+
test('rewrites href attribute', () => {
174
+
const html = '<a href="/page.html">Link</a>'
175
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
176
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
177
+
})
44
178
45
-
test('rewriteHtmlPaths - preserves relative paths', () => {
46
-
const html = '<img src="./logo.png">';
47
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
48
-
expect(result).toBe('<img src="./logo.png">');
49
-
});
179
+
test('rewrites action attribute', () => {
180
+
const html = '<form action="/submit"></form>'
181
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
182
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
183
+
})
50
184
51
-
test('rewriteHtmlPaths - handles single quotes', () => {
52
-
const html = "<img src='/logo.png'>";
53
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
54
-
expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>");
55
-
});
185
+
test('rewrites data attribute', () => {
186
+
const html = '<object data="/document.pdf"></object>'
187
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
188
+
expect(result).toBe(
189
+
'<object data="/identifier/site/document.pdf"></object>'
190
+
)
191
+
})
56
192
57
-
test('rewriteHtmlPaths - handles srcset', () => {
58
-
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
59
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
60
-
expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">');
61
-
});
193
+
test('rewrites poster attribute', () => {
194
+
const html = '<video poster="/thumbnail.jpg"></video>'
195
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
196
+
expect(result).toBe(
197
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
198
+
)
199
+
})
62
200
63
-
test('rewriteHtmlPaths - handles form actions', () => {
64
-
const html = '<form action="/submit"></form>';
65
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
66
-
expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>');
67
-
});
201
+
test('rewrites srcset attribute with single URL', () => {
202
+
const html = '<img srcset="/image.png 1x">'
203
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
204
+
expect(result).toBe(
205
+
'<img srcset="/identifier/site/image.png 1x">'
206
+
)
207
+
})
68
208
69
-
test('rewriteHtmlPaths - handles complex HTML', () => {
70
-
const html = `
209
+
test('rewrites srcset attribute with multiple URLs', () => {
210
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
211
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
212
+
expect(result).toBe(
213
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
214
+
)
215
+
})
216
+
217
+
test('rewrites srcset with width descriptors', () => {
218
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
219
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
220
+
expect(result).toBe(
221
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
222
+
)
223
+
})
224
+
225
+
test('rewrites srcset with relative paths from nested document', () => {
226
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
227
+
const result = rewriteHtmlPaths(
228
+
html,
229
+
basePath,
230
+
'folder1/folder2/index.html'
231
+
)
232
+
expect(result).toBe(
233
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
234
+
)
235
+
})
236
+
})
237
+
238
+
describe('quote handling', () => {
239
+
test('handles double quotes', () => {
240
+
const html = '<img src="/image.png">'
241
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
242
+
expect(result).toBe('<img src="/identifier/site/image.png">')
243
+
})
244
+
245
+
test('handles single quotes', () => {
246
+
const html = "<img src='/image.png'>"
247
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
248
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
249
+
})
250
+
251
+
test('handles mixed quotes in same document', () => {
252
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
253
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
254
+
expect(result).toBe(
255
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
256
+
)
257
+
})
258
+
})
259
+
260
+
describe('multiple rewrites in same document', () => {
261
+
test('rewrites multiple attributes in complex HTML', () => {
262
+
const html = `
71
263
<!DOCTYPE html>
72
264
<html>
73
265
<head>
74
-
<link rel="stylesheet" href="/style.css">
75
-
<script src="/app.js"></script>
266
+
<link href="/css/style.css" rel="stylesheet">
267
+
<script src="/js/app.js"></script>
268
+
</head>
269
+
<body>
270
+
<img src="/images/logo.png" alt="Logo">
271
+
<a href="/about.html">About</a>
272
+
<form action="/submit">
273
+
<button type="submit">Submit</button>
274
+
</form>
275
+
</body>
276
+
</html>
277
+
`
278
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
279
+
expect(result).toContain('href="/identifier/site/css/style.css"')
280
+
expect(result).toContain('src="/identifier/site/js/app.js"')
281
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
282
+
expect(result).toContain('href="/identifier/site/about.html"')
283
+
expect(result).toContain('action="/identifier/site/submit"')
284
+
})
285
+
286
+
test('handles mix of relative and absolute paths', () => {
287
+
const html = `
288
+
<img src="/abs/image.png">
289
+
<img src="./rel/image.png">
290
+
<img src="../parent/image.png">
291
+
<img src="https://external.com/image.png">
292
+
`
293
+
const result = rewriteHtmlPaths(
294
+
html,
295
+
basePath,
296
+
'folder1/folder2/page.html'
297
+
)
298
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
299
+
expect(result).toContain(
300
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
301
+
)
302
+
expect(result).toContain(
303
+
'src="/identifier/site/folder1/parent/image.png"'
304
+
)
305
+
expect(result).toContain('src="https://external.com/image.png"')
306
+
})
307
+
})
308
+
309
+
describe('edge cases', () => {
310
+
test('handles empty src attribute', () => {
311
+
const html = '<img src="">'
312
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
313
+
expect(result).toBe('<img src="">')
314
+
})
315
+
316
+
test('handles basePath without trailing slash', () => {
317
+
const html = '<img src="/image.png">'
318
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
319
+
expect(result).toBe('<img src="/identifier/site/image.png">')
320
+
})
321
+
322
+
test('handles basePath with trailing slash', () => {
323
+
const html = '<img src="/image.png">'
324
+
const result = rewriteHtmlPaths(
325
+
html,
326
+
'/identifier/site/',
327
+
'index.html'
328
+
)
329
+
expect(result).toBe('<img src="/identifier/site/image.png">')
330
+
})
331
+
332
+
test('handles whitespace around equals sign', () => {
333
+
const html = '<img src = "/image.png">'
334
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
335
+
expect(result).toBe('<img src="/identifier/site/image.png">')
336
+
})
337
+
338
+
test('preserves query strings in URLs', () => {
339
+
const html = '<img src="/image.png?v=123">'
340
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
341
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
342
+
})
343
+
344
+
test('preserves hash fragments in URLs', () => {
345
+
const html = '<a href="/page.html#section">Link</a>'
346
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
347
+
expect(result).toBe(
348
+
'<a href="/identifier/site/page.html#section">Link</a>'
349
+
)
350
+
})
351
+
352
+
test('handles paths with special characters', () => {
353
+
const html = '<img src="/folder-name/file_name.png">'
354
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
355
+
expect(result).toBe(
356
+
'<img src="/identifier/site/folder-name/file_name.png">'
357
+
)
358
+
})
359
+
})
360
+
361
+
describe('real-world scenario', () => {
362
+
test('handles the example from the bug report', () => {
363
+
// HTML file at: /folder1/folder2/folder3/index.html
364
+
// Image at: /folder1/folder2/img.png
365
+
// Reference: src="../img.png"
366
+
const html = '<img src="../img.png">'
367
+
const result = rewriteHtmlPaths(
368
+
html,
369
+
basePath,
370
+
'folder1/folder2/folder3/index.html'
371
+
)
372
+
expect(result).toBe(
373
+
'<img src="/identifier/site/folder1/folder2/img.png">'
374
+
)
375
+
})
376
+
377
+
test('handles deeply nested static site structure', () => {
378
+
// A typical static site with nested pages and shared assets
379
+
const html = `
380
+
<!DOCTYPE html>
381
+
<html>
382
+
<head>
383
+
<link href="../../css/style.css" rel="stylesheet">
384
+
<link href="../../css/theme.css" rel="stylesheet">
385
+
<script src="../../js/main.js"></script>
76
386
</head>
77
387
<body>
78
-
<img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x">
79
-
<a href="/about">About</a>
80
-
<a href="https://example.com">External</a>
81
-
<a href="#section">Anchor</a>
388
+
<img src="../../images/logo.png" alt="Logo">
389
+
<img src="./post-image.jpg" alt="Post">
390
+
<a href="../index.html">Back to Blog</a>
391
+
<a href="../../index.html">Home</a>
82
392
</body>
83
393
</html>
84
-
`.trim();
394
+
`
395
+
const result = rewriteHtmlPaths(
396
+
html,
397
+
basePath,
398
+
'blog/posts/my-post.html'
399
+
)
400
+
401
+
// Assets two levels up
402
+
expect(result).toContain('href="/identifier/site/css/style.css"')
403
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
404
+
expect(result).toContain('src="/identifier/site/js/main.js"')
405
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
406
+
407
+
// Same directory
408
+
expect(result).toContain(
409
+
'src="/identifier/site/blog/posts/post-image.jpg"'
410
+
)
85
411
86
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
412
+
// One level up
413
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
87
414
88
-
expect(result).toContain('href="/did:plc:123/mysite/style.css"');
89
-
expect(result).toContain('src="/did:plc:123/mysite/app.js"');
90
-
expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"');
91
-
expect(result).toContain('href="/did:plc:123/mysite/about"');
92
-
expect(result).toContain('href="https://example.com"'); // External preserved
93
-
expect(result).toContain('href="#section"'); // Anchor preserved
94
-
});
415
+
// Two levels up
416
+
expect(result).toContain('href="/identifier/site/index.html"')
417
+
})
418
+
})
419
+
})
95
420
96
-
test('isHtmlContent - detects HTML by extension', () => {
97
-
expect(isHtmlContent('index.html')).toBe(true);
98
-
expect(isHtmlContent('page.htm')).toBe(true);
99
-
expect(isHtmlContent('style.css')).toBe(false);
100
-
expect(isHtmlContent('script.js')).toBe(false);
101
-
});
421
+
describe('isHtmlContent', () => {
422
+
test('identifies HTML by content type', () => {
423
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
424
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
425
+
true
426
+
)
427
+
})
102
428
103
-
test('isHtmlContent - detects HTML by content type', () => {
104
-
expect(isHtmlContent('index', 'text/html')).toBe(true);
105
-
expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true);
106
-
expect(isHtmlContent('index', 'application/json')).toBe(false);
107
-
});
429
+
test('identifies HTML by .html extension', () => {
430
+
expect(isHtmlContent('index.html')).toBe(true)
431
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
432
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
433
+
})
434
+
435
+
test('identifies HTML by .htm extension', () => {
436
+
expect(isHtmlContent('index.htm')).toBe(true)
437
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
438
+
})
439
+
440
+
test('handles case-insensitive extensions', () => {
441
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
442
+
expect(isHtmlContent('page.HTM')).toBe(true)
443
+
expect(isHtmlContent('File.HtMl')).toBe(true)
444
+
})
445
+
446
+
test('returns false for non-HTML files', () => {
447
+
expect(isHtmlContent('script.js')).toBe(false)
448
+
expect(isHtmlContent('style.css')).toBe(false)
449
+
expect(isHtmlContent('image.png')).toBe(false)
450
+
expect(isHtmlContent('data.json')).toBe(false)
451
+
})
452
+
453
+
test('returns false for files with no extension', () => {
454
+
expect(isHtmlContent('README')).toBe(false)
455
+
expect(isHtmlContent('Makefile')).toBe(false)
456
+
})
457
+
})
+178
-104
hosting-service/src/lib/html-rewriter.ts
+178
-104
hosting-service/src/lib/html-rewriter.ts
···
4
4
*/
5
5
6
6
const REWRITABLE_ATTRIBUTES = [
7
-
'src',
8
-
'href',
9
-
'action',
10
-
'data',
11
-
'poster',
12
-
'srcset',
13
-
] as const;
7
+
'src',
8
+
'href',
9
+
'action',
10
+
'data',
11
+
'poster',
12
+
'srcset'
13
+
] as const
14
14
15
15
/**
16
16
* Check if a path should be rewritten
17
17
*/
18
18
function shouldRewritePath(path: string): boolean {
19
-
// Don't rewrite empty paths
20
-
if (!path) return false;
19
+
// Don't rewrite empty paths
20
+
if (!path) return false
21
+
22
+
// Don't rewrite external URLs (http://, https://, //)
23
+
if (
24
+
path.startsWith('http://') ||
25
+
path.startsWith('https://') ||
26
+
path.startsWith('//')
27
+
) {
28
+
return false
29
+
}
30
+
31
+
// Don't rewrite data URIs or other schemes (except file paths)
32
+
if (
33
+
path.includes(':') &&
34
+
!path.startsWith('./') &&
35
+
!path.startsWith('../')
36
+
) {
37
+
return false
38
+
}
39
+
40
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
41
+
return true
42
+
}
21
43
22
-
// Don't rewrite external URLs (http://, https://, //)
23
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
24
-
return false;
25
-
}
44
+
/**
45
+
* Normalize a path by resolving . and .. segments
46
+
*/
47
+
function normalizePath(path: string): string {
48
+
const parts = path.split('/')
49
+
const result: string[] = []
26
50
27
-
// Don't rewrite data URIs or other schemes (except file paths)
28
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
29
-
return false;
30
-
}
51
+
for (const part of parts) {
52
+
if (part === '.' || part === '') {
53
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
54
+
if (part === '' && result.length === 0) {
55
+
result.push(part)
56
+
}
57
+
continue
58
+
}
59
+
if (part === '..') {
60
+
// Go up one directory (but not past root)
61
+
if (result.length > 0 && result[result.length - 1] !== '..') {
62
+
result.pop()
63
+
}
64
+
continue
65
+
}
66
+
result.push(part)
67
+
}
31
68
32
-
// Don't rewrite pure anchors
33
-
if (path.startsWith('#')) return false;
69
+
return result.join('/')
70
+
}
34
71
35
-
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
36
-
return true;
72
+
/**
73
+
* Get the directory path from a file path
74
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
75
+
*/
76
+
function getDirectory(filepath: string): string {
77
+
const lastSlash = filepath.lastIndexOf('/')
78
+
if (lastSlash === -1) {
79
+
return ''
80
+
}
81
+
return filepath.substring(0, lastSlash + 1)
37
82
}
38
83
39
84
/**
40
85
* Rewrite a single path
41
86
*/
42
-
function rewritePath(path: string, basePath: string): string {
43
-
if (!shouldRewritePath(path)) {
44
-
return path;
45
-
}
87
+
function rewritePath(
88
+
path: string,
89
+
basePath: string,
90
+
documentPath: string
91
+
): string {
92
+
if (!shouldRewritePath(path)) {
93
+
return path
94
+
}
46
95
47
-
// Handle absolute paths: /file.js -> /base/file.js
48
-
if (path.startsWith('/')) {
49
-
return basePath + path.slice(1);
50
-
}
96
+
// Handle absolute paths: /file.js -> /base/file.js
97
+
if (path.startsWith('/')) {
98
+
return basePath + path.slice(1)
99
+
}
51
100
52
-
// Handle relative paths: ./file.js or ../file.js or file.js -> /base/file.js
53
-
// Strip leading ./ or ../ and just use the base path
54
-
let cleanPath = path;
55
-
if (cleanPath.startsWith('./')) {
56
-
cleanPath = cleanPath.slice(2);
57
-
} else if (cleanPath.startsWith('../')) {
58
-
// For sites.wisp.place, we can't go up from the site root, so just use base path
59
-
cleanPath = cleanPath.replace(/^(\.\.\/)+/, '');
60
-
}
101
+
// Handle relative paths by resolving against document directory
102
+
const documentDir = getDirectory(documentPath)
103
+
let resolvedPath: string
104
+
105
+
if (path.startsWith('./')) {
106
+
// ./file.js relative to current directory
107
+
resolvedPath = documentDir + path.slice(2)
108
+
} else if (path.startsWith('../')) {
109
+
// ../file.js relative to parent directory
110
+
resolvedPath = documentDir + path
111
+
} else {
112
+
// file.js (no prefix) - treat as relative to current directory
113
+
resolvedPath = documentDir + path
114
+
}
115
+
116
+
// Normalize the path to resolve .. and .
117
+
resolvedPath = normalizePath(resolvedPath)
61
118
62
-
return basePath + cleanPath;
119
+
return basePath + resolvedPath
63
120
}
64
121
65
122
/**
66
123
* Rewrite srcset attribute (can contain multiple URLs)
67
124
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
68
125
*/
69
-
function rewriteSrcset(srcset: string, basePath: string): string {
70
-
return srcset
71
-
.split(',')
72
-
.map(part => {
73
-
const trimmed = part.trim();
74
-
const spaceIndex = trimmed.indexOf(' ');
126
+
function rewriteSrcset(
127
+
srcset: string,
128
+
basePath: string,
129
+
documentPath: string
130
+
): string {
131
+
return srcset
132
+
.split(',')
133
+
.map((part) => {
134
+
const trimmed = part.trim()
135
+
const spaceIndex = trimmed.indexOf(' ')
75
136
76
-
if (spaceIndex === -1) {
77
-
// No descriptor, just URL
78
-
return rewritePath(trimmed, basePath);
79
-
}
137
+
if (spaceIndex === -1) {
138
+
// No descriptor, just URL
139
+
return rewritePath(trimmed, basePath, documentPath)
140
+
}
80
141
81
-
const url = trimmed.substring(0, spaceIndex);
82
-
const descriptor = trimmed.substring(spaceIndex);
83
-
return rewritePath(url, basePath) + descriptor;
84
-
})
85
-
.join(', ');
142
+
const url = trimmed.substring(0, spaceIndex)
143
+
const descriptor = trimmed.substring(spaceIndex)
144
+
return rewritePath(url, basePath, documentPath) + descriptor
145
+
})
146
+
.join(', ')
86
147
}
87
148
88
149
/**
89
-
* Rewrite absolute paths in HTML content
150
+
* Rewrite absolute and relative paths in HTML content
90
151
* Uses simple regex matching for safety (no full HTML parsing)
91
152
*/
92
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
93
-
// Ensure base path ends with /
94
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
153
+
export function rewriteHtmlPaths(
154
+
html: string,
155
+
basePath: string,
156
+
documentPath: string
157
+
): string {
158
+
// Ensure base path ends with /
159
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
95
160
96
-
let rewritten = html;
161
+
let rewritten = html
97
162
98
-
// Rewrite each attribute type
99
-
// Use more specific patterns to prevent ReDoS attacks
100
-
for (const attr of REWRITABLE_ATTRIBUTES) {
101
-
if (attr === 'srcset') {
102
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
103
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
104
-
const srcsetRegex = new RegExp(
105
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
106
-
'gi'
107
-
);
108
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
109
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
110
-
return `${attr}="${rewrittenValue}"`;
111
-
});
112
-
} else {
113
-
// Regular attributes with quoted values
114
-
// Limit whitespace to prevent catastrophic backtracking
115
-
const doubleQuoteRegex = new RegExp(
116
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
117
-
'gi'
118
-
);
119
-
const singleQuoteRegex = new RegExp(
120
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
121
-
'gi'
122
-
);
163
+
// Rewrite each attribute type
164
+
// Use more specific patterns to prevent ReDoS attacks
165
+
for (const attr of REWRITABLE_ATTRIBUTES) {
166
+
if (attr === 'srcset') {
167
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
168
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
169
+
const srcsetRegex = new RegExp(
170
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
171
+
'gi'
172
+
)
173
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
174
+
const rewrittenValue = rewriteSrcset(
175
+
value,
176
+
normalizedBase,
177
+
documentPath
178
+
)
179
+
return `${attr}="${rewrittenValue}"`
180
+
})
181
+
} else {
182
+
// Regular attributes with quoted values
183
+
// Limit whitespace to prevent catastrophic backtracking
184
+
const doubleQuoteRegex = new RegExp(
185
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
186
+
'gi'
187
+
)
188
+
const singleQuoteRegex = new RegExp(
189
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
190
+
'gi'
191
+
)
123
192
124
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
125
-
const rewrittenValue = rewritePath(value, normalizedBase);
126
-
return `${attr}="${rewrittenValue}"`;
127
-
});
193
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
194
+
const rewrittenValue = rewritePath(
195
+
value,
196
+
normalizedBase,
197
+
documentPath
198
+
)
199
+
return `${attr}="${rewrittenValue}"`
200
+
})
128
201
129
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
130
-
const rewrittenValue = rewritePath(value, normalizedBase);
131
-
return `${attr}='${rewrittenValue}'`;
132
-
});
133
-
}
134
-
}
202
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
203
+
const rewrittenValue = rewritePath(
204
+
value,
205
+
normalizedBase,
206
+
documentPath
207
+
)
208
+
return `${attr}='${rewrittenValue}'`
209
+
})
210
+
}
211
+
}
135
212
136
-
return rewritten;
213
+
return rewritten
137
214
}
138
215
139
216
/**
140
217
* Check if content is HTML based on content or filename
141
218
*/
142
-
export function isHtmlContent(
143
-
filepath: string,
144
-
contentType?: string
145
-
): boolean {
146
-
if (contentType && contentType.includes('text/html')) {
147
-
return true;
148
-
}
219
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
220
+
if (contentType && contentType.includes('text/html')) {
221
+
return true
222
+
}
149
223
150
-
const ext = filepath.toLowerCase().split('.').pop();
151
-
return ext === 'html' || ext === 'htm';
224
+
const ext = filepath.toLowerCase().split('.').pop()
225
+
return ext === 'html' || ext === 'htm'
152
226
}
+36
-38
hosting-service/src/lib/observability.ts
+36
-38
hosting-service/src/lib/observability.ts
···
1
1
// DIY Observability for Hosting Service
2
-
import type { Context } from 'elysia'
2
+
import type { Context } from 'hono'
3
3
4
4
// Types
5
5
export interface LogEntry {
···
175
175
// Rotate if needed
176
176
if (errors.size > MAX_ERRORS) {
177
177
const oldest = Array.from(errors.keys())[0]
178
-
errors.delete(oldest)
178
+
if (oldest !== undefined) {
179
+
errors.delete(oldest)
180
+
}
179
181
}
180
182
}
181
183
},
···
262
264
return {
263
265
totalRequests: filtered.length,
264
266
avgDuration: Math.round(totalDuration / filtered.length),
265
-
p50Duration: Math.round(p50),
266
-
p95Duration: Math.round(p95),
267
-
p99Duration: Math.round(p99),
267
+
p50Duration: Math.round(p50 ?? 0),
268
+
p95Duration: Math.round(p95 ?? 0),
269
+
p99Duration: Math.round(p99 ?? 0),
268
270
errorRate: (errors / filtered.length) * 100,
269
271
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
270
272
}
···
275
277
}
276
278
}
277
279
278
-
// Elysia middleware for request timing
280
+
// Hono middleware for request timing
279
281
export function observabilityMiddleware(service: string) {
280
-
return {
281
-
beforeHandle: ({ request }: any) => {
282
-
(request as any).__startTime = Date.now()
283
-
},
284
-
afterHandle: ({ request, set }: any) => {
285
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
286
-
const url = new URL(request.url)
282
+
return async (c: Context, next: () => Promise<void>) => {
283
+
const startTime = Date.now()
284
+
285
+
await next()
286
+
287
+
const duration = Date.now() - startTime
288
+
const { pathname } = new URL(c.req.url)
287
289
288
-
metricsCollector.recordRequest(
289
-
url.pathname,
290
-
request.method,
291
-
set.status || 200,
292
-
duration,
293
-
service
294
-
)
295
-
},
296
-
onError: ({ request, error, set }: any) => {
297
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
298
-
const url = new URL(request.url)
290
+
metricsCollector.recordRequest(
291
+
pathname,
292
+
c.req.method,
293
+
c.res.status,
294
+
duration,
295
+
service
296
+
)
297
+
}
298
+
}
299
299
300
-
metricsCollector.recordRequest(
301
-
url.pathname,
302
-
request.method,
303
-
set.status || 500,
304
-
duration,
305
-
service
306
-
)
300
+
// Hono error handler
301
+
export function observabilityErrorHandler(service: string) {
302
+
return (err: Error, c: Context) => {
303
+
const { pathname } = new URL(c.req.url)
304
+
305
+
logCollector.error(
306
+
`Request failed: ${c.req.method} ${pathname}`,
307
+
service,
308
+
err,
309
+
{ statusCode: c.res.status || 500 }
310
+
)
307
311
308
-
logCollector.error(
309
-
`Request failed: ${request.method} ${url.pathname}`,
310
-
service,
311
-
error,
312
-
{ statusCode: set.status || 500 }
313
-
)
314
-
}
312
+
return c.text('Internal Server Error', 500)
315
313
}
316
314
}
317
315
+8
-3
hosting-service/src/lib/safe-fetch.ts
+8
-3
hosting-service/src/lib/safe-fetch.ts
···
24
24
const FETCH_TIMEOUT = 120000; // 120 seconds
25
25
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
26
26
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
27
+
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
28
+
const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB
29
+
const MAX_REDIRECTS = 10;
27
30
28
31
function isBlockedHost(hostname: string): boolean {
29
32
const lowerHost = hostname.toLowerCase();
···
72
75
const response = await fetch(url, {
73
76
...options,
74
77
signal: controller.signal,
78
+
redirect: 'follow',
75
79
});
76
80
77
81
const contentLength = response.headers.get('content-length');
···
94
98
url: string,
95
99
options?: RequestInit & { maxSize?: number; timeout?: number }
96
100
): Promise<T> {
97
-
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
101
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
98
102
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
99
103
100
104
if (!response.ok) {
···
140
144
url: string,
141
145
options?: RequestInit & { maxSize?: number; timeout?: number }
142
146
): Promise<Uint8Array> {
143
-
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
144
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
147
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
148
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
149
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
145
150
146
151
if (!response.ok) {
147
152
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
+169
hosting-service/src/lib/utils.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { sanitizePath, extractBlobCid } from './utils'
3
+
import { CID } from 'multiformats'
4
+
5
+
describe('sanitizePath', () => {
6
+
test('allows normal file paths', () => {
7
+
expect(sanitizePath('index.html')).toBe('index.html')
8
+
expect(sanitizePath('css/styles.css')).toBe('css/styles.css')
9
+
expect(sanitizePath('images/logo.png')).toBe('images/logo.png')
10
+
expect(sanitizePath('js/app.js')).toBe('js/app.js')
11
+
})
12
+
13
+
test('allows deeply nested paths', () => {
14
+
expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico')
15
+
expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt')
16
+
})
17
+
18
+
test('removes leading slashes', () => {
19
+
expect(sanitizePath('/index.html')).toBe('index.html')
20
+
expect(sanitizePath('//index.html')).toBe('index.html')
21
+
expect(sanitizePath('///index.html')).toBe('index.html')
22
+
expect(sanitizePath('/css/styles.css')).toBe('css/styles.css')
23
+
})
24
+
25
+
test('blocks parent directory traversal', () => {
26
+
expect(sanitizePath('../etc/passwd')).toBe('etc/passwd')
27
+
expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd')
28
+
expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd')
29
+
expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd')
30
+
})
31
+
32
+
test('blocks directory traversal in middle of path', () => {
33
+
expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd')
34
+
// Note: sanitizePath only filters out ".." segments, doesn't resolve paths
35
+
expect(sanitizePath('a/b/../c')).toBe('a/b/c')
36
+
expect(sanitizePath('a/../b/../c')).toBe('a/b/c')
37
+
})
38
+
39
+
test('removes current directory references', () => {
40
+
expect(sanitizePath('./index.html')).toBe('index.html')
41
+
expect(sanitizePath('././index.html')).toBe('index.html')
42
+
expect(sanitizePath('css/./styles.css')).toBe('css/styles.css')
43
+
expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css')
44
+
})
45
+
46
+
test('removes empty path segments', () => {
47
+
expect(sanitizePath('css//styles.css')).toBe('css/styles.css')
48
+
expect(sanitizePath('css///styles.css')).toBe('css/styles.css')
49
+
expect(sanitizePath('a//b//c')).toBe('a/b/c')
50
+
})
51
+
52
+
test('blocks null bytes', () => {
53
+
// Null bytes cause the entire segment to be filtered out
54
+
expect(sanitizePath('index.html\0.txt')).toBe('')
55
+
expect(sanitizePath('test\0')).toBe('')
56
+
// Null byte in middle segment
57
+
expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css')
58
+
})
59
+
60
+
test('handles mixed attacks', () => {
61
+
expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd')
62
+
expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd')
63
+
expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd')
64
+
})
65
+
66
+
test('handles edge cases', () => {
67
+
expect(sanitizePath('')).toBe('')
68
+
expect(sanitizePath('/')).toBe('')
69
+
expect(sanitizePath('//')).toBe('')
70
+
expect(sanitizePath('.')).toBe('')
71
+
expect(sanitizePath('..')).toBe('')
72
+
expect(sanitizePath('../..')).toBe('')
73
+
})
74
+
75
+
test('preserves valid special characters in filenames', () => {
76
+
expect(sanitizePath('file-name.html')).toBe('file-name.html')
77
+
expect(sanitizePath('file_name.html')).toBe('file_name.html')
78
+
expect(sanitizePath('file.name.html')).toBe('file.name.html')
79
+
expect(sanitizePath('file (1).html')).toBe('file (1).html')
80
+
expect(sanitizePath('file@2x.png')).toBe('file@2x.png')
81
+
})
82
+
83
+
test('handles Unicode characters', () => {
84
+
expect(sanitizePath('ๆไปถ.html')).toBe('ๆไปถ.html')
85
+
expect(sanitizePath('ัะฐะนะป.html')).toBe('ัะฐะนะป.html')
86
+
expect(sanitizePath('ใใกใคใซ.html')).toBe('ใใกใคใซ.html')
87
+
})
88
+
})
89
+
90
+
describe('extractBlobCid', () => {
91
+
const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
92
+
93
+
test('extracts CID from IPLD link', () => {
94
+
const blobRef = { $link: TEST_CID }
95
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
96
+
})
97
+
98
+
test('extracts CID from typed BlobRef with CID object', () => {
99
+
const cid = CID.parse(TEST_CID)
100
+
const blobRef = { ref: cid }
101
+
const result = extractBlobCid(blobRef)
102
+
expect(result).toBe(TEST_CID)
103
+
})
104
+
105
+
test('extracts CID from typed BlobRef with IPLD link', () => {
106
+
const blobRef = {
107
+
ref: { $link: TEST_CID }
108
+
}
109
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
110
+
})
111
+
112
+
test('extracts CID from untyped BlobRef', () => {
113
+
const blobRef = { cid: TEST_CID }
114
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
115
+
})
116
+
117
+
test('returns null for invalid blob ref', () => {
118
+
expect(extractBlobCid(null)).toBe(null)
119
+
expect(extractBlobCid(undefined)).toBe(null)
120
+
expect(extractBlobCid({})).toBe(null)
121
+
expect(extractBlobCid('not-an-object')).toBe(null)
122
+
expect(extractBlobCid(123)).toBe(null)
123
+
})
124
+
125
+
test('returns null for malformed objects', () => {
126
+
expect(extractBlobCid({ wrongKey: 'value' })).toBe(null)
127
+
expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null)
128
+
expect(extractBlobCid({ ref: {} })).toBe(null)
129
+
})
130
+
131
+
test('handles nested structures from AT Proto API', () => {
132
+
// Real structure from AT Proto
133
+
const blobRef = {
134
+
$type: 'blob',
135
+
ref: CID.parse(TEST_CID),
136
+
mimeType: 'text/html',
137
+
size: 1234
138
+
}
139
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
140
+
})
141
+
142
+
test('handles BlobRef with additional properties', () => {
143
+
const blobRef = {
144
+
ref: { $link: TEST_CID },
145
+
mimeType: 'image/png',
146
+
size: 5678,
147
+
someOtherField: 'value'
148
+
}
149
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
150
+
})
151
+
152
+
test('prioritizes checking IPLD link first', () => {
153
+
// Direct $link takes precedence
154
+
const directLink = { $link: TEST_CID }
155
+
expect(extractBlobCid(directLink)).toBe(TEST_CID)
156
+
})
157
+
158
+
test('handles CID v0 format', () => {
159
+
const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx'
160
+
const blobRef = { $link: cidV0 }
161
+
expect(extractBlobCid(blobRef)).toBe(cidV0)
162
+
})
163
+
164
+
test('handles CID v1 format', () => {
165
+
const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
166
+
const blobRef = { $link: cidV1 }
167
+
expect(extractBlobCid(blobRef)).toBe(cidV1)
168
+
})
169
+
})
+85
-8
hosting-service/src/lib/utils.ts
+85
-8
hosting-service/src/lib/utils.ts
···
3
3
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
4
4
import { writeFile, readFile, rename } from 'fs/promises';
5
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
-
import { CID } from 'multiformats/cid';
6
+
import { CID } from 'multiformats';
7
7
8
-
const CACHE_DIR = './cache/sites';
8
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
9
9
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
10
10
11
11
interface CacheMetadata {
···
13
13
cachedAt: number;
14
14
did: string;
15
15
rkey: string;
16
+
}
17
+
18
+
/**
19
+
* Determines if a MIME type should benefit from gzip compression.
20
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
21
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
22
+
*
23
+
*/
24
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
25
+
if (!mimeType) return false;
26
+
27
+
const mime = mimeType.toLowerCase();
28
+
29
+
// Text-based web assets that benefit from compression
30
+
const compressibleTypes = [
31
+
'text/html',
32
+
'text/css',
33
+
'text/javascript',
34
+
'application/javascript',
35
+
'application/x-javascript',
36
+
'text/xml',
37
+
'application/xml',
38
+
'application/json',
39
+
'text/plain',
40
+
'image/svg+xml',
41
+
];
42
+
43
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
44
+
return true;
45
+
}
46
+
47
+
// Already-compressed formats that should NOT be double-compressed
48
+
const alreadyCompressedPrefixes = [
49
+
'video/',
50
+
'audio/',
51
+
'image/',
52
+
'application/pdf',
53
+
'application/zip',
54
+
'application/gzip',
55
+
];
56
+
57
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
58
+
return false;
59
+
}
60
+
61
+
// Default to not compressing for unknown types
62
+
return false;
16
63
}
17
64
18
65
interface IpldLink {
···
268
315
// Allow up to 100MB per file blob, with 2 minute timeout
269
316
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 });
270
317
271
-
// If content is base64-encoded, decode it back to gzipped binary
272
-
if (base64 && encoding === 'gzip') {
273
-
// Convert Uint8Array to Buffer for proper string conversion
274
-
const buffer = Buffer.from(content);
275
-
const base64String = buffer.toString('utf-8');
318
+
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
319
+
320
+
// If content is base64-encoded, decode it back to raw binary (gzipped or not)
321
+
if (base64) {
322
+
const originalSize = content.length;
323
+
// Decode base64 directly from raw bytes - no string conversion
324
+
// The blob contains base64-encoded text as raw bytes, decode it in-place
325
+
const textDecoder = new TextDecoder();
326
+
const base64String = textDecoder.decode(content);
276
327
content = Buffer.from(base64String, 'base64');
328
+
console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`);
329
+
330
+
// Check if it's actually gzipped by looking at magic bytes
331
+
if (content.length >= 2) {
332
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
333
+
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`);
334
+
}
277
335
}
278
336
279
337
const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
···
283
341
mkdirSync(fileDir, { recursive: true });
284
342
}
285
343
344
+
// Use the shared function to determine if this should remain compressed
345
+
const shouldStayCompressed = shouldCompressMimeType(mimeType);
346
+
347
+
// Decompress files that shouldn't be stored compressed
348
+
if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
349
+
content[0] === 0x1f && content[1] === 0x8b) {
350
+
console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`);
351
+
try {
352
+
const { gunzipSync } = await import('zlib');
353
+
const decompressed = gunzipSync(content);
354
+
console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
355
+
content = decompressed;
356
+
// Clear the encoding flag since we're storing decompressed
357
+
encoding = undefined;
358
+
} catch (error) {
359
+
console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error);
360
+
}
361
+
}
362
+
286
363
await writeFile(cacheFile, content);
287
364
288
-
// Store metadata if file is compressed
365
+
// Store metadata only if file is still compressed
289
366
if (encoding === 'gzip' && mimeType) {
290
367
const metaFile = `${cacheFile}.meta`;
291
368
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+201
-151
hosting-service/src/server.ts
+201
-151
hosting-service/src/server.ts
···
1
-
import { Elysia } from 'elysia';
2
-
import { node } from '@elysiajs/node'
3
-
import { opentelemetry } from '@elysiajs/opentelemetry';
1
+
import { Hono } from 'hono';
4
2
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
5
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
3
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
6
4
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
7
5
import { existsSync, readFileSync } from 'fs';
8
6
import { lookup } from 'mime-types';
9
-
import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
7
+
import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
10
8
11
9
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
12
10
···
37
35
const content = readFileSync(cachedFile);
38
36
const metaFile = `${cachedFile}.meta`;
39
37
38
+
console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`);
39
+
40
40
// Check if file has compression metadata
41
41
if (existsSync(metaFile)) {
42
42
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43
+
console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`);
44
+
45
+
// Check actual content for gzip magic bytes
46
+
if (content.length >= 2) {
47
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
48
+
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
49
+
}
50
+
43
51
if (meta.encoding === 'gzip' && meta.mimeType) {
44
-
// Serve gzipped content with proper headers
52
+
// Use shared function to determine if this should be served compressed
53
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
54
+
55
+
if (!shouldServeCompressed) {
56
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
57
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
58
+
const { gunzipSync } = await import('zlib');
59
+
const decompressed = gunzipSync(content);
60
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
61
+
return new Response(decompressed, {
62
+
headers: {
63
+
'Content-Type': meta.mimeType,
64
+
},
65
+
});
66
+
}
67
+
68
+
// Serve gzipped content with proper headers (for HTML, CSS, JS, etc.)
69
+
console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`);
45
70
return new Response(content, {
46
71
headers: {
47
72
'Content-Type': meta.mimeType,
···
121
146
}
122
147
123
148
// Check if this is HTML content that needs rewriting
124
-
// Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
125
-
// This is a trade-off for the sites.wisp.place domain which needs path rewriting
149
+
// We decompress, rewrite paths, then recompress for efficient delivery
126
150
if (isHtmlContent(requestPath, mimeType)) {
127
151
let content: string;
128
152
if (isGzipped) {
···
132
156
} else {
133
157
content = readFileSync(cachedFile, 'utf-8');
134
158
}
135
-
const rewritten = rewriteHtmlPaths(content, basePath);
136
-
return new Response(rewritten, {
159
+
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
160
+
161
+
// Recompress the HTML for efficient delivery
162
+
const { gzipSync } = await import('zlib');
163
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
164
+
165
+
return new Response(recompressed, {
137
166
headers: {
138
167
'Content-Type': 'text/html; charset=utf-8',
168
+
'Content-Encoding': 'gzip',
139
169
},
140
170
});
141
171
}
···
143
173
// Non-HTML files: serve gzipped content as-is with proper headers
144
174
const content = readFileSync(cachedFile);
145
175
if (isGzipped) {
176
+
// Use shared function to determine if this should be served compressed
177
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
178
+
179
+
if (!shouldServeCompressed) {
180
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
181
+
const { gunzipSync } = await import('zlib');
182
+
const decompressed = gunzipSync(content);
183
+
return new Response(decompressed, {
184
+
headers: {
185
+
'Content-Type': mimeType,
186
+
},
187
+
});
188
+
}
189
+
146
190
return new Response(content, {
147
191
headers: {
148
192
'Content-Type': mimeType,
···
171
215
}
172
216
}
173
217
174
-
// HTML needs path rewriting, so decompress if needed
218
+
// HTML needs path rewriting, decompress, rewrite, then recompress
175
219
let content: string;
176
220
if (isGzipped) {
177
221
const { gunzipSync } = await import('zlib');
···
180
224
} else {
181
225
content = readFileSync(indexFile, 'utf-8');
182
226
}
183
-
const rewritten = rewriteHtmlPaths(content, basePath);
184
-
return new Response(rewritten, {
227
+
const indexPath = `${requestPath}/index.html`;
228
+
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
229
+
230
+
// Recompress the HTML for efficient delivery
231
+
const { gzipSync } = await import('zlib');
232
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
233
+
234
+
return new Response(recompressed, {
185
235
headers: {
186
236
'Content-Type': 'text/html; charset=utf-8',
237
+
'Content-Encoding': 'gzip',
187
238
},
188
239
});
189
240
}
···
221
272
}
222
273
}
223
274
224
-
const app = new Elysia({ adapter: node() })
225
-
.use(opentelemetry())
226
-
.onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
227
-
.onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
228
-
.onError(observabilityMiddleware('hosting-service').onError)
229
-
.get('/*', async ({ request, set }) => {
230
-
const url = new URL(request.url);
231
-
const hostname = request.headers.get('host') || '';
232
-
const rawPath = url.pathname.replace(/^\//, '');
233
-
const path = sanitizePath(rawPath);
234
-
235
-
// Check if this is sites.wisp.place subdomain
236
-
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
237
-
// Sanitize the path FIRST to prevent path traversal
238
-
const sanitizedFullPath = sanitizePath(rawPath);
239
-
240
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
241
-
const pathParts = sanitizedFullPath.split('/');
242
-
if (pathParts.length < 2) {
243
-
set.status = 400;
244
-
return 'Invalid path format. Expected: /identifier/sitename/path';
245
-
}
246
-
247
-
const identifier = pathParts[0];
248
-
const site = pathParts[1];
249
-
const filePath = pathParts.slice(2).join('/');
275
+
const app = new Hono();
250
276
251
-
// Additional validation: identifier must be a valid DID or handle format
252
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
253
-
set.status = 400;
254
-
return 'Invalid identifier';
255
-
}
277
+
// Add observability middleware
278
+
app.use('*', observabilityMiddleware('hosting-service'));
256
279
257
-
// Validate site name (rkey)
258
-
if (!isValidRkey(site)) {
259
-
set.status = 400;
260
-
return 'Invalid site name';
261
-
}
280
+
// Error handler
281
+
app.onError(observabilityErrorHandler('hosting-service'));
262
282
263
-
// Resolve identifier to DID
264
-
const did = await resolveDid(identifier);
265
-
if (!did) {
266
-
set.status = 400;
267
-
return 'Invalid identifier';
268
-
}
283
+
// Main site serving route
284
+
app.get('/*', async (c) => {
285
+
const url = new URL(c.req.url);
286
+
const hostname = c.req.header('host') || '';
287
+
const rawPath = url.pathname.replace(/^\//, '');
288
+
const path = sanitizePath(rawPath);
269
289
270
-
// Ensure site is cached
271
-
const cached = await ensureSiteCached(did, site);
272
-
if (!cached) {
273
-
set.status = 404;
274
-
return 'Site not found';
275
-
}
290
+
// Check if this is sites.wisp.place subdomain
291
+
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
292
+
// Sanitize the path FIRST to prevent path traversal
293
+
const sanitizedFullPath = sanitizePath(rawPath);
276
294
277
-
// Serve with HTML path rewriting to handle absolute paths
278
-
const basePath = `/${identifier}/${site}/`;
279
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
295
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
296
+
const pathParts = sanitizedFullPath.split('/');
297
+
if (pathParts.length < 2) {
298
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
280
299
}
281
300
282
-
// Check if this is a DNS hash subdomain
283
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
284
-
if (dnsMatch) {
285
-
const hash = dnsMatch[1];
286
-
const baseDomain = dnsMatch[2];
287
-
288
-
if (baseDomain !== BASE_HOST) {
289
-
set.status = 400;
290
-
return 'Invalid base domain';
291
-
}
292
-
293
-
const customDomain = await getCustomDomainByHash(hash);
294
-
if (!customDomain) {
295
-
set.status = 404;
296
-
return 'Custom domain not found or not verified';
297
-
}
298
-
299
-
if (!customDomain.rkey) {
300
-
set.status = 404;
301
-
return 'Domain not mapped to a site';
302
-
}
301
+
const identifier = pathParts[0];
302
+
const site = pathParts[1];
303
+
const filePath = pathParts.slice(2).join('/');
303
304
304
-
const rkey = customDomain.rkey;
305
-
if (!isValidRkey(rkey)) {
306
-
set.status = 500;
307
-
return 'Invalid site configuration';
308
-
}
305
+
// Additional validation: identifier must be a valid DID or handle format
306
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
307
+
return c.text('Invalid identifier', 400);
308
+
}
309
309
310
-
const cached = await ensureSiteCached(customDomain.did, rkey);
311
-
if (!cached) {
312
-
set.status = 404;
313
-
return 'Site not found';
314
-
}
310
+
// Validate site parameter exists
311
+
if (!site) {
312
+
return c.text('Site name required', 400);
313
+
}
315
314
316
-
return serveFromCache(customDomain.did, rkey, path);
315
+
// Validate site name (rkey)
316
+
if (!isValidRkey(site)) {
317
+
return c.text('Invalid site name', 400);
317
318
}
318
319
319
-
// Route 2: Registered subdomains - /*.wisp.place/*
320
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
321
-
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
320
+
// Resolve identifier to DID
321
+
const did = await resolveDid(identifier);
322
+
if (!did) {
323
+
return c.text('Invalid identifier', 400);
324
+
}
322
325
323
-
const domainInfo = await getWispDomain(hostname);
324
-
if (!domainInfo) {
325
-
set.status = 404;
326
-
return 'Subdomain not registered';
327
-
}
326
+
// Ensure site is cached
327
+
const cached = await ensureSiteCached(did, site);
328
+
if (!cached) {
329
+
return c.text('Site not found', 404);
330
+
}
328
331
329
-
if (!domainInfo.rkey) {
330
-
set.status = 404;
331
-
return 'Domain not mapped to a site';
332
-
}
332
+
// Serve with HTML path rewriting to handle absolute paths
333
+
const basePath = `/${identifier}/${site}/`;
334
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
335
+
}
333
336
334
-
const rkey = domainInfo.rkey;
335
-
if (!isValidRkey(rkey)) {
336
-
set.status = 500;
337
-
return 'Invalid site configuration';
338
-
}
337
+
// Check if this is a DNS hash subdomain
338
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
339
+
if (dnsMatch) {
340
+
const hash = dnsMatch[1];
341
+
const baseDomain = dnsMatch[2];
339
342
340
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
341
-
if (!cached) {
342
-
set.status = 404;
343
-
return 'Site not found';
344
-
}
343
+
if (!hash) {
344
+
return c.text('Invalid DNS hash', 400);
345
+
}
345
346
346
-
return serveFromCache(domainInfo.did, rkey, path);
347
+
if (baseDomain !== BASE_HOST) {
348
+
return c.text('Invalid base domain', 400);
347
349
}
348
350
349
-
// Route 1: Custom domains - /*
350
-
const customDomain = await getCustomDomain(hostname);
351
+
const customDomain = await getCustomDomainByHash(hash);
351
352
if (!customDomain) {
352
-
set.status = 404;
353
-
return 'Custom domain not found or not verified';
353
+
return c.text('Custom domain not found or not verified', 404);
354
354
}
355
355
356
356
if (!customDomain.rkey) {
357
-
set.status = 404;
358
-
return 'Domain not mapped to a site';
357
+
return c.text('Domain not mapped to a site', 404);
359
358
}
360
359
361
360
const rkey = customDomain.rkey;
362
361
if (!isValidRkey(rkey)) {
363
-
set.status = 500;
364
-
return 'Invalid site configuration';
362
+
return c.text('Invalid site configuration', 500);
365
363
}
366
364
367
365
const cached = await ensureSiteCached(customDomain.did, rkey);
368
366
if (!cached) {
369
-
set.status = 404;
370
-
return 'Site not found';
367
+
return c.text('Site not found', 404);
371
368
}
372
369
373
370
return serveFromCache(customDomain.did, rkey, path);
374
-
})
375
-
// Internal observability endpoints (for admin panel)
376
-
.get('/__internal__/observability/logs', ({ query }) => {
377
-
const filter: any = {};
378
-
if (query.level) filter.level = query.level;
379
-
if (query.service) filter.service = query.service;
380
-
if (query.search) filter.search = query.search;
381
-
if (query.eventType) filter.eventType = query.eventType;
382
-
if (query.limit) filter.limit = parseInt(query.limit as string);
383
-
return { logs: logCollector.getLogs(filter) };
384
-
})
385
-
.get('/__internal__/observability/errors', ({ query }) => {
386
-
const filter: any = {};
387
-
if (query.service) filter.service = query.service;
388
-
if (query.limit) filter.limit = parseInt(query.limit as string);
389
-
return { errors: errorTracker.getErrors(filter) };
390
-
})
391
-
.get('/__internal__/observability/metrics', ({ query }) => {
392
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
393
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
394
-
return { stats, timeWindow };
395
-
});
371
+
}
372
+
373
+
// Route 2: Registered subdomains - /*.wisp.place/*
374
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
375
+
const domainInfo = await getWispDomain(hostname);
376
+
if (!domainInfo) {
377
+
return c.text('Subdomain not registered', 404);
378
+
}
379
+
380
+
if (!domainInfo.rkey) {
381
+
return c.text('Domain not mapped to a site', 404);
382
+
}
383
+
384
+
const rkey = domainInfo.rkey;
385
+
if (!isValidRkey(rkey)) {
386
+
return c.text('Invalid site configuration', 500);
387
+
}
388
+
389
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
390
+
if (!cached) {
391
+
return c.text('Site not found', 404);
392
+
}
393
+
394
+
return serveFromCache(domainInfo.did, rkey, path);
395
+
}
396
+
397
+
// Route 1: Custom domains - /*
398
+
const customDomain = await getCustomDomain(hostname);
399
+
if (!customDomain) {
400
+
return c.text('Custom domain not found or not verified', 404);
401
+
}
402
+
403
+
if (!customDomain.rkey) {
404
+
return c.text('Domain not mapped to a site', 404);
405
+
}
406
+
407
+
const rkey = customDomain.rkey;
408
+
if (!isValidRkey(rkey)) {
409
+
return c.text('Invalid site configuration', 500);
410
+
}
411
+
412
+
const cached = await ensureSiteCached(customDomain.did, rkey);
413
+
if (!cached) {
414
+
return c.text('Site not found', 404);
415
+
}
416
+
417
+
return serveFromCache(customDomain.did, rkey, path);
418
+
});
419
+
420
+
// Internal observability endpoints (for admin panel)
421
+
app.get('/__internal__/observability/logs', (c) => {
422
+
const query = c.req.query();
423
+
const filter: any = {};
424
+
if (query.level) filter.level = query.level;
425
+
if (query.service) filter.service = query.service;
426
+
if (query.search) filter.search = query.search;
427
+
if (query.eventType) filter.eventType = query.eventType;
428
+
if (query.limit) filter.limit = parseInt(query.limit as string);
429
+
return c.json({ logs: logCollector.getLogs(filter) });
430
+
});
431
+
432
+
app.get('/__internal__/observability/errors', (c) => {
433
+
const query = c.req.query();
434
+
const filter: any = {};
435
+
if (query.service) filter.service = query.service;
436
+
if (query.limit) filter.limit = parseInt(query.limit as string);
437
+
return c.json({ errors: errorTracker.getErrors(filter) });
438
+
});
439
+
440
+
app.get('/__internal__/observability/metrics', (c) => {
441
+
const query = c.req.query();
442
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
443
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
444
+
return c.json({ stats, timeWindow });
445
+
});
396
446
397
447
export default app;
+28
hosting-service/tsconfig.json
+28
hosting-service/tsconfig.json
···
1
+
{
2
+
"compilerOptions": {
3
+
/* Base Options */
4
+
"esModuleInterop": true,
5
+
"skipLibCheck": true,
6
+
"target": "es2022",
7
+
"allowJs": true,
8
+
"resolveJsonModule": true,
9
+
"moduleDetection": "force",
10
+
"isolatedModules": true,
11
+
"verbatimModuleSyntax": true,
12
+
13
+
/* Strictness */
14
+
"strict": true,
15
+
"noUncheckedIndexedAccess": true,
16
+
"noImplicitOverride": true,
17
+
"forceConsistentCasingInFileNames": true,
18
+
19
+
/* Transpiling with TypeScript */
20
+
"module": "ESNext",
21
+
"moduleResolution": "bundler",
22
+
"outDir": "dist",
23
+
"sourceMap": true,
24
+
25
+
/* Code doesn't run in DOM */
26
+
"lib": ["es2022"],
27
+
}
28
+
}
+2
-1
package.json
+2
-1
package.json
···
2
2
"name": "elysia-static",
3
3
"version": "1.0.50",
4
4
"scripts": {
5
-
"test": "echo \"Error: no test specified\" && exit 1",
5
+
"test": "bun test",
6
6
"dev": "bun run --watch src/index.ts",
7
7
"start": "bun run src/index.ts",
8
8
"build": "bun build --compile --target bun --outfile server src/index.ts"
···
30
30
"lucide-react": "^0.546.0",
31
31
"react": "^19.2.0",
32
32
"react-dom": "^19.2.0",
33
+
"react-shiki": "^0.9.0",
33
34
"tailwind-merge": "^3.3.1",
34
35
"tailwindcss": "4",
35
36
"tw-animate-css": "^1.4.0",
+23
public/components/ui/code-block.tsx
+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}
+268
-19
public/editor/editor.tsx
+268
-19
public/editor/editor.tsx
···
38
38
Settings
39
39
} from 'lucide-react'
40
40
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
+
import { CodeBlock } from '@public/components/ui/code-block'
41
42
42
43
import Layout from '@public/layouts'
43
44
···
579
580
</div>
580
581
581
582
<Tabs defaultValue="sites" className="space-y-6 w-full">
582
-
<TabsList className="grid w-full grid-cols-3 max-w-md">
583
+
<TabsList className="grid w-full grid-cols-4">
583
584
<TabsTrigger value="sites">Sites</TabsTrigger>
584
585
<TabsTrigger value="domains">Domains</TabsTrigger>
585
586
<TabsTrigger value="upload">Upload</TabsTrigger>
587
+
<TabsTrigger value="cli">CLI</TabsTrigger>
586
588
</TabsList>
587
589
588
590
{/* Sites Tab */}
···
884
886
</CardHeader>
885
887
<CardContent className="space-y-6">
886
888
<div className="space-y-4">
887
-
<RadioGroup
888
-
value={siteMode}
889
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
890
-
disabled={isUploading}
891
-
>
892
-
<div className="flex items-center space-x-2">
893
-
<RadioGroupItem value="existing" id="existing" />
894
-
<Label htmlFor="existing" className="cursor-pointer">
895
-
Update existing site
896
-
</Label>
897
-
</div>
898
-
<div className="flex items-center space-x-2">
899
-
<RadioGroupItem value="new" id="new" />
900
-
<Label htmlFor="new" className="cursor-pointer">
901
-
Create new site
902
-
</Label>
903
-
</div>
904
-
</RadioGroup>
889
+
<div className="p-4 bg-muted/50 rounded-lg">
890
+
<RadioGroup
891
+
value={siteMode}
892
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
893
+
disabled={isUploading}
894
+
>
895
+
<div className="flex items-center space-x-2">
896
+
<RadioGroupItem value="existing" id="existing" />
897
+
<Label htmlFor="existing" className="cursor-pointer">
898
+
Update existing site
899
+
</Label>
900
+
</div>
901
+
<div className="flex items-center space-x-2">
902
+
<RadioGroupItem value="new" id="new" />
903
+
<Label htmlFor="new" className="cursor-pointer">
904
+
Create new site
905
+
</Label>
906
+
</div>
907
+
</RadioGroup>
908
+
</div>
905
909
906
910
{siteMode === 'existing' ? (
907
911
<div className="space-y-2">
···
1074
1078
</>
1075
1079
)}
1076
1080
</Button>
1081
+
</CardContent>
1082
+
</Card>
1083
+
</TabsContent>
1084
+
1085
+
{/* CLI Tab */}
1086
+
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
1087
+
<Card>
1088
+
<CardHeader>
1089
+
<div className="flex items-center gap-2 mb-2">
1090
+
<CardTitle>Wisp CLI Tool</CardTitle>
1091
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
1092
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
1093
+
</div>
1094
+
<CardDescription>
1095
+
Deploy static sites directly from your terminal
1096
+
</CardDescription>
1097
+
</CardHeader>
1098
+
<CardContent className="space-y-6">
1099
+
<div className="prose prose-sm max-w-none dark:prose-invert">
1100
+
<p className="text-sm text-muted-foreground">
1101
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
1102
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
1103
+
</p>
1104
+
</div>
1105
+
1106
+
<div className="space-y-3">
1107
+
<h3 className="text-sm font-semibold">Download CLI</h3>
1108
+
<div className="grid gap-2">
1109
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1110
+
<a
1111
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
1112
+
target="_blank"
1113
+
rel="noopener noreferrer"
1114
+
className="flex items-center justify-between mb-2"
1115
+
>
1116
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
1117
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1118
+
</a>
1119
+
<div className="text-xs text-muted-foreground">
1120
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
1121
+
</div>
1122
+
</div>
1123
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1124
+
<a
1125
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
1126
+
target="_blank"
1127
+
rel="noopener noreferrer"
1128
+
className="flex items-center justify-between mb-2"
1129
+
>
1130
+
<span className="font-mono text-sm">Linux (ARM64)</span>
1131
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1132
+
</a>
1133
+
<div className="text-xs text-muted-foreground">
1134
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
1135
+
</div>
1136
+
</div>
1137
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1138
+
<a
1139
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
1140
+
target="_blank"
1141
+
rel="noopener noreferrer"
1142
+
className="flex items-center justify-between mb-2"
1143
+
>
1144
+
<span className="font-mono text-sm">Linux (x86_64)</span>
1145
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1146
+
</a>
1147
+
<div className="text-xs text-muted-foreground">
1148
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
1149
+
</div>
1150
+
</div>
1151
+
</div>
1152
+
</div>
1153
+
1154
+
<div className="space-y-3">
1155
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
1156
+
<CodeBlock
1157
+
code={`# Download and make executable
1158
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
1159
+
chmod +x wisp-cli-macos-arm64
1160
+
1161
+
# Deploy your site (will use OAuth)
1162
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
1163
+
--path ./dist \\
1164
+
--site my-site
1165
+
1166
+
# Your site will be available at:
1167
+
# https://sites.wisp.place/your-handle/my-site`}
1168
+
language="bash"
1169
+
/>
1170
+
</div>
1171
+
1172
+
<div className="space-y-3">
1173
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
1174
+
<p className="text-xs text-muted-foreground">
1175
+
Deploy automatically on every push using{' '}
1176
+
<a
1177
+
href="https://blog.tangled.org/ci"
1178
+
target="_blank"
1179
+
rel="noopener noreferrer"
1180
+
className="text-accent hover:underline"
1181
+
>
1182
+
Tangled Spindle
1183
+
</a>
1184
+
</p>
1185
+
1186
+
<div className="space-y-4">
1187
+
<div>
1188
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1189
+
<span>Example 1: Simple Asset Publishing</span>
1190
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
1191
+
</h4>
1192
+
<CodeBlock
1193
+
code={`when:
1194
+
- event: ['push']
1195
+
branch: ['main']
1196
+
- event: ['manual']
1197
+
1198
+
engine: 'nixery'
1199
+
1200
+
clone:
1201
+
skip: false
1202
+
depth: 1
1203
+
1204
+
dependencies:
1205
+
nixpkgs:
1206
+
- coreutils
1207
+
- curl
1208
+
1209
+
environment:
1210
+
SITE_PATH: '.' # Copy entire repo
1211
+
SITE_NAME: 'myWebbedSite'
1212
+
WISP_HANDLE: 'your-handle.bsky.social'
1213
+
1214
+
steps:
1215
+
- name: deploy assets to wisp
1216
+
command: |
1217
+
# Download Wisp CLI
1218
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1219
+
chmod +x wisp-cli
1220
+
1221
+
# Deploy to Wisp
1222
+
./wisp-cli \\
1223
+
"$WISP_HANDLE" \\
1224
+
--path "$SITE_PATH" \\
1225
+
--site "$SITE_NAME" \\
1226
+
--password "$WISP_APP_PASSWORD"
1227
+
1228
+
# Output
1229
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
1230
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
1231
+
`}
1232
+
language="yaml"
1233
+
/>
1234
+
</div>
1235
+
1236
+
<div>
1237
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1238
+
<span>Example 2: React/Vite Build & Deploy</span>
1239
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
1240
+
</h4>
1241
+
<CodeBlock
1242
+
code={`when:
1243
+
- event: ['push']
1244
+
branch: ['main']
1245
+
- event: ['manual']
1246
+
1247
+
engine: 'nixery'
1248
+
1249
+
clone:
1250
+
skip: false
1251
+
depth: 1
1252
+
submodules: false
1253
+
1254
+
dependencies:
1255
+
nixpkgs:
1256
+
- nodejs
1257
+
- coreutils
1258
+
- curl
1259
+
github:NixOS/nixpkgs/nixpkgs-unstable:
1260
+
- bun
1261
+
1262
+
environment:
1263
+
SITE_PATH: 'dist'
1264
+
SITE_NAME: 'my-react-site'
1265
+
WISP_HANDLE: 'your-handle.bsky.social'
1266
+
1267
+
steps:
1268
+
- name: build site
1269
+
command: |
1270
+
# necessary to ensure bun is in PATH
1271
+
export PATH="$HOME/.nix-profile/bin:$PATH"
1272
+
1273
+
bun install --frozen-lockfile
1274
+
1275
+
# build with vite, run directly to get around env issues
1276
+
bun node_modules/.bin/vite build
1277
+
1278
+
- name: deploy to wisp
1279
+
command: |
1280
+
# Download Wisp CLI
1281
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1282
+
chmod +x wisp-cli
1283
+
1284
+
# Deploy to Wisp
1285
+
./wisp-cli \\
1286
+
"$WISP_HANDLE" \\
1287
+
--path "$SITE_PATH" \\
1288
+
--site "$SITE_NAME" \\
1289
+
--password "$WISP_APP_PASSWORD"`}
1290
+
language="yaml"
1291
+
/>
1292
+
</div>
1293
+
</div>
1294
+
1295
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
1296
+
<p className="text-xs text-muted-foreground">
1297
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
1298
+
Generate an app password from your AT Protocol account settings.
1299
+
</p>
1300
+
</div>
1301
+
</div>
1302
+
1303
+
<div className="space-y-3">
1304
+
<h3 className="text-sm font-semibold">Learn More</h3>
1305
+
<div className="grid gap-2">
1306
+
<a
1307
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
1308
+
target="_blank"
1309
+
rel="noopener noreferrer"
1310
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1311
+
>
1312
+
<span className="text-sm">Source Code</span>
1313
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1314
+
</a>
1315
+
<a
1316
+
href="https://blog.tangled.org/ci"
1317
+
target="_blank"
1318
+
rel="noopener noreferrer"
1319
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1320
+
>
1321
+
<span className="text-sm">Tangled Spindle CI/CD</span>
1322
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1323
+
</a>
1324
+
</div>
1325
+
</div>
1077
1326
</CardContent>
1078
1327
</Card>
1079
1328
</TabsContent>
+1
public/editor/index.html
+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
+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
+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
+
}
+17
-9
src/index.ts
+17
-9
src/index.ts
···
1
1
import { Elysia } from 'elysia'
2
+
import type { Context } from 'elysia'
2
3
import { cors } from '@elysiajs/cors'
3
-
import { openapi, fromTypes } from '@elysiajs/openapi'
4
4
import { staticPlugin } from '@elysiajs/static'
5
5
6
6
import type { Config } from './lib/types'
···
24
24
import { adminRoutes } from './routes/admin'
25
25
26
26
const config: Config = {
27
-
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
27
+
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
28
28
clientName: Bun.env.CLIENT_NAME ?? 'PDS-View'
29
29
}
30
30
···
58
58
dnsVerifier.start()
59
59
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
60
60
61
-
export const app = new Elysia()
62
-
.use(openapi({
63
-
references: fromTypes()
64
-
}))
61
+
export const app = new Elysia({
62
+
serve: {
63
+
maxPayloadLength: 1024 * 1024 * 128 * 3,
64
+
development: Bun.env.NODE_ENV !== 'production' ? true : false,
65
+
id: Bun.env.NODE_ENV !== 'production' ? undefined : null,
66
+
}
67
+
})
65
68
// Observability middleware
66
69
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
67
-
.onAfterHandle((ctx) => {
70
+
.onAfterHandle((ctx: Context) => {
68
71
observabilityMiddleware('main-app').afterHandle(ctx)
69
72
// Security headers middleware
70
73
const { set } = ctx
···
104
107
prefix: '/'
105
108
})
106
109
)
107
-
.get('/client-metadata.json', (c) => {
110
+
.get('/client-metadata.json', () => {
108
111
return createClientMetadata(config)
109
112
})
110
-
.get('/jwks.json', async (c) => {
113
+
.get('/jwks.json', async () => {
111
114
const keys = await getCurrentKeys()
112
115
if (!keys.length) return { keys: [] }
113
116
···
143
146
error: error instanceof Error ? error.message : String(error)
144
147
}
145
148
}
149
+
})
150
+
.get('/.well-known/atproto-did', ({ set }) => {
151
+
// Return plain text DID for AT Protocol domain verification
152
+
set.headers['Content-Type'] = 'text/plain'
153
+
return 'did:plc:7puq73yz2hkvbcpdhnsze2qw'
146
154
})
147
155
.use(cors({
148
156
origin: config.domain,
+81
src/lib/csrf.test.ts
+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
+
})
+49
-20
src/lib/db.ts
+49
-20
src/lib/db.ts
···
337
337
}
338
338
};
339
339
340
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
341
-
client_id: `${config.domain}/client-metadata.json`,
342
-
client_name: config.clientName,
343
-
client_uri: config.domain,
344
-
logo_uri: `${config.domain}/logo.png`,
345
-
tos_uri: `${config.domain}/tos`,
346
-
policy_uri: `${config.domain}/policy`,
347
-
redirect_uris: [`${config.domain}/api/auth/callback`],
348
-
grant_types: ['authorization_code', 'refresh_token'],
349
-
response_types: ['code'],
350
-
application_type: 'web',
351
-
token_endpoint_auth_method: 'private_key_jwt',
352
-
token_endpoint_auth_signing_alg: "ES256",
353
-
scope: "atproto transition:generic",
354
-
dpop_bound_access_tokens: true,
355
-
jwks_uri: `${config.domain}/jwks.json`,
356
-
subject_type: 'public',
357
-
authorization_signed_response_alg: 'ES256'
358
-
});
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
+
};
359
388
360
389
const persistKey = async (key: JoseKey) => {
361
390
const priv = key.privateJwk;
···
443
472
}
444
473
};
445
474
446
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
475
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
447
476
const keys = await ensureKeys();
448
477
449
478
return new NodeOAuthClient({
+35
-15
src/lib/dns-verification-worker.ts
+35
-15
src/lib/dns-verification-worker.ts
···
71
71
};
72
72
73
73
try {
74
-
// Get all verified custom domains
75
-
const domains = await db`
76
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
74
+
// Get all custom domains (both verified and pending)
75
+
const domains = await db<Array<{
76
+
id: string;
77
+
domain: string;
78
+
did: string;
79
+
verified: boolean;
80
+
}>>`
81
+
SELECT id, domain, did, verified FROM custom_domains
77
82
`;
78
83
79
84
if (!domains || domains.length === 0) {
80
-
this.log('No verified custom domains to check');
85
+
this.log('No custom domains to check');
81
86
this.lastRunTime = Date.now();
82
87
return;
83
88
}
84
89
85
-
this.log(`Checking ${domains.length} verified custom domains`);
90
+
const verifiedCount = domains.filter(d => d.verified).length;
91
+
const pendingCount = domains.filter(d => !d.verified).length;
92
+
this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`);
86
93
87
94
// Verify each domain
88
95
for (const row of domains) {
89
96
runStats.totalChecked++;
90
-
const { id, domain, did } = row;
97
+
const { id, domain, did, verified: wasVerified } = row;
91
98
92
99
try {
93
100
// Extract hash from id (SHA256 of did:domain)
···
97
104
const result = await verifyCustomDomain(domain, did, expectedHash);
98
105
99
106
if (result.verified) {
100
-
// Update last_verified_at timestamp
107
+
// Update verified status and last_verified_at timestamp
101
108
await db`
102
109
UPDATE custom_domains
103
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
110
+
SET verified = true,
111
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
104
112
WHERE id = ${id}
105
113
`;
106
114
runStats.verified++;
107
-
this.log(`Domain verified: ${domain}`, { did });
115
+
if (!wasVerified) {
116
+
this.log(`Domain newly verified: ${domain}`, { did });
117
+
} else {
118
+
this.log(`Domain re-verified: ${domain}`, { did });
119
+
}
108
120
} else {
109
-
// Mark domain as unverified
121
+
// Mark domain as unverified or keep it pending
110
122
await db`
111
123
UPDATE custom_domains
112
124
SET verified = false,
···
114
126
WHERE id = ${id}
115
127
`;
116
128
runStats.failed++;
117
-
this.log(`Domain verification failed: ${domain}`, {
118
-
did,
119
-
error: result.error,
120
-
found: result.found,
121
-
});
129
+
if (wasVerified) {
130
+
this.log(`Domain verification failed (was verified): ${domain}`, {
131
+
did,
132
+
error: result.error,
133
+
found: result.found,
134
+
});
135
+
} else {
136
+
this.log(`Domain still pending: ${domain}`, {
137
+
did,
138
+
error: result.error,
139
+
found: result.found,
140
+
});
141
+
}
122
142
}
123
143
} catch (error) {
124
144
runStats.errors++;
+30
-4
src/lib/oauth-client.ts
+30
-4
src/lib/oauth-client.ts
···
103
103
}
104
104
};
105
105
106
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
107
-
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
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
108
134
return {
109
135
client_id: `${config.domain}/client-metadata.json`,
110
136
client_name: config.clientName,
111
-
client_uri: `https://wisp.place`,
137
+
client_uri: `https://wisp.place`,
112
138
logo_uri: `${config.domain}/logo.png`,
113
139
tos_uri: `${config.domain}/tos`,
114
140
policy_uri: `${config.domain}/policy`,
···
212
238
}
213
239
};
214
240
215
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
241
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
216
242
const keys = await ensureKeys();
217
243
218
244
return new NodeOAuthClient({
+10
-6
src/lib/observability.ts
+10
-6
src/lib/observability.ts
···
312
312
service
313
313
)
314
314
315
-
logCollector.error(
316
-
`Request failed: ${request.method} ${url.pathname}`,
317
-
service,
318
-
error,
319
-
{ statusCode: set.status || 500 }
320
-
)
315
+
// Don't log 404 errors
316
+
const statusCode = set.status || 500
317
+
if (statusCode !== 404) {
318
+
logCollector.error(
319
+
`Request failed: ${request.method} ${url.pathname}`,
320
+
service,
321
+
error,
322
+
{ statusCode }
323
+
)
324
+
}
321
325
}
322
326
}
323
327
}
+2
-2
src/lib/types.ts
+2
-2
src/lib/types.ts
···
3
3
* @typeParam Config
4
4
*/
5
5
export type Config = {
6
-
/** The base domain URL with HTTPS protocol */
7
-
domain: `https://${string}`,
6
+
/** The base domain URL with HTTP or HTTPS protocol */
7
+
domain: `http://${string}` | `https://${string}`,
8
8
/** Name of the client application */
9
9
clientName: string
10
10
};
+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
+
})
+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>