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

observability

+149
bun.lock
··· 11 11 "@elysiajs/cors": "^1.4.0", 12 12 "@elysiajs/eden": "^1.4.3", 13 13 "@elysiajs/openapi": "^1.4.11", 14 + "@elysiajs/opentelemetry": "^1.4.6", 14 15 "@elysiajs/static": "^1.4.2", 15 16 "@radix-ui/react-dialog": "^1.1.15", 16 17 "@radix-ui/react-label": "^2.1.7", ··· 39 40 }, 40 41 }, 41 42 }, 43 + "trustedDependencies": [ 44 + "core-js", 45 + "protobufjs", 46 + ], 42 47 "packages": { 43 48 "@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=="], 44 49 ··· 110 115 111 116 "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], 112 117 118 + "@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=="], 119 + 113 120 "@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="], 114 121 122 + "@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=="], 123 + 124 + "@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=="], 125 + 115 126 "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="], 116 127 128 + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], 129 + 117 130 "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], 118 131 119 132 "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 120 133 134 + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], 135 + 136 + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], 137 + 138 + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="], 139 + 140 + "@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=="], 141 + 142 + "@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=="], 143 + 144 + "@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=="], 145 + 146 + "@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=="], 147 + 148 + "@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=="], 149 + 150 + "@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=="], 151 + 152 + "@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=="], 153 + 154 + "@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=="], 155 + 156 + "@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=="], 157 + 158 + "@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=="], 159 + 160 + "@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=="], 161 + 162 + "@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=="], 163 + 164 + "@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=="], 165 + 166 + "@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=="], 167 + 168 + "@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=="], 169 + 170 + "@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=="], 171 + 172 + "@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=="], 173 + 174 + "@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=="], 175 + 176 + "@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=="], 177 + 178 + "@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=="], 179 + 180 + "@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=="], 181 + 182 + "@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=="], 183 + 184 + "@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=="], 185 + 186 + "@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=="], 187 + 188 + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], 189 + 121 190 "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], 122 191 123 192 "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], ··· 140 209 141 210 "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], 142 211 212 + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], 213 + 214 + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], 215 + 216 + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], 217 + 218 + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], 219 + 220 + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], 221 + 222 + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], 223 + 224 + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], 225 + 226 + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], 227 + 228 + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], 229 + 230 + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 231 + 143 232 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], 144 233 145 234 "@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=="], ··· 208 297 209 298 "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], 210 299 300 + "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], 301 + 211 302 "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 212 303 213 304 "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], 214 305 306 + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 307 + 308 + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 309 + 310 + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 311 + 215 312 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], 216 313 217 314 "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], ··· 251 348 "cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="], 252 349 253 350 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 351 + 352 + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], 254 353 255 354 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], 256 355 356 + "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=="], 357 + 257 358 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 258 359 259 360 "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], ··· 291 392 "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], 292 393 293 394 "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=="], 395 + 396 + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], 294 397 295 398 "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], 296 399 ··· 300 403 301 404 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 302 405 406 + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 407 + 303 408 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 304 409 305 410 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], ··· 330 435 331 436 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 332 437 438 + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 439 + 333 440 "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=="], 334 441 335 442 "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], ··· 352 459 353 460 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 354 461 462 + "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=="], 463 + 355 464 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 356 465 357 466 "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], ··· 360 469 361 470 "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], 362 471 472 + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 473 + 474 + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], 475 + 363 476 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 364 477 365 478 "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 479 + 480 + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], 481 + 482 + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], 366 483 367 484 "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 368 485 ··· 384 501 385 502 "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 386 503 504 + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], 505 + 387 506 "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 388 507 389 508 "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], ··· 404 523 405 524 "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], 406 525 526 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 527 + 407 528 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], 408 529 409 530 "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], ··· 420 541 421 542 "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], 422 543 544 + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], 545 + 423 546 "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=="], 424 547 425 548 "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], ··· 446 569 447 570 "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 448 571 572 + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 573 + 574 + "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=="], 575 + 576 + "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=="], 577 + 449 578 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 450 579 451 580 "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], ··· 459 588 "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=="], 460 589 461 590 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 591 + 592 + "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], 462 593 463 594 "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=="], 464 595 ··· 474 605 475 606 "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], 476 607 608 + "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=="], 609 + 477 610 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 478 611 612 + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 613 + 479 614 "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], 480 615 481 616 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 617 + 618 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 482 619 483 620 "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], 484 621 ··· 524 661 525 662 "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], 526 663 664 + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], 665 + 527 666 "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=="], 528 667 668 + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 669 + 670 + "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=="], 671 + 672 + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 673 + 529 674 "yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="], 530 675 531 676 "zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="], ··· 540 685 541 686 "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 542 687 688 + "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 689 + 543 690 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 544 691 545 692 "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 546 693 547 694 "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 695 + 696 + "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 548 697 } 549 698 }
+46
change-admin-password.ts
··· 1 + // Change admin password 2 + import { adminAuth } from './src/lib/admin-auth' 3 + import { db } from './src/lib/db' 4 + import { randomBytes, createHash } from 'crypto' 5 + 6 + // Get username and new password from command line 7 + const username = process.argv[2] 8 + const newPassword = process.argv[3] 9 + 10 + if (!username || !newPassword) { 11 + console.error('Usage: bun run change-admin-password.ts <username> <new-password>') 12 + process.exit(1) 13 + } 14 + 15 + if (newPassword.length < 8) { 16 + console.error('Password must be at least 8 characters') 17 + process.exit(1) 18 + } 19 + 20 + // Hash password 21 + function hashPassword(password: string, salt: string): string { 22 + return createHash('sha256').update(password + salt).digest('hex') 23 + } 24 + 25 + function generateSalt(): string { 26 + return randomBytes(32).toString('hex') 27 + } 28 + 29 + // Initialize 30 + await adminAuth.init() 31 + 32 + // Check if user exists 33 + const result = await db`SELECT username FROM admin_users WHERE username = ${username}` 34 + if (result.length === 0) { 35 + console.error(`Admin user '${username}' not found`) 36 + process.exit(1) 37 + } 38 + 39 + // Update password 40 + const salt = generateSalt() 41 + const passwordHash = hashPassword(newPassword, salt) 42 + 43 + await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}` 44 + 45 + console.log(`✓ Password updated for admin user '${username}'`) 46 + process.exit(0)
+33
claude.md
··· 1 + Wisp.place - Decentralized Static Site Hosting 2 + 3 + Architecture Overview 4 + 5 + Wisp.Place a two-service application that provides static site hosting on the AT 6 + Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files. 7 + 8 + Service 1: Main App (Port 8000, Bun runtime, elysia.js) 9 + - User-facing editor and API 10 + - OAuth authentication (AT Protocol) 11 + - File upload processing (gzip + base64 encoding) 12 + - Domain management (subdomains + custom domains) 13 + - DNS verification worker 14 + - React frontend 15 + 16 + Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js) 17 + - AT Protocol Firehose listener for real-time updates 18 + - Serves hosted websites from local cache 19 + - Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain) 20 + - Distributed locking for multi-instance coordination 21 + 22 + Tech Stack 23 + 24 + - Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK 25 + - Frontend: React 19, Tailwind CSS v4, Shadcn UI 26 + 27 + Key Features 28 + 29 + - AT Protocol Integration: Sites stored as place.wisp.fs records in user repos 30 + - File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS 31 + - Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification 32 + - Real-time Sync: Firehose worker listens for site updates and caches files locally 33 + - Atomic Updates: Safe cache swapping without downtime
+31
create-admin.ts
··· 1 + // Quick script to create admin user with randomly generated password 2 + import { adminAuth } from './src/lib/admin-auth' 3 + import { randomBytes } from 'crypto' 4 + 5 + // Generate a secure random password 6 + function generatePassword(length: number = 20): string { 7 + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' 8 + const bytes = randomBytes(length) 9 + let password = '' 10 + for (let i = 0; i < length; i++) { 11 + password += chars[bytes[i] % chars.length] 12 + } 13 + return password 14 + } 15 + 16 + const username = 'admin' 17 + const password = generatePassword(20) 18 + 19 + await adminAuth.init() 20 + await adminAuth.createAdmin(username, password) 21 + 22 + console.log('\n╔════════════════════════════════════════════════════════════════╗') 23 + console.log('║ ADMIN USER CREATED SUCCESSFULLY ║') 24 + console.log('╚════════════════════════════════════════════════════════════════╝\n') 25 + console.log(`Username: ${username}`) 26 + console.log(`Password: ${password}`) 27 + console.log('\n⚠️ IMPORTANT: Save this password securely!') 28 + console.log('This password will not be shown again.\n') 29 + console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') 30 + 31 + process.exit(0)
+3 -2
hosting-service/src/index.ts
··· 1 1 import app from './server'; 2 2 import { FirehoseWorker } from './lib/firehose'; 3 + import { logger } from './lib/observability'; 3 4 import { mkdirSync, existsSync } from 'fs'; 4 5 5 6 const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; ··· 11 12 console.log('Created cache directory:', CACHE_DIR); 12 13 } 13 14 14 - // Start firehose worker 15 + // Start firehose worker with observability logger 15 16 const firehose = new FirehoseWorker((msg, data) => { 16 - console.log(msg, data); 17 + logger.info(msg, data); 17 18 }); 18 19 19 20 firehose.start();
+43
hosting-service/src/lib/db.ts
··· 158 158 } 159 159 } 160 160 161 + /** 162 + * Generate a numeric lock ID from a string key 163 + * PostgreSQL advisory locks use bigint (64-bit signed integer) 164 + */ 165 + function stringToLockId(key: string): bigint { 166 + let hash = 0n; 167 + for (let i = 0; i < key.length; i++) { 168 + const char = BigInt(key.charCodeAt(i)); 169 + hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range 170 + } 171 + return hash; 172 + } 173 + 174 + /** 175 + * Acquire a distributed lock using PostgreSQL advisory locks 176 + * Returns true if lock was acquired, false if already held by another instance 177 + * Lock is automatically released when the transaction ends or connection closes 178 + */ 179 + export async function tryAcquireLock(key: string): Promise<boolean> { 180 + const lockId = stringToLockId(key); 181 + 182 + try { 183 + const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`; 184 + return result[0]?.acquired === true; 185 + } catch (err) { 186 + console.error('Failed to acquire lock', { key, error: err }); 187 + return false; 188 + } 189 + } 190 + 191 + /** 192 + * Release a distributed lock 193 + */ 194 + export async function releaseLock(key: string): Promise<void> { 195 + const lockId = stringToLockId(key); 196 + 197 + try { 198 + await sql`SELECT pg_advisory_unlock(${lockId})`; 199 + } catch (err) { 200 + console.error('Failed to release lock', { key, error: err }); 201 + } 202 + } 203 + 161 204 export { sql };
+20 -4
hosting-service/src/lib/firehose.ts
··· 1 1 import { existsSync, rmSync } from 'fs'; 2 2 import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; 3 - import { upsertSite } from './db'; 3 + import { upsertSite, tryAcquireLock, releaseLock } from './db'; 4 4 import { safeFetch } from './safe-fetch'; 5 5 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; 6 6 import { Firehose } from '@atproto/sync'; ··· 158 158 } 159 159 160 160 // Cache the record with verified CID (uses atomic swap internally) 161 + // All instances cache locally for edge serving 161 162 await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); 162 163 163 - // Upsert site to database 164 - await upsertSite(did, site, fsRecord.site); 164 + // Acquire distributed lock only for database write to prevent duplicate writes 165 + const lockKey = `db:upsert:${did}:${site}`; 166 + const lockAcquired = await tryAcquireLock(lockKey); 165 167 166 - this.log('Successfully processed create/update', { did, site }); 168 + if (!lockAcquired) { 169 + this.log('Another instance is writing to DB, skipping upsert', { did, site }); 170 + this.log('Successfully processed create/update (cached locally)', { did, site }); 171 + return; 172 + } 173 + 174 + try { 175 + // Upsert site to database (only one instance does this) 176 + await upsertSite(did, site, fsRecord.site); 177 + this.log('Successfully processed create/update (cached + DB updated)', { did, site }); 178 + } finally { 179 + // Always release lock, even if DB write fails 180 + await releaseLock(lockKey); 181 + } 167 182 } 168 183 169 184 private async handleDelete(did: string, site: string) { 170 185 this.log('Processing delete', { did, site }); 171 186 187 + // All instances should delete their local cache (no lock needed) 172 188 const pdsEndpoint = await getPdsForDid(did); 173 189 if (!pdsEndpoint) { 174 190 this.log('Could not resolve PDS for DID', { did });
+328
hosting-service/src/lib/observability.ts
··· 1 + // DIY Observability for Hosting Service 2 + import type { Context } from 'elysia' 3 + 4 + // Types 5 + export interface LogEntry { 6 + id: string 7 + timestamp: Date 8 + level: 'info' | 'warn' | 'error' | 'debug' 9 + message: string 10 + service: string 11 + context?: Record<string, any> 12 + traceId?: string 13 + eventType?: string 14 + } 15 + 16 + export interface ErrorEntry { 17 + id: string 18 + timestamp: Date 19 + message: string 20 + stack?: string 21 + service: string 22 + context?: Record<string, any> 23 + count: number 24 + lastSeen: Date 25 + } 26 + 27 + export interface MetricEntry { 28 + timestamp: Date 29 + path: string 30 + method: string 31 + statusCode: number 32 + duration: number 33 + service: string 34 + } 35 + 36 + // In-memory storage with rotation 37 + const MAX_LOGS = 5000 38 + const MAX_ERRORS = 500 39 + const MAX_METRICS = 10000 40 + 41 + const logs: LogEntry[] = [] 42 + const errors: Map<string, ErrorEntry> = new Map() 43 + const metrics: MetricEntry[] = [] 44 + 45 + // Helper to generate unique IDs 46 + let logCounter = 0 47 + let errorCounter = 0 48 + 49 + function generateId(prefix: string, counter: number): string { 50 + return `${prefix}-${Date.now()}-${counter}` 51 + } 52 + 53 + // Helper to extract event type from message 54 + function extractEventType(message: string): string | undefined { 55 + const match = message.match(/^\[([^\]]+)\]/) 56 + return match ? match[1] : undefined 57 + } 58 + 59 + // Log collector 60 + export const logCollector = { 61 + log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) { 62 + const entry: LogEntry = { 63 + id: generateId('log', logCounter++), 64 + timestamp: new Date(), 65 + level, 66 + message, 67 + service, 68 + context, 69 + traceId, 70 + eventType: extractEventType(message) 71 + } 72 + 73 + logs.unshift(entry) 74 + 75 + // Rotate if needed 76 + if (logs.length > MAX_LOGS) { 77 + logs.splice(MAX_LOGS) 78 + } 79 + 80 + // Also log to console for compatibility 81 + const contextStr = context ? ` ${JSON.stringify(context)}` : '' 82 + const traceStr = traceId ? ` [trace:${traceId}]` : '' 83 + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) 84 + }, 85 + 86 + info(message: string, service: string, context?: Record<string, any>, traceId?: string) { 87 + this.log('info', message, service, context, traceId) 88 + }, 89 + 90 + warn(message: string, service: string, context?: Record<string, any>, traceId?: string) { 91 + this.log('warn', message, service, context, traceId) 92 + }, 93 + 94 + error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) { 95 + const ctx = { ...context } 96 + if (error instanceof Error) { 97 + ctx.error = error.message 98 + ctx.stack = error.stack 99 + } else if (error) { 100 + ctx.error = String(error) 101 + } 102 + this.log('error', message, service, ctx, traceId) 103 + 104 + // Also track in errors 105 + errorTracker.track(message, service, error, context) 106 + }, 107 + 108 + debug(message: string, service: string, context?: Record<string, any>, traceId?: string) { 109 + if (process.env.NODE_ENV !== 'production') { 110 + this.log('debug', message, service, context, traceId) 111 + } 112 + }, 113 + 114 + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { 115 + let filtered = [...logs] 116 + 117 + if (filter?.level) { 118 + filtered = filtered.filter(log => log.level === filter.level) 119 + } 120 + 121 + if (filter?.service) { 122 + filtered = filtered.filter(log => log.service === filter.service) 123 + } 124 + 125 + if (filter?.eventType) { 126 + filtered = filtered.filter(log => log.eventType === filter.eventType) 127 + } 128 + 129 + if (filter?.search) { 130 + const search = filter.search.toLowerCase() 131 + filtered = filtered.filter(log => 132 + log.message.toLowerCase().includes(search) || 133 + JSON.stringify(log.context).toLowerCase().includes(search) 134 + ) 135 + } 136 + 137 + const limit = filter?.limit || 100 138 + return filtered.slice(0, limit) 139 + }, 140 + 141 + clear() { 142 + logs.length = 0 143 + } 144 + } 145 + 146 + // Error tracker with deduplication 147 + export const errorTracker = { 148 + track(message: string, service: string, error?: any, context?: Record<string, any>) { 149 + const key = `${service}:${message}` 150 + 151 + const existing = errors.get(key) 152 + if (existing) { 153 + existing.count++ 154 + existing.lastSeen = new Date() 155 + if (context) { 156 + existing.context = { ...existing.context, ...context } 157 + } 158 + } else { 159 + const entry: ErrorEntry = { 160 + id: generateId('error', errorCounter++), 161 + timestamp: new Date(), 162 + message, 163 + service, 164 + context, 165 + count: 1, 166 + lastSeen: new Date() 167 + } 168 + 169 + if (error instanceof Error) { 170 + entry.stack = error.stack 171 + } 172 + 173 + errors.set(key, entry) 174 + 175 + // Rotate if needed 176 + if (errors.size > MAX_ERRORS) { 177 + const oldest = Array.from(errors.keys())[0] 178 + errors.delete(oldest) 179 + } 180 + } 181 + }, 182 + 183 + getErrors(filter?: { service?: string; limit?: number }) { 184 + let filtered = Array.from(errors.values()) 185 + 186 + if (filter?.service) { 187 + filtered = filtered.filter(err => err.service === filter.service) 188 + } 189 + 190 + // Sort by last seen (most recent first) 191 + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) 192 + 193 + const limit = filter?.limit || 100 194 + return filtered.slice(0, limit) 195 + }, 196 + 197 + clear() { 198 + errors.clear() 199 + } 200 + } 201 + 202 + // Metrics collector 203 + export const metricsCollector = { 204 + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { 205 + const entry: MetricEntry = { 206 + timestamp: new Date(), 207 + path, 208 + method, 209 + statusCode, 210 + duration, 211 + service 212 + } 213 + 214 + metrics.unshift(entry) 215 + 216 + // Rotate if needed 217 + if (metrics.length > MAX_METRICS) { 218 + metrics.splice(MAX_METRICS) 219 + } 220 + }, 221 + 222 + getMetrics(filter?: { service?: string; timeWindow?: number }) { 223 + let filtered = [...metrics] 224 + 225 + if (filter?.service) { 226 + filtered = filtered.filter(m => m.service === filter.service) 227 + } 228 + 229 + if (filter?.timeWindow) { 230 + const cutoff = Date.now() - filter.timeWindow 231 + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) 232 + } 233 + 234 + return filtered 235 + }, 236 + 237 + getStats(service?: string, timeWindow: number = 3600000) { 238 + const filtered = this.getMetrics({ service, timeWindow }) 239 + 240 + if (filtered.length === 0) { 241 + return { 242 + totalRequests: 0, 243 + avgDuration: 0, 244 + p50Duration: 0, 245 + p95Duration: 0, 246 + p99Duration: 0, 247 + errorRate: 0, 248 + requestsPerMinute: 0 249 + } 250 + } 251 + 252 + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) 253 + const totalDuration = durations.reduce((sum, d) => sum + d, 0) 254 + const errors = filtered.filter(m => m.statusCode >= 400).length 255 + 256 + const p50 = durations[Math.floor(durations.length * 0.5)] 257 + const p95 = durations[Math.floor(durations.length * 0.95)] 258 + const p99 = durations[Math.floor(durations.length * 0.99)] 259 + 260 + const timeWindowMinutes = timeWindow / 60000 261 + 262 + return { 263 + totalRequests: filtered.length, 264 + avgDuration: Math.round(totalDuration / filtered.length), 265 + p50Duration: Math.round(p50), 266 + p95Duration: Math.round(p95), 267 + p99Duration: Math.round(p99), 268 + errorRate: (errors / filtered.length) * 100, 269 + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 270 + } 271 + }, 272 + 273 + clear() { 274 + metrics.length = 0 275 + } 276 + } 277 + 278 + // Elysia middleware for request timing 279 + export function observabilityMiddleware(service: string) { 280 + return { 281 + beforeHandle: ({ request }: any) => { 282 + (request as any).__startTime = Date.now() 283 + }, 284 + afterHandle: ({ request, set }: any) => { 285 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 286 + const url = new URL(request.url) 287 + 288 + metricsCollector.recordRequest( 289 + url.pathname, 290 + request.method, 291 + set.status || 200, 292 + duration, 293 + service 294 + ) 295 + }, 296 + onError: ({ request, error, set }: any) => { 297 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 298 + const url = new URL(request.url) 299 + 300 + metricsCollector.recordRequest( 301 + url.pathname, 302 + request.method, 303 + set.status || 500, 304 + duration, 305 + service 306 + ) 307 + 308 + logCollector.error( 309 + `Request failed: ${request.method} ${url.pathname}`, 310 + service, 311 + error, 312 + { statusCode: set.status || 500 } 313 + ) 314 + } 315 + } 316 + } 317 + 318 + // Export singleton logger for easy access 319 + export const logger = { 320 + info: (message: string, context?: Record<string, any>) => 321 + logCollector.info(message, 'hosting-service', context), 322 + warn: (message: string, context?: Record<string, any>) => 323 + logCollector.warn(message, 'hosting-service', context), 324 + error: (message: string, error?: any, context?: Record<string, any>) => 325 + logCollector.error(message, 'hosting-service', error, context), 326 + debug: (message: string, context?: Record<string, any>) => 327 + logCollector.debug(message, 'hosting-service', context) 328 + }
+36 -9
hosting-service/src/lib/utils.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 - import type { WispFsRecord, Directory, Entry, File } from './types'; 2 + import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs'; 3 3 import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 4 4 import { writeFile, readFile, rename } from 'fs/promises'; 5 5 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; ··· 206 206 pathPrefix: string, 207 207 dirSuffix: string = '' 208 208 ): Promise<void> { 209 - for (const entry of entries) { 210 - const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; 211 - const node = entry.node; 209 + // Collect all file blob download tasks first 210 + const downloadTasks: Array<() => Promise<void>> = []; 211 + 212 + function collectFileTasks( 213 + entries: Entry[], 214 + currentPathPrefix: string 215 + ) { 216 + for (const entry of entries) { 217 + const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name; 218 + const node = entry.node; 212 219 213 - if ('type' in node && node.type === 'directory' && 'entries' in node) { 214 - await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath, dirSuffix); 215 - } else if ('type' in node && node.type === 'file' && 'blob' in node) { 216 - const fileNode = node as File; 217 - await cacheFileBlob(did, site, currentPath, fileNode.blob, pdsEndpoint, fileNode.encoding, fileNode.mimeType, fileNode.base64, dirSuffix); 220 + if ('type' in node && node.type === 'directory' && 'entries' in node) { 221 + collectFileTasks(node.entries, currentPath); 222 + } else if ('type' in node && node.type === 'file' && 'blob' in node) { 223 + const fileNode = node as File; 224 + downloadTasks.push(() => cacheFileBlob( 225 + did, 226 + site, 227 + currentPath, 228 + fileNode.blob, 229 + pdsEndpoint, 230 + fileNode.encoding, 231 + fileNode.mimeType, 232 + fileNode.base64, 233 + dirSuffix 234 + )); 235 + } 218 236 } 237 + } 238 + 239 + collectFileTasks(entries, pathPrefix); 240 + 241 + // Execute downloads concurrently with a limit of 3 at a time 242 + const concurrencyLimit = 3; 243 + for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) { 244 + const batch = downloadTasks.slice(i, i + concurrencyLimit); 245 + await Promise.all(batch.map(task => task())); 219 246 } 220 247 } 221 248
+29 -3
hosting-service/src/server.ts
··· 6 6 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 7 7 import { existsSync, readFileSync } from 'fs'; 8 8 import { lookup } from 'mime-types'; 9 + import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability'; 9 10 10 11 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 11 12 ··· 200 201 // Fetch and cache the site 201 202 const siteData = await fetchSiteRecord(did, rkey); 202 203 if (!siteData) { 203 - console.error('Site record not found', did, rkey); 204 + logger.error('Site record not found', null, { did, rkey }); 204 205 return false; 205 206 } 206 207 207 208 const pdsEndpoint = await getPdsForDid(did); 208 209 if (!pdsEndpoint) { 209 - console.error('PDS not found for DID', did); 210 + logger.error('PDS not found for DID', null, { did }); 210 211 return false; 211 212 } 212 213 213 214 try { 214 215 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 216 + logger.info('Site cached successfully', { did, rkey }); 215 217 return true; 216 218 } catch (err) { 217 - console.error('Failed to cache site', did, rkey, err); 219 + logger.error('Failed to cache site', err, { did, rkey }); 218 220 return false; 219 221 } 220 222 } 221 223 222 224 const app = new Elysia({ adapter: node() }) 223 225 .use(opentelemetry()) 226 + .onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle) 227 + .onAfterHandle(observabilityMiddleware('hosting-service').afterHandle) 228 + .onError(observabilityMiddleware('hosting-service').onError) 224 229 .get('/*', async ({ request, set }) => { 225 230 const url = new URL(request.url); 226 231 const hostname = request.headers.get('host') || ''; ··· 351 356 } 352 357 353 358 return serveFromCache(customDomain.did, rkey, path); 359 + }) 360 + // Internal observability endpoints (for admin panel) 361 + .get('/__internal__/observability/logs', ({ query }) => { 362 + const filter: any = {}; 363 + if (query.level) filter.level = query.level; 364 + if (query.service) filter.service = query.service; 365 + if (query.search) filter.search = query.search; 366 + if (query.eventType) filter.eventType = query.eventType; 367 + if (query.limit) filter.limit = parseInt(query.limit as string); 368 + return { logs: logCollector.getLogs(filter) }; 369 + }) 370 + .get('/__internal__/observability/errors', ({ query }) => { 371 + const filter: any = {}; 372 + if (query.service) filter.service = query.service; 373 + if (query.limit) filter.limit = parseInt(query.limit as string); 374 + return { errors: errorTracker.getErrors(filter) }; 375 + }) 376 + .get('/__internal__/observability/metrics', ({ query }) => { 377 + const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 378 + const stats = metricsCollector.getStats('hosting-service', timeWindow); 379 + return { stats, timeWindow }; 354 380 }); 355 381 356 382 export default app;
+6 -1
package.json
··· 15 15 "@elysiajs/cors": "^1.4.0", 16 16 "@elysiajs/eden": "^1.4.3", 17 17 "@elysiajs/openapi": "^1.4.11", 18 + "@elysiajs/opentelemetry": "^1.4.6", 18 19 "@elysiajs/static": "^1.4.2", 19 20 "@radix-ui/react-dialog": "^1.1.15", 20 21 "@radix-ui/react-label": "^2.1.7", ··· 41 42 "bun-plugin-tailwind": "^0.1.2", 42 43 "bun-types": "latest" 43 44 }, 44 - "module": "src/index.js" 45 + "module": "src/index.js", 46 + "trustedDependencies": [ 47 + "core-js", 48 + "protobufjs" 49 + ] 45 50 }
+820
public/admin/admin.tsx
··· 1 + import { StrictMode, useState, useEffect } from 'react' 2 + import { createRoot } from 'react-dom/client' 3 + import './styles.css' 4 + 5 + // Types 6 + interface LogEntry { 7 + id: string 8 + timestamp: string 9 + level: 'info' | 'warn' | 'error' | 'debug' 10 + message: string 11 + service: string 12 + context?: Record<string, any> 13 + eventType?: string 14 + } 15 + 16 + interface ErrorEntry { 17 + id: string 18 + timestamp: string 19 + message: string 20 + stack?: string 21 + service: string 22 + count: number 23 + lastSeen: string 24 + } 25 + 26 + interface MetricsStats { 27 + totalRequests: number 28 + avgDuration: number 29 + p50Duration: number 30 + p95Duration: number 31 + p99Duration: number 32 + errorRate: number 33 + requestsPerMinute: number 34 + } 35 + 36 + // Helper function to format Unix timestamp from database 37 + function formatDbDate(timestamp: number | string): Date { 38 + const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp 39 + return new Date(num * 1000) // Convert seconds to milliseconds 40 + } 41 + 42 + // Login Component 43 + function Login({ onLogin }: { onLogin: () => void }) { 44 + const [username, setUsername] = useState('') 45 + const [password, setPassword] = useState('') 46 + const [error, setError] = useState('') 47 + const [loading, setLoading] = useState(false) 48 + 49 + const handleSubmit = async (e: React.FormEvent) => { 50 + e.preventDefault() 51 + setError('') 52 + setLoading(true) 53 + 54 + try { 55 + const res = await fetch('/api/admin/login', { 56 + method: 'POST', 57 + headers: { 'Content-Type': 'application/json' }, 58 + body: JSON.stringify({ username, password }), 59 + credentials: 'include' 60 + }) 61 + 62 + if (res.ok) { 63 + onLogin() 64 + } else { 65 + setError('Invalid credentials') 66 + } 67 + } catch (err) { 68 + setError('Failed to login') 69 + } finally { 70 + setLoading(false) 71 + } 72 + } 73 + 74 + return ( 75 + <div className="min-h-screen bg-gray-950 flex items-center justify-center p-4"> 76 + <div className="w-full max-w-md"> 77 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl"> 78 + <h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1> 79 + <form onSubmit={handleSubmit} className="space-y-4"> 80 + <div> 81 + <label className="block text-sm font-medium text-gray-300 mb-2"> 82 + Username 83 + </label> 84 + <input 85 + type="text" 86 + value={username} 87 + onChange={(e) => setUsername(e.target.value)} 88 + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 89 + required 90 + /> 91 + </div> 92 + <div> 93 + <label className="block text-sm font-medium text-gray-300 mb-2"> 94 + Password 95 + </label> 96 + <input 97 + type="password" 98 + value={password} 99 + onChange={(e) => setPassword(e.target.value)} 100 + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 101 + required 102 + /> 103 + </div> 104 + {error && ( 105 + <div className="text-red-400 text-sm">{error}</div> 106 + )} 107 + <button 108 + type="submit" 109 + disabled={loading} 110 + className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium py-2 px-4 rounded transition-colors" 111 + > 112 + {loading ? 'Logging in...' : 'Login'} 113 + </button> 114 + </form> 115 + </div> 116 + </div> 117 + </div> 118 + ) 119 + } 120 + 121 + // Dashboard Component 122 + function Dashboard() { 123 + const [tab, setTab] = useState('overview') 124 + const [logs, setLogs] = useState<LogEntry[]>([]) 125 + const [errors, setErrors] = useState<ErrorEntry[]>([]) 126 + const [metrics, setMetrics] = useState<any>(null) 127 + const [database, setDatabase] = useState<any>(null) 128 + const [sites, setSites] = useState<any>(null) 129 + const [health, setHealth] = useState<any>(null) 130 + const [autoRefresh, setAutoRefresh] = useState(true) 131 + 132 + // Filters 133 + const [logLevel, setLogLevel] = useState('') 134 + const [logService, setLogService] = useState('') 135 + const [logSearch, setLogSearch] = useState('') 136 + const [logEventType, setLogEventType] = useState('') 137 + 138 + const fetchLogs = async () => { 139 + const params = new URLSearchParams() 140 + if (logLevel) params.append('level', logLevel) 141 + if (logService) params.append('service', logService) 142 + if (logSearch) params.append('search', logSearch) 143 + if (logEventType) params.append('eventType', logEventType) 144 + params.append('limit', '100') 145 + 146 + const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' }) 147 + if (res.ok) { 148 + const data = await res.json() 149 + setLogs(data.logs) 150 + } 151 + } 152 + 153 + const fetchErrors = async () => { 154 + const res = await fetch('/api/admin/errors', { credentials: 'include' }) 155 + if (res.ok) { 156 + const data = await res.json() 157 + setErrors(data.errors) 158 + } 159 + } 160 + 161 + const fetchMetrics = async () => { 162 + const res = await fetch('/api/admin/metrics', { credentials: 'include' }) 163 + if (res.ok) { 164 + const data = await res.json() 165 + setMetrics(data) 166 + } 167 + } 168 + 169 + const fetchDatabase = async () => { 170 + const res = await fetch('/api/admin/database', { credentials: 'include' }) 171 + if (res.ok) { 172 + const data = await res.json() 173 + setDatabase(data) 174 + } 175 + } 176 + 177 + const fetchSites = async () => { 178 + const res = await fetch('/api/admin/sites', { credentials: 'include' }) 179 + if (res.ok) { 180 + const data = await res.json() 181 + setSites(data) 182 + } 183 + } 184 + 185 + const fetchHealth = async () => { 186 + const res = await fetch('/api/admin/health', { credentials: 'include' }) 187 + if (res.ok) { 188 + const data = await res.json() 189 + setHealth(data) 190 + } 191 + } 192 + 193 + const logout = async () => { 194 + await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) 195 + window.location.reload() 196 + } 197 + 198 + useEffect(() => { 199 + fetchMetrics() 200 + fetchDatabase() 201 + fetchHealth() 202 + fetchLogs() 203 + fetchErrors() 204 + fetchSites() 205 + }, []) 206 + 207 + useEffect(() => { 208 + fetchLogs() 209 + }, [logLevel, logService, logSearch]) 210 + 211 + useEffect(() => { 212 + if (!autoRefresh) return 213 + 214 + const interval = setInterval(() => { 215 + if (tab === 'overview') { 216 + fetchMetrics() 217 + fetchHealth() 218 + } else if (tab === 'logs') { 219 + fetchLogs() 220 + } else if (tab === 'errors') { 221 + fetchErrors() 222 + } else if (tab === 'database') { 223 + fetchDatabase() 224 + } else if (tab === 'sites') { 225 + fetchSites() 226 + } 227 + }, 5000) 228 + 229 + return () => clearInterval(interval) 230 + }, [tab, autoRefresh, logLevel, logService, logSearch]) 231 + 232 + const formatDuration = (ms: number) => { 233 + if (ms < 1000) return `${ms}ms` 234 + return `${(ms / 1000).toFixed(2)}s` 235 + } 236 + 237 + const formatUptime = (seconds: number) => { 238 + const hours = Math.floor(seconds / 3600) 239 + const minutes = Math.floor((seconds % 3600) / 60) 240 + return `${hours}h ${minutes}m` 241 + } 242 + 243 + return ( 244 + <div className="min-h-screen bg-gray-950 text-white"> 245 + {/* Header */} 246 + <div className="bg-gray-900 border-b border-gray-800 px-6 py-4"> 247 + <div className="flex items-center justify-between"> 248 + <h1 className="text-2xl font-bold">Wisp.place Admin</h1> 249 + <div className="flex items-center gap-4"> 250 + <label className="flex items-center gap-2 text-sm text-gray-400"> 251 + <input 252 + type="checkbox" 253 + checked={autoRefresh} 254 + onChange={(e) => setAutoRefresh(e.target.checked)} 255 + className="rounded" 256 + /> 257 + Auto-refresh 258 + </label> 259 + <button 260 + onClick={logout} 261 + className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm" 262 + > 263 + Logout 264 + </button> 265 + </div> 266 + </div> 267 + </div> 268 + 269 + {/* Tabs */} 270 + <div className="bg-gray-900 border-b border-gray-800 px-6"> 271 + <div className="flex gap-1"> 272 + {['overview', 'logs', 'errors', 'database', 'sites'].map((t) => ( 273 + <button 274 + key={t} 275 + onClick={() => setTab(t)} 276 + className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${ 277 + tab === t 278 + ? 'text-white border-b-2 border-blue-500' 279 + : 'text-gray-400 hover:text-white' 280 + }`} 281 + > 282 + {t} 283 + </button> 284 + ))} 285 + </div> 286 + </div> 287 + 288 + {/* Content */} 289 + <div className="p-6"> 290 + {tab === 'overview' && ( 291 + <div className="space-y-6"> 292 + {/* Health */} 293 + {health && ( 294 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 295 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 296 + <div className="text-sm text-gray-400 mb-1">Uptime</div> 297 + <div className="text-2xl font-bold">{formatUptime(health.uptime)}</div> 298 + </div> 299 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 300 + <div className="text-sm text-gray-400 mb-1">Memory Used</div> 301 + <div className="text-2xl font-bold">{health.memory.heapUsed} MB</div> 302 + </div> 303 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 304 + <div className="text-sm text-gray-400 mb-1">RSS</div> 305 + <div className="text-2xl font-bold">{health.memory.rss} MB</div> 306 + </div> 307 + </div> 308 + )} 309 + 310 + {/* Metrics */} 311 + {metrics && ( 312 + <div> 313 + <h2 className="text-xl font-bold mb-4">Performance Metrics</h2> 314 + <div className="space-y-4"> 315 + {/* Overall */} 316 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 317 + <h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3> 318 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 319 + <div> 320 + <div className="text-sm text-gray-400">Total Requests</div> 321 + <div className="text-xl font-bold">{metrics.overall.totalRequests}</div> 322 + </div> 323 + <div> 324 + <div className="text-sm text-gray-400">Avg Duration</div> 325 + <div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div> 326 + </div> 327 + <div> 328 + <div className="text-sm text-gray-400">P95 Duration</div> 329 + <div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div> 330 + </div> 331 + <div> 332 + <div className="text-sm text-gray-400">Error Rate</div> 333 + <div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div> 334 + </div> 335 + </div> 336 + </div> 337 + 338 + {/* Main App */} 339 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 340 + <h3 className="text-lg font-semibold mb-3">Main App</h3> 341 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 342 + <div> 343 + <div className="text-sm text-gray-400">Requests</div> 344 + <div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div> 345 + </div> 346 + <div> 347 + <div className="text-sm text-gray-400">Avg</div> 348 + <div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div> 349 + </div> 350 + <div> 351 + <div className="text-sm text-gray-400">P95</div> 352 + <div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div> 353 + </div> 354 + <div> 355 + <div className="text-sm text-gray-400">Req/min</div> 356 + <div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div> 357 + </div> 358 + </div> 359 + </div> 360 + 361 + {/* Hosting Service */} 362 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 363 + <h3 className="text-lg font-semibold mb-3">Hosting Service</h3> 364 + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 365 + <div> 366 + <div className="text-sm text-gray-400">Requests</div> 367 + <div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div> 368 + </div> 369 + <div> 370 + <div className="text-sm text-gray-400">Avg</div> 371 + <div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div> 372 + </div> 373 + <div> 374 + <div className="text-sm text-gray-400">P95</div> 375 + <div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div> 376 + </div> 377 + <div> 378 + <div className="text-sm text-gray-400">Req/min</div> 379 + <div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div> 380 + </div> 381 + </div> 382 + </div> 383 + </div> 384 + </div> 385 + )} 386 + </div> 387 + )} 388 + 389 + {tab === 'logs' && ( 390 + <div className="space-y-4"> 391 + <div className="flex gap-4"> 392 + <select 393 + value={logLevel} 394 + onChange={(e) => setLogLevel(e.target.value)} 395 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 396 + > 397 + <option value="">All Levels</option> 398 + <option value="info">Info</option> 399 + <option value="warn">Warn</option> 400 + <option value="error">Error</option> 401 + <option value="debug">Debug</option> 402 + </select> 403 + <select 404 + value={logService} 405 + onChange={(e) => setLogService(e.target.value)} 406 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 407 + > 408 + <option value="">All Services</option> 409 + <option value="main-app">Main App</option> 410 + <option value="hosting-service">Hosting Service</option> 411 + </select> 412 + <select 413 + value={logEventType} 414 + onChange={(e) => setLogEventType(e.target.value)} 415 + className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 416 + > 417 + <option value="">All Event Types</option> 418 + <option value="DNS Verifier">DNS Verifier</option> 419 + <option value="Auth">Auth</option> 420 + <option value="User">User</option> 421 + <option value="Domain">Domain</option> 422 + <option value="Site">Site</option> 423 + <option value="File Upload">File Upload</option> 424 + <option value="Sync">Sync</option> 425 + <option value="Maintenance">Maintenance</option> 426 + <option value="KeyRotation">Key Rotation</option> 427 + <option value="Cleanup">Cleanup</option> 428 + <option value="Cache">Cache</option> 429 + <option value="FirehoseWorker">Firehose Worker</option> 430 + </select> 431 + <input 432 + type="text" 433 + value={logSearch} 434 + onChange={(e) => setLogSearch(e.target.value)} 435 + placeholder="Search logs..." 436 + className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 437 + /> 438 + </div> 439 + 440 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 441 + <div className="max-h-[600px] overflow-y-auto"> 442 + <table className="w-full text-sm"> 443 + <thead className="bg-gray-800 sticky top-0"> 444 + <tr> 445 + <th className="px-4 py-2 text-left">Time</th> 446 + <th className="px-4 py-2 text-left">Level</th> 447 + <th className="px-4 py-2 text-left">Service</th> 448 + <th className="px-4 py-2 text-left">Event Type</th> 449 + <th className="px-4 py-2 text-left">Message</th> 450 + </tr> 451 + </thead> 452 + <tbody> 453 + {logs.map((log) => ( 454 + <tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800"> 455 + <td className="px-4 py-2 text-gray-400 whitespace-nowrap"> 456 + {new Date(log.timestamp).toLocaleTimeString()} 457 + </td> 458 + <td className="px-4 py-2"> 459 + <span 460 + className={`px-2 py-1 rounded text-xs font-medium ${ 461 + log.level === 'error' 462 + ? 'bg-red-900 text-red-200' 463 + : log.level === 'warn' 464 + ? 'bg-yellow-900 text-yellow-200' 465 + : log.level === 'info' 466 + ? 'bg-blue-900 text-blue-200' 467 + : 'bg-gray-700 text-gray-300' 468 + }`} 469 + > 470 + {log.level} 471 + </span> 472 + </td> 473 + <td className="px-4 py-2 text-gray-400">{log.service}</td> 474 + <td className="px-4 py-2"> 475 + {log.eventType && ( 476 + <span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium"> 477 + {log.eventType} 478 + </span> 479 + )} 480 + </td> 481 + <td className="px-4 py-2"> 482 + <div>{log.message}</div> 483 + {log.context && Object.keys(log.context).length > 0 && ( 484 + <div className="text-xs text-gray-500 mt-1"> 485 + {JSON.stringify(log.context)} 486 + </div> 487 + )} 488 + </td> 489 + </tr> 490 + ))} 491 + </tbody> 492 + </table> 493 + </div> 494 + </div> 495 + </div> 496 + )} 497 + 498 + {tab === 'errors' && ( 499 + <div className="space-y-4"> 500 + <h2 className="text-xl font-bold">Recent Errors</h2> 501 + <div className="space-y-3"> 502 + {errors.map((error) => ( 503 + <div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4"> 504 + <div className="flex items-start justify-between mb-2"> 505 + <div className="flex-1"> 506 + <div className="font-semibold text-red-400">{error.message}</div> 507 + <div className="text-sm text-gray-400 mt-1"> 508 + Service: {error.service} • Count: {error.count} • Last seen:{' '} 509 + {new Date(error.lastSeen).toLocaleString()} 510 + </div> 511 + </div> 512 + </div> 513 + {error.stack && ( 514 + <pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto"> 515 + {error.stack} 516 + </pre> 517 + )} 518 + </div> 519 + ))} 520 + {errors.length === 0 && ( 521 + <div className="text-center text-gray-500 py-8">No errors found</div> 522 + )} 523 + </div> 524 + </div> 525 + )} 526 + 527 + {tab === 'database' && database && ( 528 + <div className="space-y-6"> 529 + {/* Stats */} 530 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 531 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 532 + <div className="text-sm text-gray-400 mb-1">Total Sites</div> 533 + <div className="text-3xl font-bold">{database.stats.totalSites}</div> 534 + </div> 535 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 536 + <div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div> 537 + <div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div> 538 + </div> 539 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 540 + <div className="text-sm text-gray-400 mb-1">Custom Domains</div> 541 + <div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div> 542 + </div> 543 + </div> 544 + 545 + {/* Recent Sites */} 546 + <div> 547 + <h3 className="text-lg font-semibold mb-3">Recent Sites</h3> 548 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 549 + <table className="w-full text-sm"> 550 + <thead className="bg-gray-800"> 551 + <tr> 552 + <th className="px-4 py-2 text-left">Site Name</th> 553 + <th className="px-4 py-2 text-left">Subdomain</th> 554 + <th className="px-4 py-2 text-left">DID</th> 555 + <th className="px-4 py-2 text-left">RKey</th> 556 + <th className="px-4 py-2 text-left">Created</th> 557 + </tr> 558 + </thead> 559 + <tbody> 560 + {database.recentSites.map((site: any, i: number) => ( 561 + <tr key={i} className="border-t border-gray-800"> 562 + <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 563 + <td className="px-4 py-2"> 564 + {site.subdomain ? ( 565 + <a 566 + href={`https://${site.subdomain}`} 567 + target="_blank" 568 + rel="noopener noreferrer" 569 + className="text-blue-400 hover:underline" 570 + > 571 + {site.subdomain} 572 + </a> 573 + ) : ( 574 + <span className="text-gray-500">No domain</span> 575 + )} 576 + </td> 577 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 578 + {site.did.slice(0, 20)}... 579 + </td> 580 + <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 581 + <td className="px-4 py-2 text-gray-400"> 582 + {formatDbDate(site.created_at).toLocaleDateString()} 583 + </td> 584 + <td className="px-4 py-2"> 585 + <a 586 + href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 587 + target="_blank" 588 + rel="noopener noreferrer" 589 + className="text-blue-400 hover:text-blue-300 transition-colors" 590 + title="View on PDSls.dev" 591 + > 592 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 593 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 594 + </svg> 595 + </a> 596 + </td> 597 + </tr> 598 + ))} 599 + </tbody> 600 + </table> 601 + </div> 602 + </div> 603 + 604 + {/* Recent Domains */} 605 + <div> 606 + <h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3> 607 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 608 + <table className="w-full text-sm"> 609 + <thead className="bg-gray-800"> 610 + <tr> 611 + <th className="px-4 py-2 text-left">Domain</th> 612 + <th className="px-4 py-2 text-left">DID</th> 613 + <th className="px-4 py-2 text-left">Verified</th> 614 + <th className="px-4 py-2 text-left">Created</th> 615 + </tr> 616 + </thead> 617 + <tbody> 618 + {database.recentDomains.map((domain: any, i: number) => ( 619 + <tr key={i} className="border-t border-gray-800"> 620 + <td className="px-4 py-2">{domain.domain}</td> 621 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 622 + {domain.did.slice(0, 20)}... 623 + </td> 624 + <td className="px-4 py-2"> 625 + <span 626 + className={`px-2 py-1 rounded text-xs ${ 627 + domain.verified 628 + ? 'bg-green-900 text-green-200' 629 + : 'bg-yellow-900 text-yellow-200' 630 + }`} 631 + > 632 + {domain.verified ? 'Yes' : 'No'} 633 + </span> 634 + </td> 635 + <td className="px-4 py-2 text-gray-400"> 636 + {formatDbDate(domain.created_at).toLocaleDateString()} 637 + </td> 638 + </tr> 639 + ))} 640 + </tbody> 641 + </table> 642 + </div> 643 + </div> 644 + </div> 645 + )} 646 + 647 + {tab === 'sites' && sites && ( 648 + <div className="space-y-6"> 649 + {/* All Sites */} 650 + <div> 651 + <h3 className="text-lg font-semibold mb-3">All Sites</h3> 652 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 653 + <table className="w-full text-sm"> 654 + <thead className="bg-gray-800"> 655 + <tr> 656 + <th className="px-4 py-2 text-left">Site Name</th> 657 + <th className="px-4 py-2 text-left">Subdomain</th> 658 + <th className="px-4 py-2 text-left">DID</th> 659 + <th className="px-4 py-2 text-left">RKey</th> 660 + <th className="px-4 py-2 text-left">Created</th> 661 + </tr> 662 + </thead> 663 + <tbody> 664 + {sites.sites.map((site: any, i: number) => ( 665 + <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 666 + <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 667 + <td className="px-4 py-2"> 668 + {site.subdomain ? ( 669 + <a 670 + href={`https://${site.subdomain}`} 671 + target="_blank" 672 + rel="noopener noreferrer" 673 + className="text-blue-400 hover:underline" 674 + > 675 + {site.subdomain} 676 + </a> 677 + ) : ( 678 + <span className="text-gray-500">No domain</span> 679 + )} 680 + </td> 681 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 682 + {site.did.slice(0, 30)}... 683 + </td> 684 + <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 685 + <td className="px-4 py-2 text-gray-400"> 686 + {formatDbDate(site.created_at).toLocaleString()} 687 + </td> 688 + <td className="px-4 py-2"> 689 + <a 690 + href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 691 + target="_blank" 692 + rel="noopener noreferrer" 693 + className="text-blue-400 hover:text-blue-300 transition-colors" 694 + title="View on PDSls.dev" 695 + > 696 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 697 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 698 + </svg> 699 + </a> 700 + </td> 701 + </tr> 702 + ))} 703 + </tbody> 704 + </table> 705 + </div> 706 + </div> 707 + 708 + {/* Custom Domains */} 709 + <div> 710 + <h3 className="text-lg font-semibold mb-3">Custom Domains</h3> 711 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 712 + <table className="w-full text-sm"> 713 + <thead className="bg-gray-800"> 714 + <tr> 715 + <th className="px-4 py-2 text-left">Domain</th> 716 + <th className="px-4 py-2 text-left">Verified</th> 717 + <th className="px-4 py-2 text-left">DID</th> 718 + <th className="px-4 py-2 text-left">RKey</th> 719 + <th className="px-4 py-2 text-left">Created</th> 720 + <th className="px-4 py-2 text-left">PDSls</th> 721 + </tr> 722 + </thead> 723 + <tbody> 724 + {sites.customDomains.map((domain: any, i: number) => ( 725 + <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 726 + <td className="px-4 py-2"> 727 + {domain.verified ? ( 728 + <a 729 + href={`https://${domain.domain}`} 730 + target="_blank" 731 + rel="noopener noreferrer" 732 + className="text-blue-400 hover:underline" 733 + > 734 + {domain.domain} 735 + </a> 736 + ) : ( 737 + <span className="text-gray-400">{domain.domain}</span> 738 + )} 739 + </td> 740 + <td className="px-4 py-2"> 741 + <span 742 + className={`px-2 py-1 rounded text-xs ${ 743 + domain.verified 744 + ? 'bg-green-900 text-green-200' 745 + : 'bg-yellow-900 text-yellow-200' 746 + }`} 747 + > 748 + {domain.verified ? 'Yes' : 'Pending'} 749 + </span> 750 + </td> 751 + <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 752 + {domain.did.slice(0, 30)}... 753 + </td> 754 + <td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td> 755 + <td className="px-4 py-2 text-gray-400"> 756 + {formatDbDate(domain.created_at).toLocaleString()} 757 + </td> 758 + <td className="px-4 py-2"> 759 + <a 760 + href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`} 761 + target="_blank" 762 + rel="noopener noreferrer" 763 + className="text-blue-400 hover:text-blue-300 transition-colors" 764 + title="View on PDSls.dev" 765 + > 766 + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 767 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 768 + </svg> 769 + </a> 770 + </td> 771 + </tr> 772 + ))} 773 + </tbody> 774 + </table> 775 + </div> 776 + </div> 777 + </div> 778 + )} 779 + </div> 780 + </div> 781 + ) 782 + } 783 + 784 + // Main App 785 + function App() { 786 + const [authenticated, setAuthenticated] = useState(false) 787 + const [checking, setChecking] = useState(true) 788 + 789 + useEffect(() => { 790 + fetch('/api/admin/status', { credentials: 'include' }) 791 + .then((res) => res.json()) 792 + .then((data) => { 793 + setAuthenticated(data.authenticated) 794 + setChecking(false) 795 + }) 796 + .catch(() => { 797 + setChecking(false) 798 + }) 799 + }, []) 800 + 801 + if (checking) { 802 + return ( 803 + <div className="min-h-screen bg-gray-950 flex items-center justify-center"> 804 + <div className="text-white">Loading...</div> 805 + </div> 806 + ) 807 + } 808 + 809 + if (!authenticated) { 810 + return <Login onLogin={() => setAuthenticated(true)} /> 811 + } 812 + 813 + return <Dashboard /> 814 + } 815 + 816 + createRoot(document.getElementById('root')!).render( 817 + <StrictMode> 818 + <App /> 819 + </StrictMode> 820 + )
+13
public/admin/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Admin Dashboard - Wisp.place</title> 7 + <link rel="stylesheet" href="./styles.css" /> 8 + </head> 9 + <body> 10 + <div id="root"></div> 11 + <script type="module" src="./admin.tsx"></script> 12 + </body> 13 + </html>
+1
public/admin/styles.css
··· 1 + @import "tailwindcss";
+286 -33
public/editor/editor.tsx
··· 88 88 const [configuringSite, setConfiguringSite] = useState<Site | null>(null) 89 89 const [selectedDomain, setSelectedDomain] = useState<string>('') 90 90 const [isSavingConfig, setIsSavingConfig] = useState(false) 91 + const [isDeletingSite, setIsDeletingSite] = useState(false) 91 92 92 93 // Upload state 93 - const [siteName, setSiteName] = useState('') 94 + const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 95 + const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 96 + const [newSiteName, setNewSiteName] = useState('') 94 97 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 95 98 const [isUploading, setIsUploading] = useState(false) 96 99 const [uploadProgress, setUploadProgress] = useState('') ··· 106 109 }>({}) 107 110 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 108 111 112 + // Wisp domain claim state 113 + const [wispHandle, setWispHandle] = useState('') 114 + const [isClaimingWisp, setIsClaimingWisp] = useState(false) 115 + const [wispAvailability, setWispAvailability] = useState<{ 116 + available: boolean | null 117 + checking: boolean 118 + }>({ available: null, checking: false }) 119 + 109 120 // Fetch user info on mount 110 121 useEffect(() => { 111 122 fetchUserInfo() 112 123 fetchSites() 113 124 fetchDomains() 114 125 }, []) 126 + 127 + // Auto-switch to 'new' mode if no sites exist 128 + useEffect(() => { 129 + if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 130 + setSiteMode('new') 131 + } 132 + }, [sites, sitesLoading, siteMode]) 115 133 116 134 const fetchUserInfo = async () => { 117 135 try { ··· 207 225 } 208 226 209 227 const handleUpload = async () => { 228 + const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 229 + 210 230 if (!siteName) { 211 - alert('Please enter a site name') 231 + alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 212 232 return 213 233 } 214 234 ··· 236 256 setUploadProgress('Upload complete!') 237 257 setSkippedFiles(data.skippedFiles || []) 238 258 setUploadedCount(data.uploadedCount || data.fileCount || 0) 239 - setSiteName('') 259 + setSelectedSiteRkey('') 260 + setNewSiteName('') 240 261 setSelectedFiles(null) 241 262 242 263 // Refresh sites list ··· 430 451 } 431 452 } 432 453 454 + const handleDeleteSite = async () => { 455 + if (!configuringSite) return 456 + 457 + if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { 458 + return 459 + } 460 + 461 + setIsDeletingSite(true) 462 + try { 463 + const response = await fetch(`/api/site/${configuringSite.rkey}`, { 464 + method: 'DELETE' 465 + }) 466 + 467 + const data = await response.json() 468 + if (data.success) { 469 + // Refresh sites list 470 + await fetchSites() 471 + // Refresh domains in case this site was mapped 472 + await fetchDomains() 473 + setConfiguringSite(null) 474 + } else { 475 + throw new Error(data.error || 'Failed to delete site') 476 + } 477 + } catch (err) { 478 + console.error('Delete site error:', err) 479 + alert( 480 + `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 481 + ) 482 + } finally { 483 + setIsDeletingSite(false) 484 + } 485 + } 486 + 487 + const checkWispAvailability = async (handle: string) => { 488 + const trimmedHandle = handle.trim().toLowerCase() 489 + if (!trimmedHandle) { 490 + setWispAvailability({ available: null, checking: false }) 491 + return 492 + } 493 + 494 + setWispAvailability({ available: null, checking: true }) 495 + try { 496 + const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 497 + const data = await response.json() 498 + setWispAvailability({ available: data.available, checking: false }) 499 + } catch (err) { 500 + console.error('Check availability error:', err) 501 + setWispAvailability({ available: false, checking: false }) 502 + } 503 + } 504 + 505 + const handleClaimWispDomain = async () => { 506 + const trimmedHandle = wispHandle.trim().toLowerCase() 507 + if (!trimmedHandle) { 508 + alert('Please enter a handle') 509 + return 510 + } 511 + 512 + setIsClaimingWisp(true) 513 + try { 514 + const response = await fetch('/api/domain/claim', { 515 + method: 'POST', 516 + headers: { 'Content-Type': 'application/json' }, 517 + body: JSON.stringify({ handle: trimmedHandle }) 518 + }) 519 + 520 + const data = await response.json() 521 + if (data.success) { 522 + setWispHandle('') 523 + setWispAvailability({ available: null, checking: false }) 524 + await fetchDomains() 525 + } else { 526 + throw new Error(data.error || 'Failed to claim domain') 527 + } 528 + } catch (err) { 529 + console.error('Claim domain error:', err) 530 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 531 + 532 + // Handle "Already claimed" error more gracefully 533 + if (errorMessage.includes('Already claimed')) { 534 + alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 535 + await fetchDomains() 536 + } else { 537 + alert(`Failed to claim domain: ${errorMessage}`) 538 + } 539 + } finally { 540 + setIsClaimingWisp(false) 541 + } 542 + } 543 + 433 544 if (loading) { 434 545 return ( 435 546 <div className="w-full min-h-screen bg-background flex items-center justify-center"> ··· 586 697 </p> 587 698 </> 588 699 ) : ( 589 - <div className="text-center py-4 text-muted-foreground"> 590 - <p>No wisp.place subdomain claimed yet.</p> 591 - <p className="text-sm mt-1"> 592 - You should have claimed one during onboarding! 593 - </p> 700 + <div className="space-y-4"> 701 + <div className="p-4 bg-muted/30 rounded-lg"> 702 + <p className="text-sm text-muted-foreground mb-4"> 703 + Claim your free wisp.place subdomain 704 + </p> 705 + <div className="space-y-3"> 706 + <div className="space-y-2"> 707 + <Label htmlFor="wisp-handle">Choose your handle</Label> 708 + <div className="flex gap-2"> 709 + <div className="flex-1 relative"> 710 + <Input 711 + id="wisp-handle" 712 + placeholder="mysite" 713 + value={wispHandle} 714 + onChange={(e) => { 715 + setWispHandle(e.target.value) 716 + if (e.target.value.trim()) { 717 + checkWispAvailability(e.target.value) 718 + } else { 719 + setWispAvailability({ available: null, checking: false }) 720 + } 721 + }} 722 + disabled={isClaimingWisp} 723 + className="pr-24" 724 + /> 725 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 726 + .wisp.place 727 + </span> 728 + </div> 729 + </div> 730 + {wispAvailability.checking && ( 731 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 732 + <Loader2 className="w-3 h-3 animate-spin" /> 733 + Checking availability... 734 + </p> 735 + )} 736 + {!wispAvailability.checking && wispAvailability.available === true && ( 737 + <p className="text-xs text-green-600 flex items-center gap-1"> 738 + <CheckCircle2 className="w-3 h-3" /> 739 + Available 740 + </p> 741 + )} 742 + {!wispAvailability.checking && wispAvailability.available === false && ( 743 + <p className="text-xs text-red-600 flex items-center gap-1"> 744 + <XCircle className="w-3 h-3" /> 745 + Not available 746 + </p> 747 + )} 748 + </div> 749 + <Button 750 + onClick={handleClaimWispDomain} 751 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 752 + className="w-full" 753 + > 754 + {isClaimingWisp ? ( 755 + <> 756 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 757 + Claiming... 758 + </> 759 + ) : ( 760 + 'Claim Subdomain' 761 + )} 762 + </Button> 763 + </div> 764 + </div> 594 765 </div> 595 766 )} 596 767 </CardContent> ··· 712 883 </CardDescription> 713 884 </CardHeader> 714 885 <CardContent className="space-y-6"> 715 - <div className="space-y-2"> 716 - <Label htmlFor="site-name">Site Name</Label> 717 - <Input 718 - id="site-name" 719 - placeholder="my-awesome-site" 720 - value={siteName} 721 - onChange={(e) => setSiteName(e.target.value)} 886 + <div className="space-y-4"> 887 + <RadioGroup 888 + value={siteMode} 889 + onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 722 890 disabled={isUploading} 723 - /> 891 + > 892 + <div className="flex items-center space-x-2"> 893 + <RadioGroupItem value="existing" id="existing" /> 894 + <Label htmlFor="existing" className="cursor-pointer"> 895 + Update existing site 896 + </Label> 897 + </div> 898 + <div className="flex items-center space-x-2"> 899 + <RadioGroupItem value="new" id="new" /> 900 + <Label htmlFor="new" className="cursor-pointer"> 901 + Create new site 902 + </Label> 903 + </div> 904 + </RadioGroup> 905 + 906 + {siteMode === 'existing' ? ( 907 + <div className="space-y-2"> 908 + <Label htmlFor="site-select">Select Site</Label> 909 + {sitesLoading ? ( 910 + <div className="flex items-center justify-center py-4"> 911 + <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 912 + </div> 913 + ) : sites.length === 0 ? ( 914 + <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 915 + No sites available. Create a new site instead. 916 + </div> 917 + ) : ( 918 + <select 919 + id="site-select" 920 + 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" 921 + value={selectedSiteRkey} 922 + onChange={(e) => setSelectedSiteRkey(e.target.value)} 923 + disabled={isUploading} 924 + > 925 + <option value="">Select a site...</option> 926 + {sites.map((site) => ( 927 + <option key={site.rkey} value={site.rkey}> 928 + {site.display_name || site.rkey} 929 + </option> 930 + ))} 931 + </select> 932 + )} 933 + </div> 934 + ) : ( 935 + <div className="space-y-2"> 936 + <Label htmlFor="new-site-name">New Site Name</Label> 937 + <Input 938 + id="new-site-name" 939 + placeholder="my-awesome-site" 940 + value={newSiteName} 941 + onChange={(e) => setNewSiteName(e.target.value)} 942 + disabled={isUploading} 943 + /> 944 + </div> 945 + )} 946 + 724 947 <p className="text-xs text-muted-foreground"> 725 948 File limits: 100MB per file, 300MB total 726 949 </p> ··· 828 1051 <Button 829 1052 onClick={handleUpload} 830 1053 className="w-full" 831 - disabled={!siteName || isUploading} 1054 + disabled={ 1055 + (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 1056 + isUploading || 1057 + (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 1058 + } 832 1059 > 833 1060 {isUploading ? ( 834 1061 <> ··· 837 1064 </> 838 1065 ) : ( 839 1066 <> 840 - {selectedFiles && selectedFiles.length > 0 841 - ? 'Upload & Deploy' 842 - : 'Create Empty Site'} 1067 + {siteMode === 'existing' ? ( 1068 + 'Update Site' 1069 + ) : ( 1070 + selectedFiles && selectedFiles.length > 0 1071 + ? 'Upload & Deploy' 1072 + : 'Create Empty Site' 1073 + )} 843 1074 </> 844 1075 )} 845 1076 </Button> ··· 994 1225 </RadioGroup> 995 1226 </div> 996 1227 )} 997 - <DialogFooter> 1228 + <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2"> 998 1229 <Button 999 - variant="outline" 1000 - onClick={() => setConfiguringSite(null)} 1001 - disabled={isSavingConfig} 1230 + variant="destructive" 1231 + onClick={handleDeleteSite} 1232 + disabled={isSavingConfig || isDeletingSite} 1233 + className="sm:mr-auto" 1002 1234 > 1003 - Cancel 1004 - </Button> 1005 - <Button 1006 - onClick={handleSaveSiteConfig} 1007 - disabled={isSavingConfig} 1008 - > 1009 - {isSavingConfig ? ( 1235 + {isDeletingSite ? ( 1010 1236 <> 1011 1237 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1012 - Saving... 1238 + Deleting... 1013 1239 </> 1014 1240 ) : ( 1015 - 'Save' 1241 + <> 1242 + <Trash2 className="w-4 h-4 mr-2" /> 1243 + Delete Site 1244 + </> 1016 1245 )} 1017 1246 </Button> 1247 + <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto"> 1248 + <Button 1249 + variant="outline" 1250 + onClick={() => setConfiguringSite(null)} 1251 + disabled={isSavingConfig || isDeletingSite} 1252 + className="w-full sm:w-auto" 1253 + > 1254 + Cancel 1255 + </Button> 1256 + <Button 1257 + onClick={handleSaveSiteConfig} 1258 + disabled={isSavingConfig || isDeletingSite} 1259 + className="w-full sm:w-auto" 1260 + > 1261 + {isSavingConfig ? ( 1262 + <> 1263 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1264 + Saving... 1265 + </> 1266 + ) : ( 1267 + 'Save' 1268 + )} 1269 + </Button> 1270 + </div> 1018 1271 </DialogFooter> 1019 1272 </DialogContent> 1020 1273 </Dialog>
+265 -250
public/index.tsx
··· 25 25 }, [showForm]) 26 26 27 27 return ( 28 - <div className="min-h-screen"> 29 - {/* Header */} 30 - <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 31 - <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 32 - <div className="flex items-center gap-2"> 33 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 34 - <Globe className="w-5 h-5 text-primary-foreground" /> 28 + <> 29 + <div className="min-h-screen"> 30 + {/* Header */} 31 + <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 32 + <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 33 + <div className="flex items-center gap-2"> 34 + <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 35 + <Globe className="w-5 h-5 text-primary-foreground" /> 36 + </div> 37 + <span className="text-xl font-semibold text-foreground"> 38 + wisp.place 39 + </span> 40 + </div> 41 + <div className="flex items-center gap-3"> 42 + <Button 43 + variant="ghost" 44 + size="sm" 45 + onClick={() => setShowForm(true)} 46 + > 47 + Sign In 48 + </Button> 49 + <Button 50 + size="sm" 51 + className="bg-accent text-accent-foreground hover:bg-accent/90" 52 + > 53 + Get Started 54 + </Button> 35 55 </div> 36 - <span className="text-xl font-semibold text-foreground"> 37 - wisp.place 38 - </span> 39 56 </div> 40 - <div className="flex items-center gap-3"> 41 - <Button 42 - variant="ghost" 43 - size="sm" 44 - onClick={() => setShowForm(true)} 45 - > 46 - Sign In 47 - </Button> 48 - <Button 49 - size="sm" 50 - className="bg-accent text-accent-foreground hover:bg-accent/90" 51 - > 52 - Get Started 53 - </Button> 54 - </div> 55 - </div> 56 - </header> 57 + </header> 57 58 58 - {/* Hero Section */} 59 - <section className="container mx-auto px-4 py-20 md:py-32"> 60 - <div className="max-w-4xl mx-auto text-center"> 61 - <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 62 - <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 63 - <span className="text-sm text-accent-foreground"> 64 - Built on AT Protocol 65 - </span> 66 - </div> 59 + {/* Hero Section */} 60 + <section className="container mx-auto px-4 py-20 md:py-32"> 61 + <div className="max-w-4xl mx-auto text-center"> 62 + <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 63 + <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 64 + <span className="text-sm text-accent-foreground"> 65 + Built on AT Protocol 66 + </span> 67 + </div> 67 68 68 - <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 69 - Host your sites on the{' '} 70 - <span className="text-primary">decentralized</span> web 71 - </h1> 69 + <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 70 + Your Website.Your Control. Lightning Fast. 71 + </h1> 72 72 73 - <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 74 - Deploy static sites to a truly open network. Your 75 - content, your control, your identity. No platform 76 - lock-in, ever. 77 - </p> 73 + <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 74 + Host static sites in your AT Protocol account. You 75 + keep ownership and control. We just serve them fast 76 + through our CDN. 77 + </p> 78 78 79 - <div className="max-w-md mx-auto relative"> 80 - <div 81 - className={`transition-all duration-500 ease-in-out ${ 82 - showForm 83 - ? 'opacity-0 -translate-y-5 pointer-events-none' 84 - : 'opacity-100 translate-y-0' 85 - }`} 86 - > 87 - <Button 88 - size="lg" 89 - className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 90 - onClick={() => setShowForm(true)} 79 + <div className="max-w-md mx-auto relative"> 80 + <div 81 + className={`transition-all duration-500 ease-in-out ${ 82 + showForm 83 + ? 'opacity-0 -translate-y-5 pointer-events-none' 84 + : 'opacity-100 translate-y-0' 85 + }`} 91 86 > 92 - Log in with AT Proto 93 - <ArrowRight className="ml-2 w-5 h-5" /> 94 - </Button> 95 - </div> 87 + <Button 88 + size="lg" 89 + className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 90 + onClick={() => setShowForm(true)} 91 + > 92 + Log in with AT Proto 93 + <ArrowRight className="ml-2 w-5 h-5" /> 94 + </Button> 95 + </div> 96 96 97 - <div 98 - className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 99 - showForm 100 - ? 'opacity-100 translate-y-0' 101 - : 'opacity-0 translate-y-5 pointer-events-none' 102 - }`} 103 - > 104 - <form 105 - onSubmit={async (e) => { 106 - e.preventDefault() 107 - try { 108 - const handle = inputRef.current?.value 109 - const res = await fetch( 110 - '/api/auth/signin', 111 - { 112 - method: 'POST', 113 - headers: { 114 - 'Content-Type': 115 - 'application/json' 116 - }, 117 - body: JSON.stringify({ handle }) 97 + <div 98 + className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 99 + showForm 100 + ? 'opacity-100 translate-y-0' 101 + : 'opacity-0 translate-y-5 pointer-events-none' 102 + }`} 103 + > 104 + <form 105 + onSubmit={async (e) => { 106 + e.preventDefault() 107 + try { 108 + const handle = 109 + inputRef.current?.value 110 + const res = await fetch( 111 + '/api/auth/signin', 112 + { 113 + method: 'POST', 114 + headers: { 115 + 'Content-Type': 116 + 'application/json' 117 + }, 118 + body: JSON.stringify({ 119 + handle 120 + }) 121 + } 122 + ) 123 + if (!res.ok) 124 + throw new Error( 125 + 'Request failed' 126 + ) 127 + const data = await res.json() 128 + if (data.url) { 129 + window.location.href = data.url 130 + } else { 131 + alert('Unexpected response') 118 132 } 119 - ) 120 - if (!res.ok) 121 - throw new Error('Request failed') 122 - const data = await res.json() 123 - if (data.url) { 124 - window.location.href = data.url 125 - } else { 126 - alert('Unexpected response') 133 + } catch (error) { 134 + console.error( 135 + 'Login failed:', 136 + error 137 + ) 138 + alert('Authentication failed') 127 139 } 128 - } catch (error) { 129 - console.error('Login failed:', error) 130 - alert('Authentication failed') 131 - } 132 - }} 133 - className="space-y-3" 134 - > 135 - <input 136 - ref={inputRef} 137 - type="text" 138 - name="handle" 139 - placeholder="Enter your handle (e.g., alice.bsky.social)" 140 - className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 141 - /> 142 - <button 143 - type="submit" 144 - className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" 140 + }} 141 + className="space-y-3" 145 142 > 146 - Continue 147 - <ArrowRight className="ml-2 w-5 h-5" /> 148 - </button> 149 - </form> 143 + <input 144 + ref={inputRef} 145 + type="text" 146 + name="handle" 147 + placeholder="Enter your handle (e.g., alice.bsky.social)" 148 + className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 149 + /> 150 + <button 151 + type="submit" 152 + className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" 153 + > 154 + Continue 155 + <ArrowRight className="ml-2 w-5 h-5" /> 156 + </button> 157 + </form> 158 + </div> 150 159 </div> 151 160 </div> 152 - </div> 153 - </section> 161 + </section> 154 162 155 - {/* Stats Section */} 156 - <section className="container mx-auto px-4 py-16"> 157 - <div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-5xl mx-auto"> 158 - {[ 159 - { value: '100%', label: 'Decentralized' }, 160 - { value: '0ms', label: 'Cold Start' }, 161 - { value: '∞', label: 'Scalability' }, 162 - { value: 'You', label: 'Own Your Data' } 163 - ].map((stat, i) => ( 164 - <div key={i} className="text-center"> 165 - <div className="text-4xl md:text-5xl font-bold text-primary mb-2"> 166 - {stat.value} 163 + {/* How It Works */} 164 + <section className="container mx-auto px-4 py-16 bg-muted/30"> 165 + <div className="max-w-3xl mx-auto text-center"> 166 + <h2 className="text-3xl md:text-4xl font-bold mb-8"> 167 + How it works 168 + </h2> 169 + <div className="space-y-6 text-left"> 170 + <div className="flex gap-4 items-start"> 171 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 172 + 01 173 + </div> 174 + <div> 175 + <h3 className="text-xl font-semibold mb-2"> 176 + Upload your static site 177 + </h3> 178 + <p className="text-muted-foreground"> 179 + Your HTML, CSS, and JavaScript files are 180 + stored in your AT Protocol account as 181 + gzipped blobs and a manifest record. 182 + </p> 183 + </div> 184 + </div> 185 + <div className="flex gap-4 items-start"> 186 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 187 + 02 188 + </div> 189 + <div> 190 + <h3 className="text-xl font-semibold mb-2"> 191 + We serve it globally 192 + </h3> 193 + <p className="text-muted-foreground"> 194 + Wisp.place reads your site from your 195 + account and delivers it through our CDN 196 + for fast loading anywhere. 197 + </p> 198 + </div> 167 199 </div> 168 - <div className="text-sm text-muted-foreground"> 169 - {stat.label} 200 + <div className="flex gap-4 items-start"> 201 + <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 202 + 03 203 + </div> 204 + <div> 205 + <h3 className="text-xl font-semibold mb-2"> 206 + You stay in control 207 + </h3> 208 + <p className="text-muted-foreground"> 209 + Update or remove your site anytime 210 + through your AT Protocol account. No 211 + lock-in, no middleman ownership. 212 + </p> 213 + </div> 170 214 </div> 171 215 </div> 172 - ))} 173 - </div> 174 - </section> 216 + </div> 217 + </section> 175 218 176 - {/* Features Grid */} 177 - <section id="features" className="container mx-auto px-4 py-20"> 178 - <div className="text-center mb-16"> 179 - <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 180 - Built for the open web 181 - </h2> 182 - <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 183 - Everything you need to deploy and manage static sites on 184 - a decentralized network 185 - </p> 186 - </div> 219 + {/* Features Grid */} 220 + <section id="features" className="container mx-auto px-4 py-20"> 221 + <div className="text-center mb-16"> 222 + <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 223 + Why Wisp.place? 224 + </h2> 225 + <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 226 + Static site hosting that respects your ownership 227 + </p> 228 + </div> 187 229 188 - <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 189 - {[ 190 - { 191 - icon: Shield, 192 - title: 'True Ownership', 193 - description: 194 - 'Your content lives on the AT Protocol network. No single company can take it down or lock you out.' 195 - }, 196 - { 197 - icon: Zap, 198 - title: 'Lightning Fast', 199 - description: 200 - 'Distributed edge network ensures your sites load instantly from anywhere in the world.' 201 - }, 202 - { 203 - icon: Lock, 204 - title: 'Cryptographic Security', 205 - description: 206 - 'Content-addressed storage and cryptographic verification ensure integrity and authenticity.' 207 - }, 208 - { 209 - icon: Code, 210 - title: 'Developer Friendly', 211 - description: 212 - 'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.' 213 - }, 214 - { 215 - icon: Server, 216 - title: 'Zero Vendor Lock-in', 217 - description: 218 - 'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.' 219 - }, 220 - { 221 - icon: Globe, 222 - title: 'Global Network', 223 - description: 224 - 'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.' 225 - } 226 - ].map((feature, i) => ( 227 - <Card 228 - key={i} 229 - className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 230 - > 231 - <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 232 - <feature.icon className="w-6 h-6 text-accent" /> 233 - </div> 234 - <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 235 - {feature.title} 236 - </h3> 237 - <p className="text-muted-foreground leading-relaxed"> 238 - {feature.description} 239 - </p> 240 - </Card> 241 - ))} 242 - </div> 243 - </section> 244 - 245 - {/* How It Works */} 246 - <section 247 - id="how-it-works" 248 - className="container mx-auto px-4 py-20 bg-muted/30" 249 - > 250 - <div className="max-w-4xl mx-auto"> 251 - <h2 className="text-4xl md:text-5xl font-bold text-center mb-16 text-balance"> 252 - Deploy in three steps 253 - </h2> 254 - 255 - <div className="space-y-12"> 230 + <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 256 231 {[ 257 232 { 258 - step: '01', 259 - title: 'Upload your site', 233 + icon: Shield, 234 + title: 'You Own Your Content', 260 235 description: 261 - 'Link your Git repository or upload a folder containing your static site directly.' 236 + 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' 262 237 }, 263 238 { 264 - step: '02', 265 - title: 'Name and set domain', 239 + icon: Zap, 240 + title: 'CDN Performance', 266 241 description: 267 - 'Name your site and set domain routing to it. You can bring your own domain too.' 242 + 'We cache and serve your site from edge locations worldwide for fast load times.' 268 243 }, 269 244 { 270 - step: '03', 271 - title: 'Deploy to AT Protocol', 245 + icon: Lock, 246 + title: 'No Vendor Lock-in', 272 247 description: 273 - 'Your site is published to the decentralized network with a permanent, verifiable identity.' 248 + 'Your data stays in your account. Switch providers or self-host whenever you want.' 249 + }, 250 + { 251 + icon: Code, 252 + title: 'Simple Deployment', 253 + description: 254 + 'Upload your static files and we handle the rest. No complex configuration needed.' 255 + }, 256 + { 257 + icon: Server, 258 + title: 'AT Protocol Native', 259 + description: 260 + 'Built for the decentralized web. Your site has a verifiable identity on the network.' 261 + }, 262 + { 263 + icon: Globe, 264 + title: 'Custom Domains', 265 + description: 266 + 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' 274 267 } 275 - ].map((step, i) => ( 276 - <div key={i} className="flex gap-6 items-start"> 277 - <div className="text-6xl font-bold text-accent/20 min-w-[80px]"> 278 - {step.step} 279 - </div> 280 - <div className="flex-1 pt-2"> 281 - <h3 className="text-2xl font-semibold mb-3"> 282 - {step.title} 283 - </h3> 284 - <p className="text-lg text-muted-foreground leading-relaxed"> 285 - {step.description} 286 - </p> 268 + ].map((feature, i) => ( 269 + <Card 270 + key={i} 271 + className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 272 + > 273 + <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 274 + <feature.icon className="w-6 h-6 text-accent" /> 287 275 </div> 288 - </div> 276 + <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 277 + {feature.title} 278 + </h3> 279 + <p className="text-muted-foreground leading-relaxed"> 280 + {feature.description} 281 + </p> 282 + </Card> 289 283 ))} 290 284 </div> 291 - </div> 292 - </section> 285 + </section> 293 286 294 - {/* Footer */} 295 - <footer className="border-t border-border/40 bg-muted/20"> 296 - <div className="container mx-auto px-4 py-8"> 297 - <div className="text-center text-sm text-muted-foreground"> 298 - <p> 299 - Built by{' '} 300 - <a 301 - href="https://bsky.app/profile/nekomimi.pet" 302 - target="_blank" 303 - rel="noopener noreferrer" 304 - className="text-accent hover:text-accent/80 transition-colors font-medium" 305 - > 306 - @nekomimi.pet 307 - </a> 287 + {/* CTA Section */} 288 + <section className="container mx-auto px-4 py-20"> 289 + <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 290 + <h2 className="text-3xl md:text-4xl font-bold mb-4"> 291 + Ready to deploy? 292 + </h2> 293 + <p className="text-xl text-muted-foreground mb-8"> 294 + Host your static site on your own AT Protocol 295 + account today 308 296 </p> 297 + <Button 298 + size="lg" 299 + className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6" 300 + onClick={() => setShowForm(true)} 301 + > 302 + Get Started 303 + <ArrowRight className="ml-2 w-5 h-5" /> 304 + </Button> 309 305 </div> 310 - </div> 311 - </footer> 312 - </div> 306 + </section> 307 + 308 + {/* Footer */} 309 + <footer className="border-t border-border/40 bg-muted/20"> 310 + <div className="container mx-auto px-4 py-8"> 311 + <div className="text-center text-sm text-muted-foreground"> 312 + <p> 313 + Built by{' '} 314 + <a 315 + href="https://bsky.app/profile/nekomimi.pet" 316 + target="_blank" 317 + rel="noopener noreferrer" 318 + className="text-accent hover:text-accent/80 transition-colors font-medium" 319 + > 320 + @nekomimi.pet 321 + </a> 322 + </p> 323 + </div> 324 + </div> 325 + </footer> 326 + </div> 327 + </> 313 328 ) 314 329 } 315 330
+10 -2
public/onboarding/onboarding.tsx
··· 75 75 setClaimedDomain(data.domain) 76 76 setStep('upload') 77 77 } else { 78 - alert('Failed to claim domain. Please try again.') 78 + throw new Error(data.error || 'Failed to claim domain') 79 79 } 80 80 } catch (err) { 81 81 console.error('Error claiming domain:', err) 82 - alert('Failed to claim domain. Please try again.') 82 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 83 + 84 + // Handle "Already claimed" error - redirect to editor 85 + if (errorMessage.includes('Already claimed')) { 86 + alert('You have already claimed a wisp.place subdomain. Redirecting to editor...') 87 + window.location.href = '/editor' 88 + } else { 89 + alert(`Failed to claim domain: ${errorMessage}`) 90 + } 83 91 } finally { 84 92 setIsClaimingDomain(false) 85 93 }
+32 -12
src/index.ts
··· 1 1 import { Elysia } from 'elysia' 2 2 import { cors } from '@elysiajs/cors' 3 + import { openapi, fromTypes } from '@elysiajs/openapi' 3 4 import { staticPlugin } from '@elysiajs/static' 4 5 5 6 import type { Config } from './lib/types' ··· 15 16 import { wispRoutes } from './routes/wisp' 16 17 import { domainRoutes } from './routes/domain' 17 18 import { userRoutes } from './routes/user' 19 + import { siteRoutes } from './routes/site' 18 20 import { csrfProtection } from './lib/csrf' 19 21 import { DNSVerificationWorker } from './lib/dns-verification-worker' 20 - import { logger } from './lib/logger' 22 + import { logger, logCollector, observabilityMiddleware } from './lib/observability' 23 + import { promptAdminSetup } from './lib/admin-auth' 24 + import { adminRoutes } from './routes/admin' 21 25 22 26 const config: Config = { 23 27 domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, 24 28 clientName: Bun.env.CLIENT_NAME ?? 'PDS-View' 25 29 } 26 30 31 + // Initialize admin setup (prompt if no admin exists) 32 + await promptAdminSetup() 33 + 27 34 const client = await getOAuthClient(config) 28 35 29 36 // Periodic maintenance: cleanup expired sessions and rotate keys ··· 40 47 // Schedule maintenance to run every hour 41 48 setInterval(runMaintenance, 60 * 60 * 1000) 42 49 43 - // Start DNS verification worker (runs every hour) 50 + // Start DNS verification worker (runs every 10 minutes) 44 51 const dnsVerifier = new DNSVerificationWorker( 45 - 60 * 60 * 1000, // 1 hour 52 + 10 * 60 * 1000, // 10 minutes 46 53 (msg, data) => { 47 - logger.info('[DNS Verifier]', msg, data || '') 54 + logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined) 48 55 } 49 56 ) 50 57 51 58 dnsVerifier.start() 52 - logger.info('[DNS Verifier] Started - checking custom domains every hour') 59 + logger.info('DNS Verifier Started - checking custom domains every 10 minutes') 53 60 54 61 export const app = new Elysia() 55 - // Security headers middleware 56 - .onAfterHandle(({ set }) => { 62 + .use(openapi({ 63 + references: fromTypes() 64 + })) 65 + // Observability middleware 66 + .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) 67 + .onAfterHandle((ctx) => { 68 + observabilityMiddleware('main-app').afterHandle(ctx) 69 + // Security headers middleware 70 + const { set } = ctx 57 71 // Prevent clickjacking attacks 58 72 set.headers['X-Frame-Options'] = 'DENY' 59 73 // Prevent MIME type sniffing ··· 77 91 set.headers['X-XSS-Protection'] = '1; mode=block' 78 92 set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' 79 93 }) 80 - .use( 81 - await staticPlugin({ 82 - prefix: '/' 83 - }) 84 - ) 94 + .onError(observabilityMiddleware('main-app').onError) 85 95 .use(csrfProtection()) 86 96 .use(authRoutes(client)) 87 97 .use(wispRoutes(client)) 88 98 .use(domainRoutes(client)) 89 99 .use(userRoutes(client)) 100 + .use(siteRoutes(client)) 101 + .use(adminRoutes()) 102 + .use( 103 + await staticPlugin({ 104 + prefix: '/' 105 + }) 106 + ) 90 107 .get('/client-metadata.json', (c) => { 91 108 return createClientMetadata(config) 92 109 }) ··· 109 126 timestamp: new Date().toISOString(), 110 127 dnsVerifier: dnsVerifierHealth 111 128 } 129 + }) 130 + .get('/api/admin/test', () => { 131 + return { message: 'Admin routes test works!' } 112 132 }) 113 133 .post('/api/admin/verify-dns', async () => { 114 134 try {
+208
src/lib/admin-auth.ts
··· 1 + // Admin authentication system 2 + import { db } from './db' 3 + import { randomBytes, createHash } from 'crypto' 4 + 5 + interface AdminUser { 6 + id: number 7 + username: string 8 + password_hash: string 9 + created_at: Date 10 + } 11 + 12 + interface AdminSession { 13 + sessionId: string 14 + username: string 15 + expiresAt: Date 16 + } 17 + 18 + // In-memory session storage 19 + const sessions = new Map<string, AdminSession>() 20 + const SESSION_DURATION = 24 * 60 * 60 * 1000 // 24 hours 21 + 22 + // Hash password using SHA-256 with salt 23 + function hashPassword(password: string, salt: string): string { 24 + return createHash('sha256').update(password + salt).digest('hex') 25 + } 26 + 27 + // Generate random salt 28 + function generateSalt(): string { 29 + return randomBytes(32).toString('hex') 30 + } 31 + 32 + // Generate session ID 33 + function generateSessionId(): string { 34 + return randomBytes(32).toString('hex') 35 + } 36 + 37 + // Generate a secure random password 38 + function generatePassword(length: number = 20): string { 39 + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' 40 + const bytes = randomBytes(length) 41 + let password = '' 42 + for (let i = 0; i < length; i++) { 43 + password += chars[bytes[i] % chars.length] 44 + } 45 + return password 46 + } 47 + 48 + export const adminAuth = { 49 + // Initialize admin table 50 + async init() { 51 + await db` 52 + CREATE TABLE IF NOT EXISTS admin_users ( 53 + id SERIAL PRIMARY KEY, 54 + username TEXT UNIQUE NOT NULL, 55 + password_hash TEXT NOT NULL, 56 + salt TEXT NOT NULL, 57 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 58 + ) 59 + ` 60 + }, 61 + 62 + // Check if any admin exists 63 + async hasAdmin(): Promise<boolean> { 64 + const result = await db`SELECT COUNT(*) as count FROM admin_users` 65 + return result[0].count > 0 66 + }, 67 + 68 + // Create admin user 69 + async createAdmin(username: string, password: string): Promise<boolean> { 70 + try { 71 + const salt = generateSalt() 72 + const passwordHash = hashPassword(password, salt) 73 + 74 + await db`INSERT INTO admin_users (username, password_hash, salt) VALUES (${username}, ${passwordHash}, ${salt})` 75 + 76 + console.log(`✓ Admin user '${username}' created successfully`) 77 + return true 78 + } catch (error) { 79 + console.error('Failed to create admin user:', error) 80 + return false 81 + } 82 + }, 83 + 84 + // Verify admin credentials 85 + async verify(username: string, password: string): Promise<boolean> { 86 + try { 87 + const result = await db`SELECT password_hash, salt FROM admin_users WHERE username = ${username}` 88 + 89 + if (result.length === 0) { 90 + return false 91 + } 92 + 93 + const { password_hash, salt } = result[0] 94 + const hash = hashPassword(password, salt as string) 95 + return hash === password_hash 96 + } catch (error) { 97 + console.error('Failed to verify admin:', error) 98 + return false 99 + } 100 + }, 101 + 102 + // Create session 103 + createSession(username: string): string { 104 + const sessionId = generateSessionId() 105 + const expiresAt = new Date(Date.now() + SESSION_DURATION) 106 + 107 + sessions.set(sessionId, { 108 + sessionId, 109 + username, 110 + expiresAt 111 + }) 112 + 113 + // Clean up expired sessions 114 + this.cleanupSessions() 115 + 116 + return sessionId 117 + }, 118 + 119 + // Verify session 120 + verifySession(sessionId: string): AdminSession | null { 121 + const session = sessions.get(sessionId) 122 + 123 + if (!session) { 124 + return null 125 + } 126 + 127 + if (session.expiresAt.getTime() < Date.now()) { 128 + sessions.delete(sessionId) 129 + return null 130 + } 131 + 132 + return session 133 + }, 134 + 135 + // Delete session 136 + deleteSession(sessionId: string) { 137 + sessions.delete(sessionId) 138 + }, 139 + 140 + // Cleanup expired sessions 141 + cleanupSessions() { 142 + const now = Date.now() 143 + for (const [sessionId, session] of sessions.entries()) { 144 + if (session.expiresAt.getTime() < now) { 145 + sessions.delete(sessionId) 146 + } 147 + } 148 + } 149 + } 150 + 151 + // Prompt for admin creation on startup 152 + export async function promptAdminSetup() { 153 + await adminAuth.init() 154 + 155 + const hasAdmin = await adminAuth.hasAdmin() 156 + if (hasAdmin) { 157 + return 158 + } 159 + 160 + // Skip prompt if SKIP_ADMIN_SETUP is set 161 + if (process.env.SKIP_ADMIN_SETUP === 'true') { 162 + console.log('\n╔════════════════════════════════════════════════════════════════╗') 163 + console.log('║ ADMIN SETUP REQUIRED ║') 164 + console.log('╚════════════════════════════════════════════════════════════════╝\n') 165 + console.log('No admin user found.') 166 + console.log('Create one with: bun run create-admin.ts\n') 167 + return 168 + } 169 + 170 + console.log('\n===========================================') 171 + console.log(' ADMIN SETUP REQUIRED') 172 + console.log('===========================================\n') 173 + console.log('No admin user found. Creating one automatically...\n') 174 + 175 + // Auto-generate admin credentials with random password 176 + const username = 'admin' 177 + const password = generatePassword(20) 178 + 179 + await adminAuth.createAdmin(username, password) 180 + 181 + console.log('╔════════════════════════════════════════════════════════════════╗') 182 + console.log('║ ADMIN USER CREATED SUCCESSFULLY ║') 183 + console.log('╚════════════════════════════════════════════════════════════════╝\n') 184 + console.log(`Username: ${username}`) 185 + console.log(`Password: ${password}`) 186 + console.log('\n⚠️ IMPORTANT: Save this password securely!') 187 + console.log('This password will not be shown again.\n') 188 + console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') 189 + } 190 + 191 + // Elysia middleware to protect admin routes 192 + export function requireAdmin({ cookie, set }: any) { 193 + const sessionId = cookie.admin_session?.value 194 + 195 + if (!sessionId) { 196 + set.status = 401 197 + return { error: 'Unauthorized' } 198 + } 199 + 200 + const session = adminAuth.verifySession(sessionId) 201 + if (!session) { 202 + set.status = 401 203 + return { error: 'Unauthorized' } 204 + } 205 + 206 + // Session is valid, continue 207 + return 208 + }
+10
src/lib/db.ts
··· 527 527 return { success: false, error: err }; 528 528 } 529 529 }; 530 + 531 + export const deleteSite = async (did: string, rkey: string) => { 532 + try { 533 + await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`; 534 + return { success: true }; 535 + } catch (err) { 536 + console.error('Failed to delete site', err); 537 + return { success: false, error: err }; 538 + } 539 + };
+337
src/lib/observability.ts
··· 1 + // DIY Observability - Logs, Metrics, and Error Tracking 2 + import type { Context } from 'elysia' 3 + 4 + // Types 5 + export interface LogEntry { 6 + id: string 7 + timestamp: Date 8 + level: 'info' | 'warn' | 'error' | 'debug' 9 + message: string 10 + service: string 11 + context?: Record<string, any> 12 + traceId?: string 13 + eventType?: string 14 + } 15 + 16 + export interface ErrorEntry { 17 + id: string 18 + timestamp: Date 19 + message: string 20 + stack?: string 21 + service: string 22 + context?: Record<string, any> 23 + count: number // How many times this error occurred 24 + lastSeen: Date 25 + } 26 + 27 + export interface MetricEntry { 28 + timestamp: Date 29 + path: string 30 + method: string 31 + statusCode: number 32 + duration: number // in milliseconds 33 + service: string 34 + } 35 + 36 + export interface DatabaseStats { 37 + totalSites: number 38 + totalDomains: number 39 + totalCustomDomains: number 40 + recentSites: any[] 41 + recentDomains: any[] 42 + } 43 + 44 + // In-memory storage with rotation 45 + const MAX_LOGS = 5000 46 + const MAX_ERRORS = 500 47 + const MAX_METRICS = 10000 48 + 49 + const logs: LogEntry[] = [] 50 + const errors: Map<string, ErrorEntry> = new Map() 51 + const metrics: MetricEntry[] = [] 52 + 53 + // Helper to generate unique IDs 54 + let logCounter = 0 55 + let errorCounter = 0 56 + 57 + function generateId(prefix: string, counter: number): string { 58 + return `${prefix}-${Date.now()}-${counter}` 59 + } 60 + 61 + // Helper to extract event type from message 62 + function extractEventType(message: string): string | undefined { 63 + const match = message.match(/^\[([^\]]+)\]/) 64 + return match ? match[1] : undefined 65 + } 66 + 67 + // Log collector 68 + export const logCollector = { 69 + log(level: LogEntry['level'], message: string, service: string, context?: Record<string, any>, traceId?: string) { 70 + const entry: LogEntry = { 71 + id: generateId('log', logCounter++), 72 + timestamp: new Date(), 73 + level, 74 + message, 75 + service, 76 + context, 77 + traceId, 78 + eventType: extractEventType(message) 79 + } 80 + 81 + logs.unshift(entry) 82 + 83 + // Rotate if needed 84 + if (logs.length > MAX_LOGS) { 85 + logs.splice(MAX_LOGS) 86 + } 87 + 88 + // Also log to console for compatibility 89 + const contextStr = context ? ` ${JSON.stringify(context)}` : '' 90 + const traceStr = traceId ? ` [trace:${traceId}]` : '' 91 + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) 92 + }, 93 + 94 + info(message: string, service: string, context?: Record<string, any>, traceId?: string) { 95 + this.log('info', message, service, context, traceId) 96 + }, 97 + 98 + warn(message: string, service: string, context?: Record<string, any>, traceId?: string) { 99 + this.log('warn', message, service, context, traceId) 100 + }, 101 + 102 + error(message: string, service: string, error?: any, context?: Record<string, any>, traceId?: string) { 103 + const ctx = { ...context } 104 + if (error instanceof Error) { 105 + ctx.error = error.message 106 + ctx.stack = error.stack 107 + } else if (error) { 108 + ctx.error = String(error) 109 + } 110 + this.log('error', message, service, ctx, traceId) 111 + 112 + // Also track in errors 113 + errorTracker.track(message, service, error, context) 114 + }, 115 + 116 + debug(message: string, service: string, context?: Record<string, any>, traceId?: string) { 117 + if (process.env.NODE_ENV !== 'production') { 118 + this.log('debug', message, service, context, traceId) 119 + } 120 + }, 121 + 122 + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { 123 + let filtered = [...logs] 124 + 125 + if (filter?.level) { 126 + filtered = filtered.filter(log => log.level === filter.level) 127 + } 128 + 129 + if (filter?.service) { 130 + filtered = filtered.filter(log => log.service === filter.service) 131 + } 132 + 133 + if (filter?.eventType) { 134 + filtered = filtered.filter(log => log.eventType === filter.eventType) 135 + } 136 + 137 + if (filter?.search) { 138 + const search = filter.search.toLowerCase() 139 + filtered = filtered.filter(log => 140 + log.message.toLowerCase().includes(search) || 141 + (log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false) 142 + ) 143 + } 144 + 145 + const limit = filter?.limit || 100 146 + return filtered.slice(0, limit) 147 + }, 148 + 149 + clear() { 150 + logs.length = 0 151 + } 152 + } 153 + 154 + // Error tracker with deduplication 155 + export const errorTracker = { 156 + track(message: string, service: string, error?: any, context?: Record<string, any>) { 157 + const key = `${service}:${message}` 158 + 159 + const existing = errors.get(key) 160 + if (existing) { 161 + existing.count++ 162 + existing.lastSeen = new Date() 163 + if (context) { 164 + existing.context = { ...existing.context, ...context } 165 + } 166 + } else { 167 + const entry: ErrorEntry = { 168 + id: generateId('error', errorCounter++), 169 + timestamp: new Date(), 170 + message, 171 + service, 172 + context, 173 + count: 1, 174 + lastSeen: new Date() 175 + } 176 + 177 + if (error instanceof Error) { 178 + entry.stack = error.stack 179 + } 180 + 181 + errors.set(key, entry) 182 + 183 + // Rotate if needed 184 + if (errors.size > MAX_ERRORS) { 185 + const oldest = Array.from(errors.keys())[0] 186 + errors.delete(oldest) 187 + } 188 + } 189 + }, 190 + 191 + getErrors(filter?: { service?: string; limit?: number }) { 192 + let filtered = Array.from(errors.values()) 193 + 194 + if (filter?.service) { 195 + filtered = filtered.filter(err => err.service === filter.service) 196 + } 197 + 198 + // Sort by last seen (most recent first) 199 + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) 200 + 201 + const limit = filter?.limit || 100 202 + return filtered.slice(0, limit) 203 + }, 204 + 205 + clear() { 206 + errors.clear() 207 + } 208 + } 209 + 210 + // Metrics collector 211 + export const metricsCollector = { 212 + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { 213 + const entry: MetricEntry = { 214 + timestamp: new Date(), 215 + path, 216 + method, 217 + statusCode, 218 + duration, 219 + service 220 + } 221 + 222 + metrics.unshift(entry) 223 + 224 + // Rotate if needed 225 + if (metrics.length > MAX_METRICS) { 226 + metrics.splice(MAX_METRICS) 227 + } 228 + }, 229 + 230 + getMetrics(filter?: { service?: string; timeWindow?: number }) { 231 + let filtered = [...metrics] 232 + 233 + if (filter?.service) { 234 + filtered = filtered.filter(m => m.service === filter.service) 235 + } 236 + 237 + if (filter?.timeWindow) { 238 + const cutoff = Date.now() - filter.timeWindow 239 + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) 240 + } 241 + 242 + return filtered 243 + }, 244 + 245 + getStats(service?: string, timeWindow: number = 3600000) { 246 + const filtered = this.getMetrics({ service, timeWindow }) 247 + 248 + if (filtered.length === 0) { 249 + return { 250 + totalRequests: 0, 251 + avgDuration: 0, 252 + p50Duration: 0, 253 + p95Duration: 0, 254 + p99Duration: 0, 255 + errorRate: 0, 256 + requestsPerMinute: 0 257 + } 258 + } 259 + 260 + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) 261 + const totalDuration = durations.reduce((sum, d) => sum + d, 0) 262 + const errors = filtered.filter(m => m.statusCode >= 400).length 263 + 264 + const p50 = durations[Math.floor(durations.length * 0.5)] 265 + const p95 = durations[Math.floor(durations.length * 0.95)] 266 + const p99 = durations[Math.floor(durations.length * 0.99)] 267 + 268 + const timeWindowMinutes = timeWindow / 60000 269 + 270 + return { 271 + totalRequests: filtered.length, 272 + avgDuration: Math.round(totalDuration / filtered.length), 273 + p50Duration: Math.round(p50), 274 + p95Duration: Math.round(p95), 275 + p99Duration: Math.round(p99), 276 + errorRate: (errors / filtered.length) * 100, 277 + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) 278 + } 279 + }, 280 + 281 + clear() { 282 + metrics.length = 0 283 + } 284 + } 285 + 286 + // Elysia middleware for request timing 287 + export function observabilityMiddleware(service: string) { 288 + return { 289 + beforeHandle: ({ request }: any) => { 290 + // Store start time on request object 291 + (request as any).__startTime = Date.now() 292 + }, 293 + afterHandle: ({ request, set }: any) => { 294 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 295 + const url = new URL(request.url) 296 + 297 + metricsCollector.recordRequest( 298 + url.pathname, 299 + request.method, 300 + set.status || 200, 301 + duration, 302 + service 303 + ) 304 + }, 305 + onError: ({ request, error, set }: any) => { 306 + const duration = Date.now() - ((request as any).__startTime || Date.now()) 307 + const url = new URL(request.url) 308 + 309 + metricsCollector.recordRequest( 310 + url.pathname, 311 + request.method, 312 + set.status || 500, 313 + duration, 314 + service 315 + ) 316 + 317 + logCollector.error( 318 + `Request failed: ${request.method} ${url.pathname}`, 319 + service, 320 + error, 321 + { statusCode: set.status || 500 } 322 + ) 323 + } 324 + } 325 + } 326 + 327 + // Export singleton logger for easy access 328 + export const logger = { 329 + info: (message: string, context?: Record<string, any>) => 330 + logCollector.info(message, 'main-app', context), 331 + warn: (message: string, context?: Record<string, any>) => 332 + logCollector.warn(message, 'main-app', context), 333 + error: (message: string, error?: any, context?: Record<string, any>) => 334 + logCollector.error(message, 'main-app', error, context), 335 + debug: (message: string, context?: Record<string, any>) => 336 + logCollector.debug(message, 'main-app', context) 337 + }
+305
src/routes/admin.ts
··· 1 + // Admin API routes 2 + import { Elysia, t } from 'elysia' 3 + import { adminAuth, requireAdmin } from '../lib/admin-auth' 4 + import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 + import { db } from '../lib/db' 6 + 7 + export const adminRoutes = () => 8 + new Elysia({ prefix: '/api/admin' }) 9 + // Login 10 + .post( 11 + '/login', 12 + async ({ body, cookie, set }) => { 13 + const { username, password } = body 14 + 15 + const valid = await adminAuth.verify(username, password) 16 + if (!valid) { 17 + set.status = 401 18 + return { error: 'Invalid credentials' } 19 + } 20 + 21 + const sessionId = adminAuth.createSession(username) 22 + 23 + // Set cookie 24 + cookie.admin_session.set({ 25 + value: sessionId, 26 + httpOnly: true, 27 + secure: process.env.NODE_ENV === 'production', 28 + sameSite: 'lax', 29 + maxAge: 24 * 60 * 60 // 24 hours 30 + }) 31 + 32 + return { success: true } 33 + }, 34 + { 35 + body: t.Object({ 36 + username: t.String(), 37 + password: t.String() 38 + }) 39 + } 40 + ) 41 + 42 + // Logout 43 + .post('/logout', ({ cookie }) => { 44 + const sessionId = cookie.admin_session?.value 45 + if (sessionId && typeof sessionId === 'string') { 46 + adminAuth.deleteSession(sessionId) 47 + } 48 + cookie.admin_session.remove() 49 + return { success: true } 50 + }) 51 + 52 + // Check auth status 53 + .get('/status', ({ cookie }) => { 54 + const sessionId = cookie.admin_session?.value 55 + if (!sessionId || typeof sessionId !== 'string') { 56 + return { authenticated: false } 57 + } 58 + 59 + const session = adminAuth.verifySession(sessionId) 60 + if (!session) { 61 + return { authenticated: false } 62 + } 63 + 64 + return { 65 + authenticated: true, 66 + username: session.username 67 + } 68 + }) 69 + 70 + // Get logs (protected) 71 + .get('/logs', async ({ query, cookie, set }) => { 72 + const check = requireAdmin({ cookie, set }) 73 + if (check) return check 74 + 75 + const filter: any = {} 76 + 77 + if (query.level) filter.level = query.level 78 + if (query.service) filter.service = query.service 79 + if (query.search) filter.search = query.search 80 + if (query.eventType) filter.eventType = query.eventType 81 + if (query.limit) filter.limit = parseInt(query.limit as string) 82 + 83 + // Get logs from main app 84 + const mainLogs = logCollector.getLogs(filter) 85 + 86 + // Get logs from hosting service 87 + let hostingLogs: any[] = [] 88 + try { 89 + const hostingPort = process.env.HOSTING_PORT || '3001' 90 + const params = new URLSearchParams() 91 + if (query.level) params.append('level', query.level as string) 92 + if (query.service) params.append('service', query.service as string) 93 + if (query.search) params.append('search', query.search as string) 94 + if (query.eventType) params.append('eventType', query.eventType as string) 95 + params.append('limit', String(filter.limit || 100)) 96 + 97 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`) 98 + if (response.ok) { 99 + const data = await response.json() 100 + hostingLogs = data.logs 101 + } 102 + } catch (err) { 103 + // Hosting service might not be running 104 + } 105 + 106 + // Merge and sort by timestamp 107 + const allLogs = [...mainLogs, ...hostingLogs].sort((a, b) => 108 + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() 109 + ) 110 + 111 + return { logs: allLogs.slice(0, filter.limit || 100) } 112 + }) 113 + 114 + // Get errors (protected) 115 + .get('/errors', async ({ query, cookie, set }) => { 116 + const check = requireAdmin({ cookie, set }) 117 + if (check) return check 118 + 119 + const filter: any = {} 120 + 121 + if (query.service) filter.service = query.service 122 + if (query.limit) filter.limit = parseInt(query.limit as string) 123 + 124 + // Get errors from main app 125 + const mainErrors = errorTracker.getErrors(filter) 126 + 127 + // Get errors from hosting service 128 + let hostingErrors: any[] = [] 129 + try { 130 + const hostingPort = process.env.HOSTING_PORT || '3001' 131 + const params = new URLSearchParams() 132 + if (query.service) params.append('service', query.service as string) 133 + params.append('limit', String(filter.limit || 100)) 134 + 135 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`) 136 + if (response.ok) { 137 + const data = await response.json() 138 + hostingErrors = data.errors 139 + } 140 + } catch (err) { 141 + // Hosting service might not be running 142 + } 143 + 144 + // Merge and sort by last seen 145 + const allErrors = [...mainErrors, ...hostingErrors].sort((a, b) => 146 + new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime() 147 + ) 148 + 149 + return { errors: allErrors.slice(0, filter.limit || 100) } 150 + }) 151 + 152 + // Get metrics (protected) 153 + .get('/metrics', async ({ query, cookie, set }) => { 154 + const check = requireAdmin({ cookie, set }) 155 + if (check) return check 156 + 157 + const timeWindow = query.timeWindow 158 + ? parseInt(query.timeWindow as string) 159 + : 3600000 // 1 hour default 160 + 161 + const mainAppStats = metricsCollector.getStats('main-app', timeWindow) 162 + const overallStats = metricsCollector.getStats(undefined, timeWindow) 163 + 164 + // Get hosting service stats from its own endpoint 165 + let hostingServiceStats = { 166 + totalRequests: 0, 167 + avgDuration: 0, 168 + p50Duration: 0, 169 + p95Duration: 0, 170 + p99Duration: 0, 171 + errorRate: 0, 172 + requestsPerMinute: 0 173 + } 174 + 175 + try { 176 + const hostingPort = process.env.HOSTING_PORT || '3001' 177 + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 178 + if (response.ok) { 179 + const data = await response.json() 180 + hostingServiceStats = data.stats 181 + } 182 + } catch (err) { 183 + // Hosting service might not be running 184 + } 185 + 186 + return { 187 + overall: overallStats, 188 + mainApp: mainAppStats, 189 + hostingService: hostingServiceStats, 190 + timeWindow 191 + } 192 + }) 193 + 194 + // Get database stats (protected) 195 + .get('/database', async ({ cookie, set }) => { 196 + const check = requireAdmin({ cookie, set }) 197 + if (check) return check 198 + 199 + try { 200 + // Get total counts 201 + const allSitesResult = await db`SELECT COUNT(*) as count FROM sites` 202 + const wispSubdomainsResult = await db`SELECT COUNT(*) as count FROM domains WHERE domain LIKE '%.wisp.place'` 203 + const customDomainsResult = await db`SELECT COUNT(*) as count FROM custom_domains WHERE verified = true` 204 + 205 + // Get recent sites (including those without domains) 206 + const recentSites = await db` 207 + SELECT 208 + s.did, 209 + s.rkey, 210 + s.display_name, 211 + s.created_at, 212 + d.domain as subdomain 213 + FROM sites s 214 + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' 215 + ORDER BY s.created_at DESC 216 + LIMIT 10 217 + ` 218 + 219 + // Get recent domains 220 + const recentDomains = await db`SELECT domain, did, rkey, verified, created_at FROM custom_domains ORDER BY created_at DESC LIMIT 10` 221 + 222 + return { 223 + stats: { 224 + totalSites: allSitesResult[0].count, 225 + totalWispSubdomains: wispSubdomainsResult[0].count, 226 + totalCustomDomains: customDomainsResult[0].count 227 + }, 228 + recentSites: recentSites, 229 + recentDomains: recentDomains 230 + } 231 + } catch (error) { 232 + set.status = 500 233 + return { 234 + error: 'Failed to fetch database stats', 235 + message: error instanceof Error ? error.message : String(error) 236 + } 237 + } 238 + }) 239 + 240 + // Get sites listing (protected) 241 + .get('/sites', async ({ query, cookie, set }) => { 242 + const check = requireAdmin({ cookie, set }) 243 + if (check) return check 244 + 245 + const limit = query.limit ? parseInt(query.limit as string) : 50 246 + const offset = query.offset ? parseInt(query.offset as string) : 0 247 + 248 + try { 249 + const sites = await db` 250 + SELECT 251 + s.did, 252 + s.rkey, 253 + s.display_name, 254 + s.created_at, 255 + d.domain as subdomain 256 + FROM sites s 257 + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' 258 + ORDER BY s.created_at DESC 259 + LIMIT ${limit} OFFSET ${offset} 260 + ` 261 + 262 + const customDomains = await db` 263 + SELECT 264 + domain, 265 + did, 266 + rkey, 267 + verified, 268 + created_at 269 + FROM custom_domains 270 + ORDER BY created_at DESC 271 + LIMIT ${limit} OFFSET ${offset} 272 + ` 273 + 274 + return { 275 + sites: sites, 276 + customDomains: customDomains 277 + } 278 + } catch (error) { 279 + set.status = 500 280 + return { 281 + error: 'Failed to fetch sites', 282 + message: error instanceof Error ? error.message : String(error) 283 + } 284 + } 285 + }) 286 + 287 + // Get system health (protected) 288 + .get('/health', ({ cookie, set }) => { 289 + const check = requireAdmin({ cookie, set }) 290 + if (check) return check 291 + 292 + const uptime = process.uptime() 293 + const memory = process.memoryUsage() 294 + 295 + return { 296 + uptime: Math.floor(uptime), 297 + memory: { 298 + heapUsed: Math.round(memory.heapUsed / 1024 / 1024), // MB 299 + heapTotal: Math.round(memory.heapTotal / 1024 / 1024), // MB 300 + rss: Math.round(memory.rss / 1024 / 1024) // MB 301 + }, 302 + timestamp: new Date().toISOString() 303 + } 304 + }) 305 +
+9 -4
src/routes/auth.ts
··· 3 3 import { getSitesByDid, getDomainByDid } from '../lib/db' 4 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 5 import { authenticateRequest } from '../lib/wisp-auth' 6 - import { logger } from '../lib/logger' 6 + import { logger } from '../lib/observability' 7 7 8 8 export const authRoutes = (client: NodeOAuthClient) => new Elysia() 9 9 .post('/api/auth/signin', async (c) => { 10 + let handle = 'unknown' 10 11 try { 11 - const { handle } = await c.request.json() 12 + const body = c.body as { handle: string } 13 + handle = body.handle 14 + logger.info('Sign-in attempt', { handle }) 12 15 const state = crypto.randomUUID() 13 16 const url = await client.authorize(handle, { state }) 17 + logger.info('Authorization URL generated', { handle }) 14 18 return { url: url.toString() } 15 19 } catch (err) { 16 - logger.error('[Auth] Signin error', err) 17 - return { error: 'Authentication failed' } 20 + logger.error('Signin error', err, { handle }) 21 + console.error('[Auth] Full error:', err) 22 + return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) } 18 23 } 19 24 }) 20 25 .get('/api/auth/callback', async (c) => {
+60
src/routes/site.ts
··· 1 + import { Elysia } from 'elysia' 2 + import { requireAuth } from '../lib/wisp-auth' 3 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 + import { Agent } from '@atproto/api' 5 + import { deleteSite } from '../lib/db' 6 + import { logger } from '../lib/logger' 7 + 8 + export const siteRoutes = (client: NodeOAuthClient) => 9 + new Elysia({ prefix: '/api/site' }) 10 + .derive(async ({ cookie }) => { 11 + const auth = await requireAuth(client, cookie) 12 + return { auth } 13 + }) 14 + .delete('/:rkey', async ({ params, auth }) => { 15 + const { rkey } = params 16 + 17 + if (!rkey) { 18 + return { 19 + success: false, 20 + error: 'Site rkey is required' 21 + } 22 + } 23 + 24 + try { 25 + // Create agent with OAuth session 26 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 27 + 28 + // Delete the record from AT Protocol 29 + try { 30 + await agent.com.atproto.repo.deleteRecord({ 31 + repo: auth.did, 32 + collection: 'place.wisp.fs', 33 + rkey: rkey 34 + }) 35 + logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`) 36 + } catch (err) { 37 + logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err) 38 + throw new Error('Failed to delete site from AT Protocol') 39 + } 40 + 41 + // Delete from database 42 + const result = await deleteSite(auth.did, rkey) 43 + if (!result.success) { 44 + throw new Error('Failed to delete site from database') 45 + } 46 + 47 + logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`) 48 + 49 + return { 50 + success: true, 51 + message: 'Site deleted successfully' 52 + } 53 + } catch (err) { 54 + logger.error('[Site] Delete error', err) 55 + return { 56 + success: false, 57 + error: err instanceof Error ? err.message : 'Failed to delete site' 58 + } 59 + } 60 + })
+28 -74
src/routes/wisp.ts
··· 12 12 compressFile 13 13 } from '../lib/wisp-utils' 14 14 import { upsertSite } from '../lib/db' 15 - import { logger } from '../lib/logger' 15 + import { logger } from '../lib/observability' 16 16 import { validateRecord } from '../lexicon/types/place/wisp/fs' 17 + import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants' 17 18 18 19 function isValidSiteName(siteName: string): boolean { 19 20 if (!siteName || typeof siteName !== 'string') return false; ··· 112 113 const uploadedFiles: UploadedFile[] = []; 113 114 const skippedFiles: Array<{ name: string; reason: string }> = []; 114 115 115 - // Define allowed file extensions for static site hosting 116 - const allowedExtensions = new Set([ 117 - // HTML 118 - '.html', '.htm', 119 - // CSS 120 - '.css', 121 - // JavaScript 122 - '.js', '.mjs', '.jsx', '.ts', '.tsx', 123 - // Images 124 - '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 125 - // Fonts 126 - '.woff', '.woff2', '.ttf', '.otf', '.eot', 127 - // Documents 128 - '.pdf', '.txt', 129 - // JSON (for config files, but not .map files) 130 - '.json', 131 - // Audio/Video 132 - '.mp3', '.mp4', '.webm', '.ogg', '.wav', 133 - // Other web assets 134 - '.xml', '.rss', '.atom' 135 - ]); 136 116 137 - // Files to explicitly exclude 138 - const excludedFiles = new Set([ 139 - '.map', '.DS_Store', 'Thumbs.db' 140 - ]); 141 117 142 118 for (let i = 0; i < fileArray.length; i++) { 143 119 const file = fileArray[i]; 144 - const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 145 - 146 - // Skip excluded files 147 - if (excludedFiles.has(fileExtension)) { 148 - skippedFiles.push({ name: file.name, reason: 'excluded file type' }); 149 - continue; 150 - } 151 - 152 - // Skip files that aren't in allowed extensions 153 - if (!allowedExtensions.has(fileExtension)) { 154 - skippedFiles.push({ name: file.name, reason: 'unsupported file type' }); 155 - continue; 156 - } 157 120 158 121 // Skip files that are too large (limit to 100MB per file) 159 - const maxSize = 100 * 1024 * 1024; // 100MB 122 + const maxSize = MAX_FILE_SIZE; // 100MB 160 123 if (file.size > maxSize) { 161 124 skippedFiles.push({ 162 125 name: file.name, ··· 169 132 const originalContent = Buffer.from(arrayBuffer); 170 133 const originalMimeType = file.type || 'application/octet-stream'; 171 134 172 - // Determine if we should compress this file 173 - const shouldCompress = shouldCompressFile(originalMimeType); 174 - 175 - if (shouldCompress) { 176 - const compressedContent = compressFile(originalContent); 177 - // Base64 encode the gzipped content to prevent PDS content sniffing 178 - const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 179 - const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 180 - logger.info(`[Wisp] Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 135 + // Compress and base64 encode ALL files 136 + const compressedContent = compressFile(originalContent); 137 + // Base64 encode the gzipped content to prevent PDS content sniffing 138 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); 139 + const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 140 + logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 181 141 182 - uploadedFiles.push({ 183 - name: file.name, 184 - content: base64Content, 185 - mimeType: originalMimeType, 186 - size: base64Content.length, 187 - compressed: true, 188 - originalMimeType 189 - }); 190 - } else { 191 - uploadedFiles.push({ 192 - name: file.name, 193 - content: originalContent, 194 - mimeType: originalMimeType, 195 - size: file.size, 196 - compressed: false 197 - }); 198 - } 142 + uploadedFiles.push({ 143 + name: file.name, 144 + content: base64Content, 145 + mimeType: originalMimeType, 146 + size: base64Content.length, 147 + compressed: true, 148 + originalMimeType 149 + }); 199 150 } 200 151 201 152 // Check total size limit (300MB) 202 153 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 203 - const maxTotalSize = 300 * 1024 * 1024; // 300MB 154 + const maxTotalSize = MAX_SITE_SIZE; // 300MB 204 155 205 156 if (totalSize > maxTotalSize) { 206 157 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 158 + } 159 + 160 + // Check file count limit (2000 files) 161 + if (uploadedFiles.length > MAX_FILE_COUNT) { 162 + throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`); 207 163 } 208 164 209 165 if (uploadedFiles.length === 0) { ··· 264 220 : file.mimeType; 265 221 266 222 const compressionInfo = file.compressed ? ' (gzipped)' : ''; 267 - logger.info(`[Wisp] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 223 + logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); 268 224 269 225 const uploadResult = await agent.com.atproto.repo.uploadBlob( 270 226 file.content, ··· 291 247 returnedMimeType: returnedBlobRef.mimeType 292 248 }; 293 249 } catch (uploadError) { 294 - logger.error('[Wisp] Upload failed for file', uploadError); 250 + logger.error('Upload failed for file', uploadError); 295 251 throw uploadError; 296 252 } 297 253 }); ··· 321 277 record: manifest 322 278 }); 323 279 } catch (putRecordError: any) { 324 - logger.error('[Wisp] Failed to create record on PDS'); 325 - logger.error('[Wisp] Record creation error', putRecordError); 280 + logger.error('Failed to create record on PDS', putRecordError); 326 281 327 282 throw putRecordError; 328 283 } ··· 342 297 343 298 return result; 344 299 } catch (error) { 345 - logger.error('[Wisp] Upload error', error); 346 - logger.errorWithContext('[Wisp] Upload error details', { 300 + logger.error('Upload error', error, { 347 301 message: error instanceof Error ? error.message : 'Unknown error', 348 302 name: error instanceof Error ? error.name : undefined 349 - }, error); 303 + }); 350 304 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 351 305 } 352 306 }