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

observability

+149
bun.lock
···
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
+
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
···
},
},
},
+
"trustedDependencies": [
+
"core-js",
+
"protobufjs",
+
],
"packages": {
"@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="],
···
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="],
+
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
+
"@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="],
+
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
+
+
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
+
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
+
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
+
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+
+
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
+
+
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
+
+
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
+
+
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
+
+
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="],
+
+
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="],
+
+
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
+
+
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
+
+
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
+
+
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="],
+
+
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="],
+
+
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="],
+
+
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="],
+
+
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="],
+
+
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
+
+
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
+
+
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
+
+
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
+
+
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
+
+
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
+
+
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
+
+
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
+
+
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
+
+
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
+
+
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="],
+
+
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
+
+
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
+
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="],
···
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
+
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
+
+
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
+
+
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
+
+
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
+
+
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
+
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
···
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
+
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
+
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
+
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+
+
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
+
+
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
···
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
···
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
+
+
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
···
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
···
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
···
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
+
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
···
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
+
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+
+
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
+
+
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
+
+
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
···
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
+
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
···
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
+
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
···
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
+
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
+
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
···
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
+
+
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
+
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
···
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
+
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
+
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
···
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
+
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
+
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+
+
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
"yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="],
"zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="],
···
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
+46
change-admin-password.ts
···
+
// Change admin password
+
import { adminAuth } from './src/lib/admin-auth'
+
import { db } from './src/lib/db'
+
import { randomBytes, createHash } from 'crypto'
+
+
// Get username and new password from command line
+
const username = process.argv[2]
+
const newPassword = process.argv[3]
+
+
if (!username || !newPassword) {
+
console.error('Usage: bun run change-admin-password.ts <username> <new-password>')
+
process.exit(1)
+
}
+
+
if (newPassword.length < 8) {
+
console.error('Password must be at least 8 characters')
+
process.exit(1)
+
}
+
+
// Hash password
+
function hashPassword(password: string, salt: string): string {
+
return createHash('sha256').update(password + salt).digest('hex')
+
}
+
+
function generateSalt(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Initialize
+
await adminAuth.init()
+
+
// Check if user exists
+
const result = await db`SELECT username FROM admin_users WHERE username = ${username}`
+
if (result.length === 0) {
+
console.error(`Admin user '${username}' not found`)
+
process.exit(1)
+
}
+
+
// Update password
+
const salt = generateSalt()
+
const passwordHash = hashPassword(newPassword, salt)
+
+
await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}`
+
+
console.log(`✓ Password updated for admin user '${username}'`)
+
process.exit(0)
+33
claude.md
···
+
Wisp.place - Decentralized Static Site Hosting
+
+
Architecture Overview
+
+
Wisp.Place a two-service application that provides static site hosting on the AT
+
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.
+
+
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
+
- User-facing editor and API
+
- OAuth authentication (AT Protocol)
+
- File upload processing (gzip + base64 encoding)
+
- Domain management (subdomains + custom domains)
+
- DNS verification worker
+
- React frontend
+
+
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
+
- AT Protocol Firehose listener for real-time updates
+
- Serves hosted websites from local cache
+
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
+
- Distributed locking for multi-instance coordination
+
+
Tech Stack
+
+
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
+
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
+
+
Key Features
+
+
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
+
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
+
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
+
- Real-time Sync: Firehose worker listens for site updates and caches files locally
+
- Atomic Updates: Safe cache swapping without downtime
+31
create-admin.ts
···
+
// Quick script to create admin user with randomly generated password
+
import { adminAuth } from './src/lib/admin-auth'
+
import { randomBytes } from 'crypto'
+
+
// Generate a secure random password
+
function generatePassword(length: number = 20): string {
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
+
const bytes = randomBytes(length)
+
let password = ''
+
for (let i = 0; i < length; i++) {
+
password += chars[bytes[i] % chars.length]
+
}
+
return password
+
}
+
+
const username = 'admin'
+
const password = generatePassword(20)
+
+
await adminAuth.init()
+
await adminAuth.createAdmin(username, password)
+
+
console.log('\n╔════════════════════════════════════════════════════════════════╗')
+
console.log('║ ADMIN USER CREATED SUCCESSFULLY ║')
+
console.log('╚════════════════════════════════════════════════════════════════╝\n')
+
console.log(`Username: ${username}`)
+
console.log(`Password: ${password}`)
+
console.log('\n⚠️ IMPORTANT: Save this password securely!')
+
console.log('This password will not be shown again.\n')
+
console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n')
+
+
process.exit(0)
+3 -2
hosting-service/src/index.ts
···
import app from './server';
import { FirehoseWorker } from './lib/firehose';
+
import { logger } from './lib/observability';
import { mkdirSync, existsSync } from 'fs';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
···
console.log('Created cache directory:', CACHE_DIR);
}
-
// Start firehose worker
+
// Start firehose worker with observability logger
const firehose = new FirehoseWorker((msg, data) => {
-
console.log(msg, data);
+
logger.info(msg, data);
});
firehose.start();
+43
hosting-service/src/lib/db.ts
···
}
}
+
/**
+
* Generate a numeric lock ID from a string key
+
* PostgreSQL advisory locks use bigint (64-bit signed integer)
+
*/
+
function stringToLockId(key: string): bigint {
+
let hash = 0n;
+
for (let i = 0; i < key.length; i++) {
+
const char = BigInt(key.charCodeAt(i));
+
hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
+
}
+
return hash;
+
}
+
+
/**
+
* Acquire a distributed lock using PostgreSQL advisory locks
+
* Returns true if lock was acquired, false if already held by another instance
+
* Lock is automatically released when the transaction ends or connection closes
+
*/
+
export async function tryAcquireLock(key: string): Promise<boolean> {
+
const lockId = stringToLockId(key);
+
+
try {
+
const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
+
return result[0]?.acquired === true;
+
} catch (err) {
+
console.error('Failed to acquire lock', { key, error: err });
+
return false;
+
}
+
}
+
+
/**
+
* Release a distributed lock
+
*/
+
export async function releaseLock(key: string): Promise<void> {
+
const lockId = stringToLockId(key);
+
+
try {
+
await sql`SELECT pg_advisory_unlock(${lockId})`;
+
} catch (err) {
+
console.error('Failed to release lock', { key, error: err });
+
}
+
}
+
export { sql };
+20 -4
hosting-service/src/lib/firehose.ts
···
import { existsSync, rmSync } from 'fs';
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
-
import { upsertSite } from './db';
+
import { upsertSite, tryAcquireLock, releaseLock } from './db';
import { safeFetch } from './safe-fetch';
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
import { Firehose } from '@atproto/sync';
···
}
// Cache the record with verified CID (uses atomic swap internally)
+
// All instances cache locally for edge serving
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
-
// Upsert site to database
-
await upsertSite(did, site, fsRecord.site);
+
// Acquire distributed lock only for database write to prevent duplicate writes
+
const lockKey = `db:upsert:${did}:${site}`;
+
const lockAcquired = await tryAcquireLock(lockKey);
-
this.log('Successfully processed create/update', { did, site });
+
if (!lockAcquired) {
+
this.log('Another instance is writing to DB, skipping upsert', { did, site });
+
this.log('Successfully processed create/update (cached locally)', { did, site });
+
return;
+
}
+
+
try {
+
// Upsert site to database (only one instance does this)
+
await upsertSite(did, site, fsRecord.site);
+
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
+
} finally {
+
// Always release lock, even if DB write fails
+
await releaseLock(lockKey);
+
}
}
private async handleDelete(did: string, site: string) {
this.log('Processing delete', { did, site });
+
// All instances should delete their local cache (no lock needed)
const pdsEndpoint = await getPdsForDid(did);
if (!pdsEndpoint) {
this.log('Could not resolve PDS for DID', { did });
+328
hosting-service/src/lib/observability.ts
···
+
// DIY Observability for Hosting Service
+
import type { Context } from 'elysia'
+
+
// Types
+
export interface LogEntry {
+
id: string
+
timestamp: Date
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
traceId?: string
+
eventType?: string
+
}
+
+
export interface ErrorEntry {
+
id: string
+
timestamp: Date
+
message: string
+
stack?: string
+
service: string
+
context?: Record<string, any>
+
count: number
+
lastSeen: Date
+
}
+
+
export interface MetricEntry {
+
timestamp: Date
+
path: string
+
method: string
+
statusCode: number
+
duration: number
+
service: string
+
}
+
+
// In-memory storage with rotation
+
const MAX_LOGS = 5000
+
const MAX_ERRORS = 500
+
const MAX_METRICS = 10000
+
+
const logs: LogEntry[] = []
+
const errors: Map<string, ErrorEntry> = new Map()
+
const metrics: MetricEntry[] = []
+
+
// Helper to generate unique IDs
+
let logCounter = 0
+
let errorCounter = 0
+
+
function generateId(prefix: string, counter: number): string {
+
return `${prefix}-${Date.now()}-${counter}`
+
}
+
+
// Helper to extract event type from message
+
function extractEventType(message: string): string | undefined {
+
const match = message.match(/^\[([^\]]+)\]/)
+
return match ? match[1] : undefined
+
}
+
+
// Log collector
+
export const logCollector = {
+
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
const entry: LogEntry = {
+
id: generateId('log', logCounter++),
+
timestamp: new Date(),
+
level,
+
message,
+
service,
+
context,
+
traceId,
+
eventType: extractEventType(message)
+
}
+
+
logs.unshift(entry)
+
+
// Rotate if needed
+
if (logs.length > MAX_LOGS) {
+
logs.splice(MAX_LOGS)
+
}
+
+
// Also log to console for compatibility
+
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
+
const traceStr = traceId ? ` [trace:${traceId}]` : ''
+
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
+
},
+
+
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('info', message, service, context, traceId)
+
},
+
+
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('warn', message, service, context, traceId)
+
},
+
+
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
+
const ctx = { ...context }
+
if (error instanceof Error) {
+
ctx.error = error.message
+
ctx.stack = error.stack
+
} else if (error) {
+
ctx.error = String(error)
+
}
+
this.log('error', message, service, ctx, traceId)
+
+
// Also track in errors
+
errorTracker.track(message, service, error, context)
+
},
+
+
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
if (process.env.NODE_ENV !== 'production') {
+
this.log('debug', message, service, context, traceId)
+
}
+
},
+
+
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
+
let filtered = [...logs]
+
+
if (filter?.level) {
+
filtered = filtered.filter(log => log.level === filter.level)
+
}
+
+
if (filter?.service) {
+
filtered = filtered.filter(log => log.service === filter.service)
+
}
+
+
if (filter?.eventType) {
+
filtered = filtered.filter(log => log.eventType === filter.eventType)
+
}
+
+
if (filter?.search) {
+
const search = filter.search.toLowerCase()
+
filtered = filtered.filter(log =>
+
log.message.toLowerCase().includes(search) ||
+
JSON.stringify(log.context).toLowerCase().includes(search)
+
)
+
}
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
logs.length = 0
+
}
+
}
+
+
// Error tracker with deduplication
+
export const errorTracker = {
+
track(message: string, service: string, error?: any, context?: Record<string, any>) {
+
const key = `${service}:${message}`
+
+
const existing = errors.get(key)
+
if (existing) {
+
existing.count++
+
existing.lastSeen = new Date()
+
if (context) {
+
existing.context = { ...existing.context, ...context }
+
}
+
} else {
+
const entry: ErrorEntry = {
+
id: generateId('error', errorCounter++),
+
timestamp: new Date(),
+
message,
+
service,
+
context,
+
count: 1,
+
lastSeen: new Date()
+
}
+
+
if (error instanceof Error) {
+
entry.stack = error.stack
+
}
+
+
errors.set(key, entry)
+
+
// Rotate if needed
+
if (errors.size > MAX_ERRORS) {
+
const oldest = Array.from(errors.keys())[0]
+
errors.delete(oldest)
+
}
+
}
+
},
+
+
getErrors(filter?: { service?: string; limit?: number }) {
+
let filtered = Array.from(errors.values())
+
+
if (filter?.service) {
+
filtered = filtered.filter(err => err.service === filter.service)
+
}
+
+
// Sort by last seen (most recent first)
+
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
errors.clear()
+
}
+
}
+
+
// Metrics collector
+
export const metricsCollector = {
+
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
+
const entry: MetricEntry = {
+
timestamp: new Date(),
+
path,
+
method,
+
statusCode,
+
duration,
+
service
+
}
+
+
metrics.unshift(entry)
+
+
// Rotate if needed
+
if (metrics.length > MAX_METRICS) {
+
metrics.splice(MAX_METRICS)
+
}
+
},
+
+
getMetrics(filter?: { service?: string; timeWindow?: number }) {
+
let filtered = [...metrics]
+
+
if (filter?.service) {
+
filtered = filtered.filter(m => m.service === filter.service)
+
}
+
+
if (filter?.timeWindow) {
+
const cutoff = Date.now() - filter.timeWindow
+
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
+
}
+
+
return filtered
+
},
+
+
getStats(service?: string, timeWindow: number = 3600000) {
+
const filtered = this.getMetrics({ service, timeWindow })
+
+
if (filtered.length === 0) {
+
return {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
}
+
+
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
+
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
+
const errors = filtered.filter(m => m.statusCode >= 400).length
+
+
const p50 = durations[Math.floor(durations.length * 0.5)]
+
const p95 = durations[Math.floor(durations.length * 0.95)]
+
const p99 = durations[Math.floor(durations.length * 0.99)]
+
+
const timeWindowMinutes = timeWindow / 60000
+
+
return {
+
totalRequests: filtered.length,
+
avgDuration: Math.round(totalDuration / filtered.length),
+
p50Duration: Math.round(p50),
+
p95Duration: Math.round(p95),
+
p99Duration: Math.round(p99),
+
errorRate: (errors / filtered.length) * 100,
+
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
+
}
+
},
+
+
clear() {
+
metrics.length = 0
+
}
+
}
+
+
// Elysia middleware for request timing
+
export function observabilityMiddleware(service: string) {
+
return {
+
beforeHandle: ({ request }: any) => {
+
(request as any).__startTime = Date.now()
+
},
+
afterHandle: ({ request, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 200,
+
duration,
+
service
+
)
+
},
+
onError: ({ request, error, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 500,
+
duration,
+
service
+
)
+
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode: set.status || 500 }
+
)
+
}
+
}
+
}
+
+
// Export singleton logger for easy access
+
export const logger = {
+
info: (message: string, context?: Record<string, any>) =>
+
logCollector.info(message, 'hosting-service', context),
+
warn: (message: string, context?: Record<string, any>) =>
+
logCollector.warn(message, 'hosting-service', context),
+
error: (message: string, error?: any, context?: Record<string, any>) =>
+
logCollector.error(message, 'hosting-service', error, context),
+
debug: (message: string, context?: Record<string, any>) =>
+
logCollector.debug(message, 'hosting-service', context)
+
}
+36 -9
hosting-service/src/lib/utils.ts
···
import { AtpAgent } from '@atproto/api';
-
import type { WispFsRecord, Directory, Entry, File } from './types';
+
import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
···
pathPrefix: string,
dirSuffix: string = ''
): Promise<void> {
-
for (const entry of entries) {
-
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
-
const node = entry.node;
+
// Collect all file blob download tasks first
+
const downloadTasks: Array<() => Promise<void>> = [];
+
+
function collectFileTasks(
+
entries: Entry[],
+
currentPathPrefix: string
+
) {
+
for (const entry of entries) {
+
const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name;
+
const node = entry.node;
-
if ('type' in node && node.type === 'directory' && 'entries' in node) {
-
await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath, dirSuffix);
-
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
-
const fileNode = node as File;
-
await cacheFileBlob(did, site, currentPath, fileNode.blob, pdsEndpoint, fileNode.encoding, fileNode.mimeType, fileNode.base64, dirSuffix);
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
+
collectFileTasks(node.entries, currentPath);
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
+
const fileNode = node as File;
+
downloadTasks.push(() => cacheFileBlob(
+
did,
+
site,
+
currentPath,
+
fileNode.blob,
+
pdsEndpoint,
+
fileNode.encoding,
+
fileNode.mimeType,
+
fileNode.base64,
+
dirSuffix
+
));
+
}
}
+
}
+
+
collectFileTasks(entries, pathPrefix);
+
+
// Execute downloads concurrently with a limit of 3 at a time
+
const concurrencyLimit = 3;
+
for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) {
+
const batch = downloadTasks.slice(i, i + concurrencyLimit);
+
await Promise.all(batch.map(task => task()));
}
}
+29 -3
hosting-service/src/server.ts
···
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync, readFileSync } from 'fs';
import { lookup } from 'mime-types';
+
import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
···
// Fetch and cache the site
const siteData = await fetchSiteRecord(did, rkey);
if (!siteData) {
-
console.error('Site record not found', did, rkey);
+
logger.error('Site record not found', null, { did, rkey });
return false;
}
const pdsEndpoint = await getPdsForDid(did);
if (!pdsEndpoint) {
-
console.error('PDS not found for DID', did);
+
logger.error('PDS not found for DID', null, { did });
return false;
}
try {
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
+
logger.info('Site cached successfully', { did, rkey });
return true;
} catch (err) {
-
console.error('Failed to cache site', did, rkey, err);
+
logger.error('Failed to cache site', err, { did, rkey });
return false;
}
}
const app = new Elysia({ adapter: node() })
.use(opentelemetry())
+
.onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
+
.onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
+
.onError(observabilityMiddleware('hosting-service').onError)
.get('/*', async ({ request, set }) => {
const url = new URL(request.url);
const hostname = request.headers.get('host') || '';
···
}
return serveFromCache(customDomain.did, rkey, path);
+
})
+
// Internal observability endpoints (for admin panel)
+
.get('/__internal__/observability/logs', ({ query }) => {
+
const filter: any = {};
+
if (query.level) filter.level = query.level;
+
if (query.service) filter.service = query.service;
+
if (query.search) filter.search = query.search;
+
if (query.eventType) filter.eventType = query.eventType;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return { logs: logCollector.getLogs(filter) };
+
})
+
.get('/__internal__/observability/errors', ({ query }) => {
+
const filter: any = {};
+
if (query.service) filter.service = query.service;
+
if (query.limit) filter.limit = parseInt(query.limit as string);
+
return { errors: errorTracker.getErrors(filter) };
+
})
+
.get('/__internal__/observability/metrics', ({ query }) => {
+
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
+
const stats = metricsCollector.getStats('hosting-service', timeWindow);
+
return { stats, timeWindow };
});
export default app;
+6 -1
package.json
···
"@elysiajs/cors": "^1.4.0",
"@elysiajs/eden": "^1.4.3",
"@elysiajs/openapi": "^1.4.11",
+
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
···
"bun-plugin-tailwind": "^0.1.2",
"bun-types": "latest"
},
-
"module": "src/index.js"
+
"module": "src/index.js",
+
"trustedDependencies": [
+
"core-js",
+
"protobufjs"
+
]
}
+820
public/admin/admin.tsx
···
+
import { StrictMode, useState, useEffect } from 'react'
+
import { createRoot } from 'react-dom/client'
+
import './styles.css'
+
+
// Types
+
interface LogEntry {
+
id: string
+
timestamp: string
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
eventType?: string
+
}
+
+
interface ErrorEntry {
+
id: string
+
timestamp: string
+
message: string
+
stack?: string
+
service: string
+
count: number
+
lastSeen: string
+
}
+
+
interface MetricsStats {
+
totalRequests: number
+
avgDuration: number
+
p50Duration: number
+
p95Duration: number
+
p99Duration: number
+
errorRate: number
+
requestsPerMinute: number
+
}
+
+
// Helper function to format Unix timestamp from database
+
function formatDbDate(timestamp: number | string): Date {
+
const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp
+
return new Date(num * 1000) // Convert seconds to milliseconds
+
}
+
+
// Login Component
+
function Login({ onLogin }: { onLogin: () => void }) {
+
const [username, setUsername] = useState('')
+
const [password, setPassword] = useState('')
+
const [error, setError] = useState('')
+
const [loading, setLoading] = useState(false)
+
+
const handleSubmit = async (e: React.FormEvent) => {
+
e.preventDefault()
+
setError('')
+
setLoading(true)
+
+
try {
+
const res = await fetch('/api/admin/login', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ username, password }),
+
credentials: 'include'
+
})
+
+
if (res.ok) {
+
onLogin()
+
} else {
+
setError('Invalid credentials')
+
}
+
} catch (err) {
+
setError('Failed to login')
+
} finally {
+
setLoading(false)
+
}
+
}
+
+
return (
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
+
<div className="w-full max-w-md">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl">
+
<h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1>
+
<form onSubmit={handleSubmit} className="space-y-4">
+
<div>
+
<label className="block text-sm font-medium text-gray-300 mb-2">
+
Username
+
</label>
+
<input
+
type="text"
+
value={username}
+
onChange={(e) => setUsername(e.target.value)}
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
+
required
+
/>
+
</div>
+
<div>
+
<label className="block text-sm font-medium text-gray-300 mb-2">
+
Password
+
</label>
+
<input
+
type="password"
+
value={password}
+
onChange={(e) => setPassword(e.target.value)}
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
+
required
+
/>
+
</div>
+
{error && (
+
<div className="text-red-400 text-sm">{error}</div>
+
)}
+
<button
+
type="submit"
+
disabled={loading}
+
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium py-2 px-4 rounded transition-colors"
+
>
+
{loading ? 'Logging in...' : 'Login'}
+
</button>
+
</form>
+
</div>
+
</div>
+
</div>
+
)
+
}
+
+
// Dashboard Component
+
function Dashboard() {
+
const [tab, setTab] = useState('overview')
+
const [logs, setLogs] = useState<LogEntry[]>([])
+
const [errors, setErrors] = useState<ErrorEntry[]>([])
+
const [metrics, setMetrics] = useState<any>(null)
+
const [database, setDatabase] = useState<any>(null)
+
const [sites, setSites] = useState<any>(null)
+
const [health, setHealth] = useState<any>(null)
+
const [autoRefresh, setAutoRefresh] = useState(true)
+
+
// Filters
+
const [logLevel, setLogLevel] = useState('')
+
const [logService, setLogService] = useState('')
+
const [logSearch, setLogSearch] = useState('')
+
const [logEventType, setLogEventType] = useState('')
+
+
const fetchLogs = async () => {
+
const params = new URLSearchParams()
+
if (logLevel) params.append('level', logLevel)
+
if (logService) params.append('service', logService)
+
if (logSearch) params.append('search', logSearch)
+
if (logEventType) params.append('eventType', logEventType)
+
params.append('limit', '100')
+
+
const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setLogs(data.logs)
+
}
+
}
+
+
const fetchErrors = async () => {
+
const res = await fetch('/api/admin/errors', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setErrors(data.errors)
+
}
+
}
+
+
const fetchMetrics = async () => {
+
const res = await fetch('/api/admin/metrics', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setMetrics(data)
+
}
+
}
+
+
const fetchDatabase = async () => {
+
const res = await fetch('/api/admin/database', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setDatabase(data)
+
}
+
}
+
+
const fetchSites = async () => {
+
const res = await fetch('/api/admin/sites', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setSites(data)
+
}
+
}
+
+
const fetchHealth = async () => {
+
const res = await fetch('/api/admin/health', { credentials: 'include' })
+
if (res.ok) {
+
const data = await res.json()
+
setHealth(data)
+
}
+
}
+
+
const logout = async () => {
+
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
+
window.location.reload()
+
}
+
+
useEffect(() => {
+
fetchMetrics()
+
fetchDatabase()
+
fetchHealth()
+
fetchLogs()
+
fetchErrors()
+
fetchSites()
+
}, [])
+
+
useEffect(() => {
+
fetchLogs()
+
}, [logLevel, logService, logSearch])
+
+
useEffect(() => {
+
if (!autoRefresh) return
+
+
const interval = setInterval(() => {
+
if (tab === 'overview') {
+
fetchMetrics()
+
fetchHealth()
+
} else if (tab === 'logs') {
+
fetchLogs()
+
} else if (tab === 'errors') {
+
fetchErrors()
+
} else if (tab === 'database') {
+
fetchDatabase()
+
} else if (tab === 'sites') {
+
fetchSites()
+
}
+
}, 5000)
+
+
return () => clearInterval(interval)
+
}, [tab, autoRefresh, logLevel, logService, logSearch])
+
+
const formatDuration = (ms: number) => {
+
if (ms < 1000) return `${ms}ms`
+
return `${(ms / 1000).toFixed(2)}s`
+
}
+
+
const formatUptime = (seconds: number) => {
+
const hours = Math.floor(seconds / 3600)
+
const minutes = Math.floor((seconds % 3600) / 60)
+
return `${hours}h ${minutes}m`
+
}
+
+
return (
+
<div className="min-h-screen bg-gray-950 text-white">
+
{/* Header */}
+
<div className="bg-gray-900 border-b border-gray-800 px-6 py-4">
+
<div className="flex items-center justify-between">
+
<h1 className="text-2xl font-bold">Wisp.place Admin</h1>
+
<div className="flex items-center gap-4">
+
<label className="flex items-center gap-2 text-sm text-gray-400">
+
<input
+
type="checkbox"
+
checked={autoRefresh}
+
onChange={(e) => setAutoRefresh(e.target.checked)}
+
className="rounded"
+
/>
+
Auto-refresh
+
</label>
+
<button
+
onClick={logout}
+
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm"
+
>
+
Logout
+
</button>
+
</div>
+
</div>
+
</div>
+
+
{/* Tabs */}
+
<div className="bg-gray-900 border-b border-gray-800 px-6">
+
<div className="flex gap-1">
+
{['overview', 'logs', 'errors', 'database', 'sites'].map((t) => (
+
<button
+
key={t}
+
onClick={() => setTab(t)}
+
className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${
+
tab === t
+
? 'text-white border-b-2 border-blue-500'
+
: 'text-gray-400 hover:text-white'
+
}`}
+
>
+
{t}
+
</button>
+
))}
+
</div>
+
</div>
+
+
{/* Content */}
+
<div className="p-6">
+
{tab === 'overview' && (
+
<div className="space-y-6">
+
{/* Health */}
+
{health && (
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Uptime</div>
+
<div className="text-2xl font-bold">{formatUptime(health.uptime)}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Memory Used</div>
+
<div className="text-2xl font-bold">{health.memory.heapUsed} MB</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">RSS</div>
+
<div className="text-2xl font-bold">{health.memory.rss} MB</div>
+
</div>
+
</div>
+
)}
+
+
{/* Metrics */}
+
{metrics && (
+
<div>
+
<h2 className="text-xl font-bold mb-4">Performance Metrics</h2>
+
<div className="space-y-4">
+
{/* Overall */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Total Requests</div>
+
<div className="text-xl font-bold">{metrics.overall.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg Duration</div>
+
<div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95 Duration</div>
+
<div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Error Rate</div>
+
<div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Main App */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Main App</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Requests</div>
+
<div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg</div>
+
<div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95</div>
+
<div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Req/min</div>
+
<div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div>
+
</div>
+
</div>
+
</div>
+
+
{/* Hosting Service */}
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<h3 className="text-lg font-semibold mb-3">Hosting Service</h3>
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+
<div>
+
<div className="text-sm text-gray-400">Requests</div>
+
<div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Avg</div>
+
<div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">P95</div>
+
<div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div>
+
</div>
+
<div>
+
<div className="text-sm text-gray-400">Req/min</div>
+
<div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
+
)}
+
+
{tab === 'logs' && (
+
<div className="space-y-4">
+
<div className="flex gap-4">
+
<select
+
value={logLevel}
+
onChange={(e) => setLogLevel(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Levels</option>
+
<option value="info">Info</option>
+
<option value="warn">Warn</option>
+
<option value="error">Error</option>
+
<option value="debug">Debug</option>
+
</select>
+
<select
+
value={logService}
+
onChange={(e) => setLogService(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Services</option>
+
<option value="main-app">Main App</option>
+
<option value="hosting-service">Hosting Service</option>
+
</select>
+
<select
+
value={logEventType}
+
onChange={(e) => setLogEventType(e.target.value)}
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
>
+
<option value="">All Event Types</option>
+
<option value="DNS Verifier">DNS Verifier</option>
+
<option value="Auth">Auth</option>
+
<option value="User">User</option>
+
<option value="Domain">Domain</option>
+
<option value="Site">Site</option>
+
<option value="File Upload">File Upload</option>
+
<option value="Sync">Sync</option>
+
<option value="Maintenance">Maintenance</option>
+
<option value="KeyRotation">Key Rotation</option>
+
<option value="Cleanup">Cleanup</option>
+
<option value="Cache">Cache</option>
+
<option value="FirehoseWorker">Firehose Worker</option>
+
</select>
+
<input
+
type="text"
+
value={logSearch}
+
onChange={(e) => setLogSearch(e.target.value)}
+
placeholder="Search logs..."
+
className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
+
/>
+
</div>
+
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<div className="max-h-[600px] overflow-y-auto">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800 sticky top-0">
+
<tr>
+
<th className="px-4 py-2 text-left">Time</th>
+
<th className="px-4 py-2 text-left">Level</th>
+
<th className="px-4 py-2 text-left">Service</th>
+
<th className="px-4 py-2 text-left">Event Type</th>
+
<th className="px-4 py-2 text-left">Message</th>
+
</tr>
+
</thead>
+
<tbody>
+
{logs.map((log) => (
+
<tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">
+
{new Date(log.timestamp).toLocaleTimeString()}
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs font-medium ${
+
log.level === 'error'
+
? 'bg-red-900 text-red-200'
+
: log.level === 'warn'
+
? 'bg-yellow-900 text-yellow-200'
+
: log.level === 'info'
+
? 'bg-blue-900 text-blue-200'
+
: 'bg-gray-700 text-gray-300'
+
}`}
+
>
+
{log.level}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400">{log.service}</td>
+
<td className="px-4 py-2">
+
{log.eventType && (
+
<span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium">
+
{log.eventType}
+
</span>
+
)}
+
</td>
+
<td className="px-4 py-2">
+
<div>{log.message}</div>
+
{log.context && Object.keys(log.context).length > 0 && (
+
<div className="text-xs text-gray-500 mt-1">
+
{JSON.stringify(log.context)}
+
</div>
+
)}
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
+
{tab === 'errors' && (
+
<div className="space-y-4">
+
<h2 className="text-xl font-bold">Recent Errors</h2>
+
<div className="space-y-3">
+
{errors.map((error) => (
+
<div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4">
+
<div className="flex items-start justify-between mb-2">
+
<div className="flex-1">
+
<div className="font-semibold text-red-400">{error.message}</div>
+
<div className="text-sm text-gray-400 mt-1">
+
Service: {error.service} • Count: {error.count} • Last seen:{' '}
+
{new Date(error.lastSeen).toLocaleString()}
+
</div>
+
</div>
+
</div>
+
{error.stack && (
+
<pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto">
+
{error.stack}
+
</pre>
+
)}
+
</div>
+
))}
+
{errors.length === 0 && (
+
<div className="text-center text-gray-500 py-8">No errors found</div>
+
)}
+
</div>
+
</div>
+
)}
+
+
{tab === 'database' && database && (
+
<div className="space-y-6">
+
{/* Stats */}
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Total Sites</div>
+
<div className="text-3xl font-bold">{database.stats.totalSites}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div>
+
<div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div>
+
</div>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
+
<div className="text-sm text-gray-400 mb-1">Custom Domains</div>
+
<div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div>
+
</div>
+
</div>
+
+
{/* Recent Sites */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Recent Sites</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Site Name</th>
+
<th className="px-4 py-2 text-left">Subdomain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{database.recentSites.map((site: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800">
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
+
<td className="px-4 py-2">
+
{site.subdomain ? (
+
<a
+
href={`https://${site.subdomain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{site.subdomain}
+
</a>
+
) : (
+
<span className="text-gray-500">No domain</span>
+
)}
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{site.did.slice(0, 20)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(site.created_at).toLocaleDateString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
+
{/* Recent Domains */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Domain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">Verified</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{database.recentDomains.map((domain: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800">
+
<td className="px-4 py-2">{domain.domain}</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{domain.did.slice(0, 20)}...
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs ${
+
domain.verified
+
? 'bg-green-900 text-green-200'
+
: 'bg-yellow-900 text-yellow-200'
+
}`}
+
>
+
{domain.verified ? 'Yes' : 'No'}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(domain.created_at).toLocaleDateString()}
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
+
{tab === 'sites' && sites && (
+
<div className="space-y-6">
+
{/* All Sites */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">All Sites</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Site Name</th>
+
<th className="px-4 py-2 text-left">Subdomain</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
</tr>
+
</thead>
+
<tbody>
+
{sites.sites.map((site: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
+
<td className="px-4 py-2">
+
{site.subdomain ? (
+
<a
+
href={`https://${site.subdomain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{site.subdomain}
+
</a>
+
) : (
+
<span className="text-gray-500">No domain</span>
+
)}
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{site.did.slice(0, 30)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(site.created_at).toLocaleString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
+
{/* Custom Domains */}
+
<div>
+
<h3 className="text-lg font-semibold mb-3">Custom Domains</h3>
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
+
<table className="w-full text-sm">
+
<thead className="bg-gray-800">
+
<tr>
+
<th className="px-4 py-2 text-left">Domain</th>
+
<th className="px-4 py-2 text-left">Verified</th>
+
<th className="px-4 py-2 text-left">DID</th>
+
<th className="px-4 py-2 text-left">RKey</th>
+
<th className="px-4 py-2 text-left">Created</th>
+
<th className="px-4 py-2 text-left">PDSls</th>
+
</tr>
+
</thead>
+
<tbody>
+
{sites.customDomains.map((domain: any, i: number) => (
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
+
<td className="px-4 py-2">
+
{domain.verified ? (
+
<a
+
href={`https://${domain.domain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:underline"
+
>
+
{domain.domain}
+
</a>
+
) : (
+
<span className="text-gray-400">{domain.domain}</span>
+
)}
+
</td>
+
<td className="px-4 py-2">
+
<span
+
className={`px-2 py-1 rounded text-xs ${
+
domain.verified
+
? 'bg-green-900 text-green-200'
+
: 'bg-yellow-900 text-yellow-200'
+
}`}
+
>
+
{domain.verified ? 'Yes' : 'Pending'}
+
</span>
+
</td>
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
+
{domain.did.slice(0, 30)}...
+
</td>
+
<td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td>
+
<td className="px-4 py-2 text-gray-400">
+
{formatDbDate(domain.created_at).toLocaleString()}
+
</td>
+
<td className="px-4 py-2">
+
<a
+
href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-blue-400 hover:text-blue-300 transition-colors"
+
title="View on PDSls.dev"
+
>
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+
</svg>
+
</a>
+
</td>
+
</tr>
+
))}
+
</tbody>
+
</table>
+
</div>
+
</div>
+
</div>
+
)}
+
</div>
+
</div>
+
)
+
}
+
+
// Main App
+
function App() {
+
const [authenticated, setAuthenticated] = useState(false)
+
const [checking, setChecking] = useState(true)
+
+
useEffect(() => {
+
fetch('/api/admin/status', { credentials: 'include' })
+
.then((res) => res.json())
+
.then((data) => {
+
setAuthenticated(data.authenticated)
+
setChecking(false)
+
})
+
.catch(() => {
+
setChecking(false)
+
})
+
}, [])
+
+
if (checking) {
+
return (
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
+
<div className="text-white">Loading...</div>
+
</div>
+
)
+
}
+
+
if (!authenticated) {
+
return <Login onLogin={() => setAuthenticated(true)} />
+
}
+
+
return <Dashboard />
+
}
+
+
createRoot(document.getElementById('root')!).render(
+
<StrictMode>
+
<App />
+
</StrictMode>
+
)
+13
public/admin/index.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Admin Dashboard - Wisp.place</title>
+
<link rel="stylesheet" href="./styles.css" />
+
</head>
+
<body>
+
<div id="root"></div>
+
<script type="module" src="./admin.tsx"></script>
+
</body>
+
</html>
+1
public/admin/styles.css
···
+
@import "tailwindcss";
+286 -33
public/editor/editor.tsx
···
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
const [selectedDomain, setSelectedDomain] = useState<string>('')
const [isSavingConfig, setIsSavingConfig] = useState(false)
+
const [isDeletingSite, setIsDeletingSite] = useState(false)
// Upload state
-
const [siteName, setSiteName] = useState('')
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
+
const [newSiteName, setNewSiteName] = useState('')
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState('')
···
}>({})
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
+
// Wisp domain claim state
+
const [wispHandle, setWispHandle] = useState('')
+
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
+
const [wispAvailability, setWispAvailability] = useState<{
+
available: boolean | null
+
checking: boolean
+
}>({ available: null, checking: false })
+
// Fetch user info on mount
useEffect(() => {
fetchUserInfo()
fetchSites()
fetchDomains()
}, [])
+
+
// Auto-switch to 'new' mode if no sites exist
+
useEffect(() => {
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
+
setSiteMode('new')
+
}
+
}, [sites, sitesLoading, siteMode])
const fetchUserInfo = async () => {
try {
···
}
const handleUpload = async () => {
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
+
if (!siteName) {
-
alert('Please enter a site name')
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
return
}
···
setUploadProgress('Upload complete!')
setSkippedFiles(data.skippedFiles || [])
setUploadedCount(data.uploadedCount || data.fileCount || 0)
-
setSiteName('')
+
setSelectedSiteRkey('')
+
setNewSiteName('')
setSelectedFiles(null)
// Refresh sites list
···
}
}
+
const handleDeleteSite = async () => {
+
if (!configuringSite) return
+
+
if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) {
+
return
+
}
+
+
setIsDeletingSite(true)
+
try {
+
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
+
method: 'DELETE'
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
// Refresh sites list
+
await fetchSites()
+
// Refresh domains in case this site was mapped
+
await fetchDomains()
+
setConfiguringSite(null)
+
} else {
+
throw new Error(data.error || 'Failed to delete site')
+
}
+
} catch (err) {
+
console.error('Delete site error:', err)
+
alert(
+
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
} finally {
+
setIsDeletingSite(false)
+
}
+
}
+
+
const checkWispAvailability = async (handle: string) => {
+
const trimmedHandle = handle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
setWispAvailability({ available: null, checking: false })
+
return
+
}
+
+
setWispAvailability({ available: null, checking: true })
+
try {
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
+
const data = await response.json()
+
setWispAvailability({ available: data.available, checking: false })
+
} catch (err) {
+
console.error('Check availability error:', err)
+
setWispAvailability({ available: false, checking: false })
+
}
+
}
+
+
const handleClaimWispDomain = async () => {
+
const trimmedHandle = wispHandle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
alert('Please enter a handle')
+
return
+
}
+
+
setIsClaimingWisp(true)
+
try {
+
const response = await fetch('/api/domain/claim', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ handle: trimmedHandle })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setWispHandle('')
+
setWispAvailability({ available: null, checking: false })
+
await fetchDomains()
+
} else {
+
throw new Error(data.error || 'Failed to claim domain')
+
}
+
} catch (err) {
+
console.error('Claim domain error:', err)
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
+
+
// Handle "Already claimed" error more gracefully
+
if (errorMessage.includes('Already claimed')) {
+
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
+
await fetchDomains()
+
} else {
+
alert(`Failed to claim domain: ${errorMessage}`)
+
}
+
} finally {
+
setIsClaimingWisp(false)
+
}
+
}
+
if (loading) {
return (
<div className="w-full min-h-screen bg-background flex items-center justify-center">
···
</p>
</>
) : (
-
<div className="text-center py-4 text-muted-foreground">
-
<p>No wisp.place subdomain claimed yet.</p>
-
<p className="text-sm mt-1">
-
You should have claimed one during onboarding!
-
</p>
+
<div className="space-y-4">
+
<div className="p-4 bg-muted/30 rounded-lg">
+
<p className="text-sm text-muted-foreground mb-4">
+
Claim your free wisp.place subdomain
+
</p>
+
<div className="space-y-3">
+
<div className="space-y-2">
+
<Label htmlFor="wisp-handle">Choose your handle</Label>
+
<div className="flex gap-2">
+
<div className="flex-1 relative">
+
<Input
+
id="wisp-handle"
+
placeholder="mysite"
+
value={wispHandle}
+
onChange={(e) => {
+
setWispHandle(e.target.value)
+
if (e.target.value.trim()) {
+
checkWispAvailability(e.target.value)
+
} else {
+
setWispAvailability({ available: null, checking: false })
+
}
+
}}
+
disabled={isClaimingWisp}
+
className="pr-24"
+
/>
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
+
.wisp.place
+
</span>
+
</div>
+
</div>
+
{wispAvailability.checking && (
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
+
<Loader2 className="w-3 h-3 animate-spin" />
+
Checking availability...
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === true && (
+
<p className="text-xs text-green-600 flex items-center gap-1">
+
<CheckCircle2 className="w-3 h-3" />
+
Available
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === false && (
+
<p className="text-xs text-red-600 flex items-center gap-1">
+
<XCircle className="w-3 h-3" />
+
Not available
+
</p>
+
)}
+
</div>
+
<Button
+
onClick={handleClaimWispDomain}
+
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
+
className="w-full"
+
>
+
{isClaimingWisp ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Claiming...
+
</>
+
) : (
+
'Claim Subdomain'
+
)}
+
</Button>
+
</div>
+
</div>
</div>
)}
</CardContent>
···
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
-
<div className="space-y-2">
-
<Label htmlFor="site-name">Site Name</Label>
-
<Input
-
id="site-name"
-
placeholder="my-awesome-site"
-
value={siteName}
-
onChange={(e) => setSiteName(e.target.value)}
+
<div className="space-y-4">
+
<RadioGroup
+
value={siteMode}
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
disabled={isUploading}
-
/>
+
>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="existing" id="existing" />
+
<Label htmlFor="existing" className="cursor-pointer">
+
Update existing site
+
</Label>
+
</div>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="new" id="new" />
+
<Label htmlFor="new" className="cursor-pointer">
+
Create new site
+
</Label>
+
</div>
+
</RadioGroup>
+
+
{siteMode === 'existing' ? (
+
<div className="space-y-2">
+
<Label htmlFor="site-select">Select Site</Label>
+
{sitesLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
+
</div>
+
) : sites.length === 0 ? (
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
+
No sites available. Create a new site instead.
+
</div>
+
) : (
+
<select
+
id="site-select"
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+
value={selectedSiteRkey}
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
+
disabled={isUploading}
+
>
+
<option value="">Select a site...</option>
+
{sites.map((site) => (
+
<option key={site.rkey} value={site.rkey}>
+
{site.display_name || site.rkey}
+
</option>
+
))}
+
</select>
+
)}
+
</div>
+
) : (
+
<div className="space-y-2">
+
<Label htmlFor="new-site-name">New Site Name</Label>
+
<Input
+
id="new-site-name"
+
placeholder="my-awesome-site"
+
value={newSiteName}
+
onChange={(e) => setNewSiteName(e.target.value)}
+
disabled={isUploading}
+
/>
+
</div>
+
)}
+
<p className="text-xs text-muted-foreground">
File limits: 100MB per file, 300MB total
</p>
···
<Button
onClick={handleUpload}
className="w-full"
-
disabled={!siteName || isUploading}
+
disabled={
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
+
isUploading ||
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
+
}
>
{isUploading ? (
<>
···
</>
) : (
<>
-
{selectedFiles && selectedFiles.length > 0
-
? 'Upload & Deploy'
-
: 'Create Empty Site'}
+
{siteMode === 'existing' ? (
+
'Update Site'
+
) : (
+
selectedFiles && selectedFiles.length > 0
+
? 'Upload & Deploy'
+
: 'Create Empty Site'
+
)}
</>
)}
</Button>
···
</RadioGroup>
</div>
)}
-
<DialogFooter>
+
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
<Button
-
variant="outline"
-
onClick={() => setConfiguringSite(null)}
-
disabled={isSavingConfig}
+
variant="destructive"
+
onClick={handleDeleteSite}
+
disabled={isSavingConfig || isDeletingSite}
+
className="sm:mr-auto"
-
Cancel
-
</Button>
-
<Button
-
onClick={handleSaveSiteConfig}
-
disabled={isSavingConfig}
-
>
-
{isSavingConfig ? (
+
{isDeletingSite ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
Saving...
+
Deleting...
</>
) : (
-
'Save'
+
<>
+
<Trash2 className="w-4 h-4 mr-2" />
+
Delete Site
+
</>
)}
</Button>
+
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
+
<Button
+
variant="outline"
+
onClick={() => setConfiguringSite(null)}
+
disabled={isSavingConfig || isDeletingSite}
+
className="w-full sm:w-auto"
+
>
+
Cancel
+
</Button>
+
<Button
+
onClick={handleSaveSiteConfig}
+
disabled={isSavingConfig || isDeletingSite}
+
className="w-full sm:w-auto"
+
>
+
{isSavingConfig ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Saving...
+
</>
+
) : (
+
'Save'
+
)}
+
</Button>
+
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+265 -250
public/index.tsx
···
}, [showForm])
return (
-
<div className="min-h-screen">
-
{/* Header */}
-
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
-
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
-
<div className="flex items-center gap-2">
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
-
<Globe className="w-5 h-5 text-primary-foreground" />
+
<>
+
<div className="min-h-screen">
+
{/* Header */}
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
+
<div className="flex items-center gap-2">
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+
<Globe className="w-5 h-5 text-primary-foreground" />
+
</div>
+
<span className="text-xl font-semibold text-foreground">
+
wisp.place
+
</span>
+
</div>
+
<div className="flex items-center gap-3">
+
<Button
+
variant="ghost"
+
size="sm"
+
onClick={() => setShowForm(true)}
+
>
+
Sign In
+
</Button>
+
<Button
+
size="sm"
+
className="bg-accent text-accent-foreground hover:bg-accent/90"
+
>
+
Get Started
+
</Button>
</div>
-
<span className="text-xl font-semibold text-foreground">
-
wisp.place
-
</span>
</div>
-
<div className="flex items-center gap-3">
-
<Button
-
variant="ghost"
-
size="sm"
-
onClick={() => setShowForm(true)}
-
>
-
Sign In
-
</Button>
-
<Button
-
size="sm"
-
className="bg-accent text-accent-foreground hover:bg-accent/90"
-
>
-
Get Started
-
</Button>
-
</div>
-
</div>
-
</header>
+
</header>
-
{/* Hero Section */}
-
<section className="container mx-auto px-4 py-20 md:py-32">
-
<div className="max-w-4xl mx-auto text-center">
-
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
-
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
-
<span className="text-sm text-accent-foreground">
-
Built on AT Protocol
-
</span>
-
</div>
+
{/* Hero Section */}
+
<section className="container mx-auto px-4 py-20 md:py-32">
+
<div className="max-w-4xl mx-auto text-center">
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
+
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
+
<span className="text-sm text-accent-foreground">
+
Built on AT Protocol
+
</span>
+
</div>
-
<h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
-
Host your sites on the{' '}
-
<span className="text-primary">decentralized</span> web
-
</h1>
+
<h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
+
Your Website.Your Control. Lightning Fast.
+
</h1>
-
<p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
-
Deploy static sites to a truly open network. Your
-
content, your control, your identity. No platform
-
lock-in, ever.
-
</p>
+
<p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
+
Host static sites in your AT Protocol account. You
+
keep ownership and control. We just serve them fast
+
through our CDN.
+
</p>
-
<div className="max-w-md mx-auto relative">
-
<div
-
className={`transition-all duration-500 ease-in-out ${
-
showForm
-
? 'opacity-0 -translate-y-5 pointer-events-none'
-
: 'opacity-100 translate-y-0'
-
}`}
-
>
-
<Button
-
size="lg"
-
className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
-
onClick={() => setShowForm(true)}
+
<div className="max-w-md mx-auto relative">
+
<div
+
className={`transition-all duration-500 ease-in-out ${
+
showForm
+
? 'opacity-0 -translate-y-5 pointer-events-none'
+
: 'opacity-100 translate-y-0'
+
}`}
>
-
Log in with AT Proto
-
<ArrowRight className="ml-2 w-5 h-5" />
-
</Button>
-
</div>
+
<Button
+
size="lg"
+
className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
+
onClick={() => setShowForm(true)}
+
>
+
Log in with AT Proto
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</Button>
+
</div>
-
<div
-
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
-
showForm
-
? 'opacity-100 translate-y-0'
-
: 'opacity-0 translate-y-5 pointer-events-none'
-
}`}
-
>
-
<form
-
onSubmit={async (e) => {
-
e.preventDefault()
-
try {
-
const handle = inputRef.current?.value
-
const res = await fetch(
-
'/api/auth/signin',
-
{
-
method: 'POST',
-
headers: {
-
'Content-Type':
-
'application/json'
-
},
-
body: JSON.stringify({ handle })
+
<div
+
className={`transition-all duration-500 ease-in-out absolute inset-0 ${
+
showForm
+
? 'opacity-100 translate-y-0'
+
: 'opacity-0 translate-y-5 pointer-events-none'
+
}`}
+
>
+
<form
+
onSubmit={async (e) => {
+
e.preventDefault()
+
try {
+
const handle =
+
inputRef.current?.value
+
const res = await fetch(
+
'/api/auth/signin',
+
{
+
method: 'POST',
+
headers: {
+
'Content-Type':
+
'application/json'
+
},
+
body: JSON.stringify({
+
handle
+
})
+
}
+
)
+
if (!res.ok)
+
throw new Error(
+
'Request failed'
+
)
+
const data = await res.json()
+
if (data.url) {
+
window.location.href = data.url
+
} else {
+
alert('Unexpected response')
}
-
)
-
if (!res.ok)
-
throw new Error('Request failed')
-
const data = await res.json()
-
if (data.url) {
-
window.location.href = data.url
-
} else {
-
alert('Unexpected response')
+
} catch (error) {
+
console.error(
+
'Login failed:',
+
error
+
)
+
alert('Authentication failed')
}
-
} catch (error) {
-
console.error('Login failed:', error)
-
alert('Authentication failed')
-
}
-
}}
-
className="space-y-3"
-
>
-
<input
-
ref={inputRef}
-
type="text"
-
name="handle"
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
-
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
-
/>
-
<button
-
type="submit"
-
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
+
}}
+
className="space-y-3"
>
-
Continue
-
<ArrowRight className="ml-2 w-5 h-5" />
-
</button>
-
</form>
+
<input
+
ref={inputRef}
+
type="text"
+
name="handle"
+
placeholder="Enter your handle (e.g., alice.bsky.social)"
+
className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
+
/>
+
<button
+
type="submit"
+
className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors"
+
>
+
Continue
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</button>
+
</form>
+
</div>
</div>
</div>
-
</div>
-
</section>
+
</section>
-
{/* Stats Section */}
-
<section className="container mx-auto px-4 py-16">
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto">
-
{[
-
{ value: '100%', label: 'Decentralized' },
-
{ value: '0ms', label: 'Cold Start' },
-
{ value: '∞', label: 'Scalability' },
-
{ value: 'You', label: 'Own Your Data' }
-
].map((stat, i) => (
-
<div key={i} className="text-center">
-
<div className="text-4xl md:text-5xl font-bold text-primary mb-2">
-
{stat.value}
+
{/* How It Works */}
+
<section className="container mx-auto px-4 py-16 bg-muted/30">
+
<div className="max-w-3xl mx-auto text-center">
+
<h2 className="text-3xl md:text-4xl font-bold mb-8">
+
How it works
+
</h2>
+
<div className="space-y-6 text-left">
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
01
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
Upload your static site
+
</h3>
+
<p className="text-muted-foreground">
+
Your HTML, CSS, and JavaScript files are
+
stored in your AT Protocol account as
+
gzipped blobs and a manifest record.
+
</p>
+
</div>
+
</div>
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
02
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
We serve it globally
+
</h3>
+
<p className="text-muted-foreground">
+
Wisp.place reads your site from your
+
account and delivers it through our CDN
+
for fast loading anywhere.
+
</p>
+
</div>
</div>
-
<div className="text-sm text-muted-foreground">
-
{stat.label}
+
<div className="flex gap-4 items-start">
+
<div className="text-4xl font-bold text-accent/40 min-w-[60px]">
+
03
+
</div>
+
<div>
+
<h3 className="text-xl font-semibold mb-2">
+
You stay in control
+
</h3>
+
<p className="text-muted-foreground">
+
Update or remove your site anytime
+
through your AT Protocol account. No
+
lock-in, no middleman ownership.
+
</p>
+
</div>
</div>
</div>
-
))}
-
</div>
-
</section>
+
</div>
+
</section>
-
{/* Features Grid */}
-
<section id="features" className="container mx-auto px-4 py-20">
-
<div className="text-center mb-16">
-
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
-
Built for the open web
-
</h2>
-
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
-
Everything you need to deploy and manage static sites on
-
a decentralized network
-
</p>
-
</div>
+
{/* Features Grid */}
+
<section id="features" className="container mx-auto px-4 py-20">
+
<div className="text-center mb-16">
+
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
+
Why Wisp.place?
+
</h2>
+
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
+
Static site hosting that respects your ownership
+
</p>
+
</div>
-
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
-
{[
-
{
-
icon: Shield,
-
title: 'True Ownership',
-
description:
-
'Your content lives on the AT Protocol network. No single company can take it down or lock you out.'
-
},
-
{
-
icon: Zap,
-
title: 'Lightning Fast',
-
description:
-
'Distributed edge network ensures your sites load instantly from anywhere in the world.'
-
},
-
{
-
icon: Lock,
-
title: 'Cryptographic Security',
-
description:
-
'Content-addressed storage and cryptographic verification ensure integrity and authenticity.'
-
},
-
{
-
icon: Code,
-
title: 'Developer Friendly',
-
description:
-
'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.'
-
},
-
{
-
icon: Server,
-
title: 'Zero Vendor Lock-in',
-
description:
-
'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.'
-
},
-
{
-
icon: Globe,
-
title: 'Global Network',
-
description:
-
'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.'
-
}
-
].map((feature, i) => (
-
<Card
-
key={i}
-
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
-
>
-
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
-
<feature.icon className="w-6 h-6 text-accent" />
-
</div>
-
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
-
{feature.title}
-
</h3>
-
<p className="text-muted-foreground leading-relaxed">
-
{feature.description}
-
</p>
-
</Card>
-
))}
-
</div>
-
</section>
-
-
{/* How It Works */}
-
<section
-
id="how-it-works"
-
className="container mx-auto px-4 py-20 bg-muted/30"
-
>
-
<div className="max-w-4xl mx-auto">
-
<h2 className="text-4xl md:text-5xl font-bold text-center mb-16 text-balance">
-
Deploy in three steps
-
</h2>
-
-
<div className="space-y-12">
+
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{[
{
-
step: '01',
-
title: 'Upload your site',
+
icon: Shield,
+
title: 'You Own Your Content',
description:
-
'Link your Git repository or upload a folder containing your static site directly.'
+
'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.'
},
{
-
step: '02',
-
title: 'Name and set domain',
+
icon: Zap,
+
title: 'CDN Performance',
description:
-
'Name your site and set domain routing to it. You can bring your own domain too.'
+
'We cache and serve your site from edge locations worldwide for fast load times.'
},
{
-
step: '03',
-
title: 'Deploy to AT Protocol',
+
icon: Lock,
+
title: 'No Vendor Lock-in',
description:
-
'Your site is published to the decentralized network with a permanent, verifiable identity.'
+
'Your data stays in your account. Switch providers or self-host whenever you want.'
+
},
+
{
+
icon: Code,
+
title: 'Simple Deployment',
+
description:
+
'Upload your static files and we handle the rest. No complex configuration needed.'
+
},
+
{
+
icon: Server,
+
title: 'AT Protocol Native',
+
description:
+
'Built for the decentralized web. Your site has a verifiable identity on the network.'
+
},
+
{
+
icon: Globe,
+
title: 'Custom Domains',
+
description:
+
'Use your own domain name or a wisp.place subdomain. Your choice, either way.'
}
-
].map((step, i) => (
-
<div key={i} className="flex gap-6 items-start">
-
<div className="text-6xl font-bold text-accent/20 min-w-[80px]">
-
{step.step}
-
</div>
-
<div className="flex-1 pt-2">
-
<h3 className="text-2xl font-semibold mb-3">
-
{step.title}
-
</h3>
-
<p className="text-lg text-muted-foreground leading-relaxed">
-
{step.description}
-
</p>
+
].map((feature, i) => (
+
<Card
+
key={i}
+
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
+
>
+
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
+
<feature.icon className="w-6 h-6 text-accent" />
</div>
-
</div>
+
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
+
{feature.title}
+
</h3>
+
<p className="text-muted-foreground leading-relaxed">
+
{feature.description}
+
</p>
+
</Card>
))}
</div>
-
</div>
-
</section>
+
</section>
-
{/* Footer */}
-
<footer className="border-t border-border/40 bg-muted/20">
-
<div className="container mx-auto px-4 py-8">
-
<div className="text-center text-sm text-muted-foreground">
-
<p>
-
Built by{' '}
-
<a
-
href="https://bsky.app/profile/nekomimi.pet"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-accent hover:text-accent/80 transition-colors font-medium"
-
>
-
@nekomimi.pet
-
</a>
+
{/* CTA Section */}
+
<section className="container mx-auto px-4 py-20">
+
<div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12">
+
<h2 className="text-3xl md:text-4xl font-bold mb-4">
+
Ready to deploy?
+
</h2>
+
<p className="text-xl text-muted-foreground mb-8">
+
Host your static site on your own AT Protocol
+
account today
</p>
+
<Button
+
size="lg"
+
className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6"
+
onClick={() => setShowForm(true)}
+
>
+
Get Started
+
<ArrowRight className="ml-2 w-5 h-5" />
+
</Button>
</div>
-
</div>
-
</footer>
-
</div>
+
</section>
+
+
{/* Footer */}
+
<footer className="border-t border-border/40 bg-muted/20">
+
<div className="container mx-auto px-4 py-8">
+
<div className="text-center text-sm text-muted-foreground">
+
<p>
+
Built by{' '}
+
<a
+
href="https://bsky.app/profile/nekomimi.pet"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
+
>
+
@nekomimi.pet
+
</a>
+
</p>
+
</div>
+
</div>
+
</footer>
+
</div>
+
</>
)
}
+10 -2
public/onboarding/onboarding.tsx
···
setClaimedDomain(data.domain)
setStep('upload')
} else {
-
alert('Failed to claim domain. Please try again.')
+
throw new Error(data.error || 'Failed to claim domain')
}
} catch (err) {
console.error('Error claiming domain:', err)
-
alert('Failed to claim domain. Please try again.')
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
+
+
// Handle "Already claimed" error - redirect to editor
+
if (errorMessage.includes('Already claimed')) {
+
alert('You have already claimed a wisp.place subdomain. Redirecting to editor...')
+
window.location.href = '/editor'
+
} else {
+
alert(`Failed to claim domain: ${errorMessage}`)
+
}
} finally {
setIsClaimingDomain(false)
}
+32 -12
src/index.ts
···
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
+
import { openapi, fromTypes } from '@elysiajs/openapi'
import { staticPlugin } from '@elysiajs/static'
import type { Config } from './lib/types'
···
import { wispRoutes } from './routes/wisp'
import { domainRoutes } from './routes/domain'
import { userRoutes } from './routes/user'
+
import { siteRoutes } from './routes/site'
import { csrfProtection } from './lib/csrf'
import { DNSVerificationWorker } from './lib/dns-verification-worker'
-
import { logger } from './lib/logger'
+
import { logger, logCollector, observabilityMiddleware } from './lib/observability'
+
import { promptAdminSetup } from './lib/admin-auth'
+
import { adminRoutes } from './routes/admin'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
clientName: Bun.env.CLIENT_NAME ?? 'PDS-View'
}
+
// Initialize admin setup (prompt if no admin exists)
+
await promptAdminSetup()
+
const client = await getOAuthClient(config)
// Periodic maintenance: cleanup expired sessions and rotate keys
···
// Schedule maintenance to run every hour
setInterval(runMaintenance, 60 * 60 * 1000)
-
// Start DNS verification worker (runs every hour)
+
// Start DNS verification worker (runs every 10 minutes)
const dnsVerifier = new DNSVerificationWorker(
-
60 * 60 * 1000, // 1 hour
+
10 * 60 * 1000, // 10 minutes
(msg, data) => {
-
logger.info('[DNS Verifier]', msg, data || '')
+
logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined)
}
)
dnsVerifier.start()
-
logger.info('[DNS Verifier] Started - checking custom domains every hour')
+
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
export const app = new Elysia()
-
// Security headers middleware
-
.onAfterHandle(({ set }) => {
+
.use(openapi({
+
references: fromTypes()
+
}))
+
// Observability middleware
+
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
+
.onAfterHandle((ctx) => {
+
observabilityMiddleware('main-app').afterHandle(ctx)
+
// Security headers middleware
+
const { set } = ctx
// Prevent clickjacking attacks
set.headers['X-Frame-Options'] = 'DENY'
// Prevent MIME type sniffing
···
set.headers['X-XSS-Protection'] = '1; mode=block'
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
})
-
.use(
-
await staticPlugin({
-
prefix: '/'
-
})
-
)
+
.onError(observabilityMiddleware('main-app').onError)
.use(csrfProtection())
.use(authRoutes(client))
.use(wispRoutes(client))
.use(domainRoutes(client))
.use(userRoutes(client))
+
.use(siteRoutes(client))
+
.use(adminRoutes())
+
.use(
+
await staticPlugin({
+
prefix: '/'
+
})
+
)
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
···
timestamp: new Date().toISOString(),
dnsVerifier: dnsVerifierHealth
}
+
})
+
.get('/api/admin/test', () => {
+
return { message: 'Admin routes test works!' }
})
.post('/api/admin/verify-dns', async () => {
try {
+208
src/lib/admin-auth.ts
···
+
// Admin authentication system
+
import { db } from './db'
+
import { randomBytes, createHash } from 'crypto'
+
+
interface AdminUser {
+
id: number
+
username: string
+
password_hash: string
+
created_at: Date
+
}
+
+
interface AdminSession {
+
sessionId: string
+
username: string
+
expiresAt: Date
+
}
+
+
// In-memory session storage
+
const sessions = new Map<string, AdminSession>()
+
const SESSION_DURATION = 24 * 60 * 60 * 1000 // 24 hours
+
+
// Hash password using SHA-256 with salt
+
function hashPassword(password: string, salt: string): string {
+
return createHash('sha256').update(password + salt).digest('hex')
+
}
+
+
// Generate random salt
+
function generateSalt(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Generate session ID
+
function generateSessionId(): string {
+
return randomBytes(32).toString('hex')
+
}
+
+
// Generate a secure random password
+
function generatePassword(length: number = 20): string {
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
+
const bytes = randomBytes(length)
+
let password = ''
+
for (let i = 0; i < length; i++) {
+
password += chars[bytes[i] % chars.length]
+
}
+
return password
+
}
+
+
export const adminAuth = {
+
// Initialize admin table
+
async init() {
+
await db`
+
CREATE TABLE IF NOT EXISTS admin_users (
+
id SERIAL PRIMARY KEY,
+
username TEXT UNIQUE NOT NULL,
+
password_hash TEXT NOT NULL,
+
salt TEXT NOT NULL,
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+
)
+
`
+
},
+
+
// Check if any admin exists
+
async hasAdmin(): Promise<boolean> {
+
const result = await db`SELECT COUNT(*) as count FROM admin_users`
+
return result[0].count > 0
+
},
+
+
// Create admin user
+
async createAdmin(username: string, password: string): Promise<boolean> {
+
try {
+
const salt = generateSalt()
+
const passwordHash = hashPassword(password, salt)
+
+
await db`INSERT INTO admin_users (username, password_hash, salt) VALUES (${username}, ${passwordHash}, ${salt})`
+
+
console.log(`✓ Admin user '${username}' created successfully`)
+
return true
+
} catch (error) {
+
console.error('Failed to create admin user:', error)
+
return false
+
}
+
},
+
+
// Verify admin credentials
+
async verify(username: string, password: string): Promise<boolean> {
+
try {
+
const result = await db`SELECT password_hash, salt FROM admin_users WHERE username = ${username}`
+
+
if (result.length === 0) {
+
return false
+
}
+
+
const { password_hash, salt } = result[0]
+
const hash = hashPassword(password, salt as string)
+
return hash === password_hash
+
} catch (error) {
+
console.error('Failed to verify admin:', error)
+
return false
+
}
+
},
+
+
// Create session
+
createSession(username: string): string {
+
const sessionId = generateSessionId()
+
const expiresAt = new Date(Date.now() + SESSION_DURATION)
+
+
sessions.set(sessionId, {
+
sessionId,
+
username,
+
expiresAt
+
})
+
+
// Clean up expired sessions
+
this.cleanupSessions()
+
+
return sessionId
+
},
+
+
// Verify session
+
verifySession(sessionId: string): AdminSession | null {
+
const session = sessions.get(sessionId)
+
+
if (!session) {
+
return null
+
}
+
+
if (session.expiresAt.getTime() < Date.now()) {
+
sessions.delete(sessionId)
+
return null
+
}
+
+
return session
+
},
+
+
// Delete session
+
deleteSession(sessionId: string) {
+
sessions.delete(sessionId)
+
},
+
+
// Cleanup expired sessions
+
cleanupSessions() {
+
const now = Date.now()
+
for (const [sessionId, session] of sessions.entries()) {
+
if (session.expiresAt.getTime() < now) {
+
sessions.delete(sessionId)
+
}
+
}
+
}
+
}
+
+
// Prompt for admin creation on startup
+
export async function promptAdminSetup() {
+
await adminAuth.init()
+
+
const hasAdmin = await adminAuth.hasAdmin()
+
if (hasAdmin) {
+
return
+
}
+
+
// Skip prompt if SKIP_ADMIN_SETUP is set
+
if (process.env.SKIP_ADMIN_SETUP === 'true') {
+
console.log('\n╔════════════════════════════════════════════════════════════════╗')
+
console.log('║ ADMIN SETUP REQUIRED ║')
+
console.log('╚════════════════════════════════════════════════════════════════╝\n')
+
console.log('No admin user found.')
+
console.log('Create one with: bun run create-admin.ts\n')
+
return
+
}
+
+
console.log('\n===========================================')
+
console.log(' ADMIN SETUP REQUIRED')
+
console.log('===========================================\n')
+
console.log('No admin user found. Creating one automatically...\n')
+
+
// Auto-generate admin credentials with random password
+
const username = 'admin'
+
const password = generatePassword(20)
+
+
await adminAuth.createAdmin(username, password)
+
+
console.log('╔════════════════════════════════════════════════════════════════╗')
+
console.log('║ ADMIN USER CREATED SUCCESSFULLY ║')
+
console.log('╚════════════════════════════════════════════════════════════════╝\n')
+
console.log(`Username: ${username}`)
+
console.log(`Password: ${password}`)
+
console.log('\n⚠️ IMPORTANT: Save this password securely!')
+
console.log('This password will not be shown again.\n')
+
console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n')
+
}
+
+
// Elysia middleware to protect admin routes
+
export function requireAdmin({ cookie, set }: any) {
+
const sessionId = cookie.admin_session?.value
+
+
if (!sessionId) {
+
set.status = 401
+
return { error: 'Unauthorized' }
+
}
+
+
const session = adminAuth.verifySession(sessionId)
+
if (!session) {
+
set.status = 401
+
return { error: 'Unauthorized' }
+
}
+
+
// Session is valid, continue
+
return
+
}
+10
src/lib/db.ts
···
return { success: false, error: err };
}
};
+
+
export const deleteSite = async (did: string, rkey: string) => {
+
try {
+
await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`;
+
return { success: true };
+
} catch (err) {
+
console.error('Failed to delete site', err);
+
return { success: false, error: err };
+
}
+
};
+337
src/lib/observability.ts
···
+
// DIY Observability - Logs, Metrics, and Error Tracking
+
import type { Context } from 'elysia'
+
+
// Types
+
export interface LogEntry {
+
id: string
+
timestamp: Date
+
level: 'info' | 'warn' | 'error' | 'debug'
+
message: string
+
service: string
+
context?: Record<string, any>
+
traceId?: string
+
eventType?: string
+
}
+
+
export interface ErrorEntry {
+
id: string
+
timestamp: Date
+
message: string
+
stack?: string
+
service: string
+
context?: Record<string, any>
+
count: number // How many times this error occurred
+
lastSeen: Date
+
}
+
+
export interface MetricEntry {
+
timestamp: Date
+
path: string
+
method: string
+
statusCode: number
+
duration: number // in milliseconds
+
service: string
+
}
+
+
export interface DatabaseStats {
+
totalSites: number
+
totalDomains: number
+
totalCustomDomains: number
+
recentSites: any[]
+
recentDomains: any[]
+
}
+
+
// In-memory storage with rotation
+
const MAX_LOGS = 5000
+
const MAX_ERRORS = 500
+
const MAX_METRICS = 10000
+
+
const logs: LogEntry[] = []
+
const errors: Map<string, ErrorEntry> = new Map()
+
const metrics: MetricEntry[] = []
+
+
// Helper to generate unique IDs
+
let logCounter = 0
+
let errorCounter = 0
+
+
function generateId(prefix: string, counter: number): string {
+
return `${prefix}-${Date.now()}-${counter}`
+
}
+
+
// Helper to extract event type from message
+
function extractEventType(message: string): string | undefined {
+
const match = message.match(/^\[([^\]]+)\]/)
+
return match ? match[1] : undefined
+
}
+
+
// Log collector
+
export const logCollector = {
+
log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
const entry: LogEntry = {
+
id: generateId('log', logCounter++),
+
timestamp: new Date(),
+
level,
+
message,
+
service,
+
context,
+
traceId,
+
eventType: extractEventType(message)
+
}
+
+
logs.unshift(entry)
+
+
// Rotate if needed
+
if (logs.length > MAX_LOGS) {
+
logs.splice(MAX_LOGS)
+
}
+
+
// Also log to console for compatibility
+
const contextStr = context ? ` ${JSON.stringify(context)}` : ''
+
const traceStr = traceId ? ` [trace:${traceId}]` : ''
+
console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`)
+
},
+
+
info(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('info', message, service, context, traceId)
+
},
+
+
warn(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
this.log('warn', message, service, context, traceId)
+
},
+
+
error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) {
+
const ctx = { ...context }
+
if (error instanceof Error) {
+
ctx.error = error.message
+
ctx.stack = error.stack
+
} else if (error) {
+
ctx.error = String(error)
+
}
+
this.log('error', message, service, ctx, traceId)
+
+
// Also track in errors
+
errorTracker.track(message, service, error, context)
+
},
+
+
debug(message: string, service: string, context?: Record<string, any>, traceId?: string) {
+
if (process.env.NODE_ENV !== 'production') {
+
this.log('debug', message, service, context, traceId)
+
}
+
},
+
+
getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) {
+
let filtered = [...logs]
+
+
if (filter?.level) {
+
filtered = filtered.filter(log => log.level === filter.level)
+
}
+
+
if (filter?.service) {
+
filtered = filtered.filter(log => log.service === filter.service)
+
}
+
+
if (filter?.eventType) {
+
filtered = filtered.filter(log => log.eventType === filter.eventType)
+
}
+
+
if (filter?.search) {
+
const search = filter.search.toLowerCase()
+
filtered = filtered.filter(log =>
+
log.message.toLowerCase().includes(search) ||
+
(log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false)
+
)
+
}
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
logs.length = 0
+
}
+
}
+
+
// Error tracker with deduplication
+
export const errorTracker = {
+
track(message: string, service: string, error?: any, context?: Record<string, any>) {
+
const key = `${service}:${message}`
+
+
const existing = errors.get(key)
+
if (existing) {
+
existing.count++
+
existing.lastSeen = new Date()
+
if (context) {
+
existing.context = { ...existing.context, ...context }
+
}
+
} else {
+
const entry: ErrorEntry = {
+
id: generateId('error', errorCounter++),
+
timestamp: new Date(),
+
message,
+
service,
+
context,
+
count: 1,
+
lastSeen: new Date()
+
}
+
+
if (error instanceof Error) {
+
entry.stack = error.stack
+
}
+
+
errors.set(key, entry)
+
+
// Rotate if needed
+
if (errors.size > MAX_ERRORS) {
+
const oldest = Array.from(errors.keys())[0]
+
errors.delete(oldest)
+
}
+
}
+
},
+
+
getErrors(filter?: { service?: string; limit?: number }) {
+
let filtered = Array.from(errors.values())
+
+
if (filter?.service) {
+
filtered = filtered.filter(err => err.service === filter.service)
+
}
+
+
// Sort by last seen (most recent first)
+
filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime())
+
+
const limit = filter?.limit || 100
+
return filtered.slice(0, limit)
+
},
+
+
clear() {
+
errors.clear()
+
}
+
}
+
+
// Metrics collector
+
export const metricsCollector = {
+
recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) {
+
const entry: MetricEntry = {
+
timestamp: new Date(),
+
path,
+
method,
+
statusCode,
+
duration,
+
service
+
}
+
+
metrics.unshift(entry)
+
+
// Rotate if needed
+
if (metrics.length > MAX_METRICS) {
+
metrics.splice(MAX_METRICS)
+
}
+
},
+
+
getMetrics(filter?: { service?: string; timeWindow?: number }) {
+
let filtered = [...metrics]
+
+
if (filter?.service) {
+
filtered = filtered.filter(m => m.service === filter.service)
+
}
+
+
if (filter?.timeWindow) {
+
const cutoff = Date.now() - filter.timeWindow
+
filtered = filtered.filter(m => m.timestamp.getTime() > cutoff)
+
}
+
+
return filtered
+
},
+
+
getStats(service?: string, timeWindow: number = 3600000) {
+
const filtered = this.getMetrics({ service, timeWindow })
+
+
if (filtered.length === 0) {
+
return {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
}
+
+
const durations = filtered.map(m => m.duration).sort((a, b) => a - b)
+
const totalDuration = durations.reduce((sum, d) => sum + d, 0)
+
const errors = filtered.filter(m => m.statusCode >= 400).length
+
+
const p50 = durations[Math.floor(durations.length * 0.5)]
+
const p95 = durations[Math.floor(durations.length * 0.95)]
+
const p99 = durations[Math.floor(durations.length * 0.99)]
+
+
const timeWindowMinutes = timeWindow / 60000
+
+
return {
+
totalRequests: filtered.length,
+
avgDuration: Math.round(totalDuration / filtered.length),
+
p50Duration: Math.round(p50),
+
p95Duration: Math.round(p95),
+
p99Duration: Math.round(p99),
+
errorRate: (errors / filtered.length) * 100,
+
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
+
}
+
},
+
+
clear() {
+
metrics.length = 0
+
}
+
}
+
+
// Elysia middleware for request timing
+
export function observabilityMiddleware(service: string) {
+
return {
+
beforeHandle: ({ request }: any) => {
+
// Store start time on request object
+
(request as any).__startTime = Date.now()
+
},
+
afterHandle: ({ request, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 200,
+
duration,
+
service
+
)
+
},
+
onError: ({ request, error, set }: any) => {
+
const duration = Date.now() - ((request as any).__startTime || Date.now())
+
const url = new URL(request.url)
+
+
metricsCollector.recordRequest(
+
url.pathname,
+
request.method,
+
set.status || 500,
+
duration,
+
service
+
)
+
+
logCollector.error(
+
`Request failed: ${request.method} ${url.pathname}`,
+
service,
+
error,
+
{ statusCode: set.status || 500 }
+
)
+
}
+
}
+
}
+
+
// Export singleton logger for easy access
+
export const logger = {
+
info: (message: string, context?: Record<string, any>) =>
+
logCollector.info(message, 'main-app', context),
+
warn: (message: string, context?: Record<string, any>) =>
+
logCollector.warn(message, 'main-app', context),
+
error: (message: string, error?: any, context?: Record<string, any>) =>
+
logCollector.error(message, 'main-app', error, context),
+
debug: (message: string, context?: Record<string, any>) =>
+
logCollector.debug(message, 'main-app', context)
+
}
+305
src/routes/admin.ts
···
+
// Admin API routes
+
import { Elysia, t } from 'elysia'
+
import { adminAuth, requireAdmin } from '../lib/admin-auth'
+
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
+
import { db } from '../lib/db'
+
+
export const adminRoutes = () =>
+
new Elysia({ prefix: '/api/admin' })
+
// Login
+
.post(
+
'/login',
+
async ({ body, cookie, set }) => {
+
const { username, password } = body
+
+
const valid = await adminAuth.verify(username, password)
+
if (!valid) {
+
set.status = 401
+
return { error: 'Invalid credentials' }
+
}
+
+
const sessionId = adminAuth.createSession(username)
+
+
// Set cookie
+
cookie.admin_session.set({
+
value: sessionId,
+
httpOnly: true,
+
secure: process.env.NODE_ENV === 'production',
+
sameSite: 'lax',
+
maxAge: 24 * 60 * 60 // 24 hours
+
})
+
+
return { success: true }
+
},
+
{
+
body: t.Object({
+
username: t.String(),
+
password: t.String()
+
})
+
}
+
)
+
+
// Logout
+
.post('/logout', ({ cookie }) => {
+
const sessionId = cookie.admin_session?.value
+
if (sessionId && typeof sessionId === 'string') {
+
adminAuth.deleteSession(sessionId)
+
}
+
cookie.admin_session.remove()
+
return { success: true }
+
})
+
+
// Check auth status
+
.get('/status', ({ cookie }) => {
+
const sessionId = cookie.admin_session?.value
+
if (!sessionId || typeof sessionId !== 'string') {
+
return { authenticated: false }
+
}
+
+
const session = adminAuth.verifySession(sessionId)
+
if (!session) {
+
return { authenticated: false }
+
}
+
+
return {
+
authenticated: true,
+
username: session.username
+
}
+
})
+
+
// Get logs (protected)
+
.get('/logs', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const filter: any = {}
+
+
if (query.level) filter.level = query.level
+
if (query.service) filter.service = query.service
+
if (query.search) filter.search = query.search
+
if (query.eventType) filter.eventType = query.eventType
+
if (query.limit) filter.limit = parseInt(query.limit as string)
+
+
// Get logs from main app
+
const mainLogs = logCollector.getLogs(filter)
+
+
// Get logs from hosting service
+
let hostingLogs: any[] = []
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const params = new URLSearchParams()
+
if (query.level) params.append('level', query.level as string)
+
if (query.service) params.append('service', query.service as string)
+
if (query.search) params.append('search', query.search as string)
+
if (query.eventType) params.append('eventType', query.eventType as string)
+
params.append('limit', String(filter.limit || 100))
+
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingLogs = data.logs
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
// Merge and sort by timestamp
+
const allLogs = [...mainLogs, ...hostingLogs].sort((a, b) =>
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
+
)
+
+
return { logs: allLogs.slice(0, filter.limit || 100) }
+
})
+
+
// Get errors (protected)
+
.get('/errors', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const filter: any = {}
+
+
if (query.service) filter.service = query.service
+
if (query.limit) filter.limit = parseInt(query.limit as string)
+
+
// Get errors from main app
+
const mainErrors = errorTracker.getErrors(filter)
+
+
// Get errors from hosting service
+
let hostingErrors: any[] = []
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const params = new URLSearchParams()
+
if (query.service) params.append('service', query.service as string)
+
params.append('limit', String(filter.limit || 100))
+
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingErrors = data.errors
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
// Merge and sort by last seen
+
const allErrors = [...mainErrors, ...hostingErrors].sort((a, b) =>
+
new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime()
+
)
+
+
return { errors: allErrors.slice(0, filter.limit || 100) }
+
})
+
+
// Get metrics (protected)
+
.get('/metrics', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const timeWindow = query.timeWindow
+
? parseInt(query.timeWindow as string)
+
: 3600000 // 1 hour default
+
+
const mainAppStats = metricsCollector.getStats('main-app', timeWindow)
+
const overallStats = metricsCollector.getStats(undefined, timeWindow)
+
+
// Get hosting service stats from its own endpoint
+
let hostingServiceStats = {
+
totalRequests: 0,
+
avgDuration: 0,
+
p50Duration: 0,
+
p95Duration: 0,
+
p99Duration: 0,
+
errorRate: 0,
+
requestsPerMinute: 0
+
}
+
+
try {
+
const hostingPort = process.env.HOSTING_PORT || '3001'
+
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`)
+
if (response.ok) {
+
const data = await response.json()
+
hostingServiceStats = data.stats
+
}
+
} catch (err) {
+
// Hosting service might not be running
+
}
+
+
return {
+
overall: overallStats,
+
mainApp: mainAppStats,
+
hostingService: hostingServiceStats,
+
timeWindow
+
}
+
})
+
+
// Get database stats (protected)
+
.get('/database', async ({ cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
try {
+
// Get total counts
+
const allSitesResult = await db`SELECT COUNT(*) as count FROM sites`
+
const wispSubdomainsResult = await db`SELECT COUNT(*) as count FROM domains WHERE domain LIKE '%.wisp.place'`
+
const customDomainsResult = await db`SELECT COUNT(*) as count FROM custom_domains WHERE verified = true`
+
+
// Get recent sites (including those without domains)
+
const recentSites = await db`
+
SELECT
+
s.did,
+
s.rkey,
+
s.display_name,
+
s.created_at,
+
d.domain as subdomain
+
FROM sites s
+
LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place'
+
ORDER BY s.created_at DESC
+
LIMIT 10
+
`
+
+
// Get recent domains
+
const recentDomains = await db`SELECT domain, did, rkey, verified, created_at FROM custom_domains ORDER BY created_at DESC LIMIT 10`
+
+
return {
+
stats: {
+
totalSites: allSitesResult[0].count,
+
totalWispSubdomains: wispSubdomainsResult[0].count,
+
totalCustomDomains: customDomainsResult[0].count
+
},
+
recentSites: recentSites,
+
recentDomains: recentDomains
+
}
+
} catch (error) {
+
set.status = 500
+
return {
+
error: 'Failed to fetch database stats',
+
message: error instanceof Error ? error.message : String(error)
+
}
+
}
+
})
+
+
// Get sites listing (protected)
+
.get('/sites', async ({ query, cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const limit = query.limit ? parseInt(query.limit as string) : 50
+
const offset = query.offset ? parseInt(query.offset as string) : 0
+
+
try {
+
const sites = await db`
+
SELECT
+
s.did,
+
s.rkey,
+
s.display_name,
+
s.created_at,
+
d.domain as subdomain
+
FROM sites s
+
LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place'
+
ORDER BY s.created_at DESC
+
LIMIT ${limit} OFFSET ${offset}
+
`
+
+
const customDomains = await db`
+
SELECT
+
domain,
+
did,
+
rkey,
+
verified,
+
created_at
+
FROM custom_domains
+
ORDER BY created_at DESC
+
LIMIT ${limit} OFFSET ${offset}
+
`
+
+
return {
+
sites: sites,
+
customDomains: customDomains
+
}
+
} catch (error) {
+
set.status = 500
+
return {
+
error: 'Failed to fetch sites',
+
message: error instanceof Error ? error.message : String(error)
+
}
+
}
+
})
+
+
// Get system health (protected)
+
.get('/health', ({ cookie, set }) => {
+
const check = requireAdmin({ cookie, set })
+
if (check) return check
+
+
const uptime = process.uptime()
+
const memory = process.memoryUsage()
+
+
return {
+
uptime: Math.floor(uptime),
+
memory: {
+
heapUsed: Math.round(memory.heapUsed / 1024 / 1024), // MB
+
heapTotal: Math.round(memory.heapTotal / 1024 / 1024), // MB
+
rss: Math.round(memory.rss / 1024 / 1024) // MB
+
},
+
timestamp: new Date().toISOString()
+
}
+
})
+
+9 -4
src/routes/auth.ts
···
import { getSitesByDid, getDomainByDid } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { authenticateRequest } from '../lib/wisp-auth'
-
import { logger } from '../lib/logger'
+
import { logger } from '../lib/observability'
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
.post('/api/auth/signin', async (c) => {
+
let handle = 'unknown'
try {
-
const { handle } = await c.request.json()
+
const body = c.body as { handle: string }
+
handle = body.handle
+
logger.info('Sign-in attempt', { handle })
const state = crypto.randomUUID()
const url = await client.authorize(handle, { state })
+
logger.info('Authorization URL generated', { handle })
return { url: url.toString() }
} catch (err) {
-
logger.error('[Auth] Signin error', err)
-
return { error: 'Authentication failed' }
+
logger.error('Signin error', err, { handle })
+
console.error('[Auth] Full error:', err)
+
return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) }
}
})
.get('/api/auth/callback', async (c) => {
+60
src/routes/site.ts
···
+
import { Elysia } from 'elysia'
+
import { requireAuth } from '../lib/wisp-auth'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Agent } from '@atproto/api'
+
import { deleteSite } from '../lib/db'
+
import { logger } from '../lib/logger'
+
+
export const siteRoutes = (client: NodeOAuthClient) =>
+
new Elysia({ prefix: '/api/site' })
+
.derive(async ({ cookie }) => {
+
const auth = await requireAuth(client, cookie)
+
return { auth }
+
})
+
.delete('/:rkey', async ({ params, auth }) => {
+
const { rkey } = params
+
+
if (!rkey) {
+
return {
+
success: false,
+
error: 'Site rkey is required'
+
}
+
}
+
+
try {
+
// Create agent with OAuth session
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
+
// Delete the record from AT Protocol
+
try {
+
await agent.com.atproto.repo.deleteRecord({
+
repo: auth.did,
+
collection: 'place.wisp.fs',
+
rkey: rkey
+
})
+
logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`)
+
} catch (err) {
+
logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err)
+
throw new Error('Failed to delete site from AT Protocol')
+
}
+
+
// Delete from database
+
const result = await deleteSite(auth.did, rkey)
+
if (!result.success) {
+
throw new Error('Failed to delete site from database')
+
}
+
+
logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`)
+
+
return {
+
success: true,
+
message: 'Site deleted successfully'
+
}
+
} catch (err) {
+
logger.error('[Site] Delete error', err)
+
return {
+
success: false,
+
error: err instanceof Error ? err.message : 'Failed to delete site'
+
}
+
}
+
})
+28 -74
src/routes/wisp.ts
···
compressFile
} from '../lib/wisp-utils'
import { upsertSite } from '../lib/db'
-
import { logger } from '../lib/logger'
+
import { logger } from '../lib/observability'
import { validateRecord } from '../lexicon/types/place/wisp/fs'
+
import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants'
function isValidSiteName(siteName: string): boolean {
if (!siteName || typeof siteName !== 'string') return false;
···
const uploadedFiles: UploadedFile[] = [];
const skippedFiles: Array<{ name: string; reason: string }> = [];
-
// Define allowed file extensions for static site hosting
-
const allowedExtensions = new Set([
-
// HTML
-
'.html', '.htm',
-
// CSS
-
'.css',
-
// JavaScript
-
'.js', '.mjs', '.jsx', '.ts', '.tsx',
-
// Images
-
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
-
// Fonts
-
'.woff', '.woff2', '.ttf', '.otf', '.eot',
-
// Documents
-
'.pdf', '.txt',
-
// JSON (for config files, but not .map files)
-
'.json',
-
// Audio/Video
-
'.mp3', '.mp4', '.webm', '.ogg', '.wav',
-
// Other web assets
-
'.xml', '.rss', '.atom'
-
]);
-
// Files to explicitly exclude
-
const excludedFiles = new Set([
-
'.map', '.DS_Store', 'Thumbs.db'
-
]);
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
-
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
-
-
// Skip excluded files
-
if (excludedFiles.has(fileExtension)) {
-
skippedFiles.push({ name: file.name, reason: 'excluded file type' });
-
continue;
-
}
-
-
// Skip files that aren't in allowed extensions
-
if (!allowedExtensions.has(fileExtension)) {
-
skippedFiles.push({ name: file.name, reason: 'unsupported file type' });
-
continue;
-
}
// Skip files that are too large (limit to 100MB per file)
-
const maxSize = 100 * 1024 * 1024; // 100MB
+
const maxSize = MAX_FILE_SIZE; // 100MB
if (file.size > maxSize) {
skippedFiles.push({
name: file.name,
···
const originalContent = Buffer.from(arrayBuffer);
const originalMimeType = file.type || 'application/octet-stream';
-
// Determine if we should compress this file
-
const shouldCompress = shouldCompressFile(originalMimeType);
-
-
if (shouldCompress) {
-
const compressedContent = compressFile(originalContent);
-
// Base64 encode the gzipped content to prevent PDS content sniffing
-
const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
-
const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
-
logger.info(`[Wisp] Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
+
// Compress and base64 encode ALL files
+
const compressedContent = compressFile(originalContent);
+
// Base64 encode the gzipped content to prevent PDS content sniffing
+
const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
+
const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
+
logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
-
uploadedFiles.push({
-
name: file.name,
-
content: base64Content,
-
mimeType: originalMimeType,
-
size: base64Content.length,
-
compressed: true,
-
originalMimeType
-
});
-
} else {
-
uploadedFiles.push({
-
name: file.name,
-
content: originalContent,
-
mimeType: originalMimeType,
-
size: file.size,
-
compressed: false
-
});
-
}
+
uploadedFiles.push({
+
name: file.name,
+
content: base64Content,
+
mimeType: originalMimeType,
+
size: base64Content.length,
+
compressed: true,
+
originalMimeType
+
});
}
// Check total size limit (300MB)
const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
-
const maxTotalSize = 300 * 1024 * 1024; // 300MB
+
const maxTotalSize = MAX_SITE_SIZE; // 300MB
if (totalSize > maxTotalSize) {
throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
+
}
+
+
// Check file count limit (2000 files)
+
if (uploadedFiles.length > MAX_FILE_COUNT) {
+
throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`);
}
if (uploadedFiles.length === 0) {
···
: file.mimeType;
const compressionInfo = file.compressed ? ' (gzipped)' : '';
-
logger.info(`[Wisp] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
+
logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
const uploadResult = await agent.com.atproto.repo.uploadBlob(
file.content,
···
returnedMimeType: returnedBlobRef.mimeType
};
} catch (uploadError) {
-
logger.error('[Wisp] Upload failed for file', uploadError);
+
logger.error('Upload failed for file', uploadError);
throw uploadError;
}
});
···
record: manifest
});
} catch (putRecordError: any) {
-
logger.error('[Wisp] Failed to create record on PDS');
-
logger.error('[Wisp] Record creation error', putRecordError);
+
logger.error('Failed to create record on PDS', putRecordError);
throw putRecordError;
}
···
return result;
} catch (error) {
-
logger.error('[Wisp] Upload error', error);
-
logger.errorWithContext('[Wisp] Upload error details', {
+
logger.error('Upload error', error, {
message: error instanceof Error ? error.message : 'Unknown error',
name: error instanceof Error ? error.name : undefined
-
}, error);
+
});
throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}