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

refactor(hosting-service): migrate from Elysia to Hono and improve fetch safety

- Switch from Elysia to Hono framework
- Remove OpenTelemetry dependencies
- Fix HTML path rewriting to skip relative and hash paths
- Add size limits and redirect handling to safe-fetch
- Add tests

+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
+12 -190
hosting-service/bun.lock
··· 9 9 "@atproto/lexicon": "^0.5.1", 10 10 "@atproto/sync": "^0.1.36", 11 11 "@atproto/xrpc": "^0.7.5", 12 - "@elysiajs/node": "^1.4.2", 13 - "@elysiajs/opentelemetry": "latest", 14 - "elysia": "^1.4.15", 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", ··· 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 - 53 - "@elysiajs/node": ["@elysiajs/node@1.4.2", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.9.4" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-zqeBAV4/faCcmIEjCp3g6jRwsbaWsd5HqmlEf3CirD9HkTWQNo4T+GN/qGZi7zgd84D3Kzxsny7ZTMXEfrDSXQ=="], 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 50 57 51 "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], 58 52 ··· 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", "", {}, ""], ··· 230 132 231 133 "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""], 232 134 135 + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 136 + 233 137 "bytes": ["bytes@3.1.2", "", {}, ""], 234 138 235 139 "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""], ··· 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.15", "", { "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", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="], 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", "", {}, ""], ··· 298 188 299 189 "events": ["events@3.3.0", "", {}, ""], 300 190 301 - "exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="], 302 - 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 - 305 - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], 306 192 307 193 "fast-redact": ["fast-redact@3.5.0", "", {}, ""], 308 194 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 - 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 315 197 "forwarded": ["forwarded@0.2.0", "", {}, ""], ··· 319 201 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 320 202 321 203 "function-bind": ["function-bind@1.1.2", "", {}, ""], 322 - 323 - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 324 204 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 ··· 336 216 337 217 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""], 338 218 219 + "hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="], 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 341 223 "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, ""], 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 - 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 232 361 233 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""], 362 234 363 235 "media-typer": ["media-typer@0.3.0", "", {}, ""], 364 236 365 - "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], 366 - 367 237 "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""], 368 238 369 239 "methods": ["methods@1.1.2", "", {}, ""], ··· 373 243 "mime-db": ["mime-db@1.52.0", "", {}, ""], 374 244 375 245 "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], 376 - 377 - "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], 378 246 379 247 "ms": ["ms@2.0.0", "", {}, ""], 380 248 ··· 390 258 391 259 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""], 392 260 393 - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], 394 - 395 261 "p-finally": ["p-finally@1.0.0", "", {}, ""], 396 262 397 263 "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""], ··· 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" }, ""], ··· 415 279 "process": ["process@0.11.10", "", {}, ""], 416 280 417 281 "process-warning": ["process-warning@3.0.0", "", {}, ""], 418 - 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 282 421 283 "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""], 422 284 ··· 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.9.5", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-nQsA2c8q3XwbSn6kTxVQjz0zS096rV+Be2pzJwrYEAdtnYszLw4MTy8JWJjz1XEGBZwP0qW51SUIX3WdjdRemQ=="], 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", "", {}, ""], 550 - 551 - "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, ""], 552 374 } 553 375 }
+1
hosting-service/package.json
··· 20 20 "postgres": "^3.4.5" 21 21 }, 22 22 "devDependencies": { 23 + "@types/bun": "^1.3.1", 23 24 "@types/mime-types": "^2.1.4", 24 25 "@types/node": "^22.10.5", 25 26 "tsx": "^4.19.2"
+9 -14
hosting-service/src/lib/html-rewriter.ts
··· 29 29 return false; 30 30 } 31 31 32 - // Don't rewrite pure anchors 33 - if (path.startsWith('#')) return false; 32 + // Don't rewrite pure anchors or paths that start with /# 33 + if (path.startsWith('#') || path.startsWith('/#')) return false; 34 + 35 + // Don't rewrite relative paths (./ or ../) 36 + if (path.startsWith('./') || path.startsWith('../')) return false; 34 37 35 - // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) 38 + // Rewrite absolute paths (/) 36 39 return true; 37 40 } 38 41 ··· 49 52 return basePath + path.slice(1); 50 53 } 51 54 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 - } 61 - 62 - return basePath + cleanPath; 55 + // At this point, only plain filenames without ./ or ../ prefix should reach here 56 + // But since we're filtering those in shouldRewritePath, this shouldn't happen 57 + return path; 63 58 } 64 59 65 60 /**
+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
··· 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 + })
+1 -1
hosting-service/tsconfig.json
··· 23 23 "sourceMap": true, 24 24 25 25 /* Code doesn't run in DOM */ 26 - "lib": ["es2022"] 26 + "lib": ["es2022"], 27 27 } 28 28 }
+1 -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"
+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 + })
+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 + })