+149
bun.lock
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+820
public/admin/admin.tsx
···
1
+
import { StrictMode, useState, useEffect } from 'react'
2
+
import { createRoot } from 'react-dom/client'
3
+
import './styles.css'
4
+
5
+
// Types
6
+
interface LogEntry {
7
+
id: string
8
+
timestamp: string
9
+
level: 'info' | 'warn' | 'error' | 'debug'
10
+
message: string
11
+
service: string
12
+
context?: Record<string, any>
13
+
eventType?: string
14
+
}
15
+
16
+
interface ErrorEntry {
17
+
id: string
18
+
timestamp: string
19
+
message: string
20
+
stack?: string
21
+
service: string
22
+
count: number
23
+
lastSeen: string
24
+
}
25
+
26
+
interface MetricsStats {
27
+
totalRequests: number
28
+
avgDuration: number
29
+
p50Duration: number
30
+
p95Duration: number
31
+
p99Duration: number
32
+
errorRate: number
33
+
requestsPerMinute: number
34
+
}
35
+
36
+
// Helper function to format Unix timestamp from database
37
+
function formatDbDate(timestamp: number | string): Date {
38
+
const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp
39
+
return new Date(num * 1000) // Convert seconds to milliseconds
40
+
}
41
+
42
+
// Login Component
43
+
function Login({ onLogin }: { onLogin: () => void }) {
44
+
const [username, setUsername] = useState('')
45
+
const [password, setPassword] = useState('')
46
+
const [error, setError] = useState('')
47
+
const [loading, setLoading] = useState(false)
48
+
49
+
const handleSubmit = async (e: React.FormEvent) => {
50
+
e.preventDefault()
51
+
setError('')
52
+
setLoading(true)
53
+
54
+
try {
55
+
const res = await fetch('/api/admin/login', {
56
+
method: 'POST',
57
+
headers: { 'Content-Type': 'application/json' },
58
+
body: JSON.stringify({ username, password }),
59
+
credentials: 'include'
60
+
})
61
+
62
+
if (res.ok) {
63
+
onLogin()
64
+
} else {
65
+
setError('Invalid credentials')
66
+
}
67
+
} catch (err) {
68
+
setError('Failed to login')
69
+
} finally {
70
+
setLoading(false)
71
+
}
72
+
}
73
+
74
+
return (
75
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
76
+
<div className="w-full max-w-md">
77
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl">
78
+
<h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1>
79
+
<form onSubmit={handleSubmit} className="space-y-4">
80
+
<div>
81
+
<label className="block text-sm font-medium text-gray-300 mb-2">
82
+
Username
83
+
</label>
84
+
<input
85
+
type="text"
86
+
value={username}
87
+
onChange={(e) => setUsername(e.target.value)}
88
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
89
+
required
90
+
/>
91
+
</div>
92
+
<div>
93
+
<label className="block text-sm font-medium text-gray-300 mb-2">
94
+
Password
95
+
</label>
96
+
<input
97
+
type="password"
98
+
value={password}
99
+
onChange={(e) => setPassword(e.target.value)}
100
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
101
+
required
102
+
/>
103
+
</div>
104
+
{error && (
105
+
<div className="text-red-400 text-sm">{error}</div>
106
+
)}
107
+
<button
108
+
type="submit"
109
+
disabled={loading}
110
+
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium py-2 px-4 rounded transition-colors"
111
+
>
112
+
{loading ? 'Logging in...' : 'Login'}
113
+
</button>
114
+
</form>
115
+
</div>
116
+
</div>
117
+
</div>
118
+
)
119
+
}
120
+
121
+
// Dashboard Component
122
+
function Dashboard() {
123
+
const [tab, setTab] = useState('overview')
124
+
const [logs, setLogs] = useState<LogEntry[]>([])
125
+
const [errors, setErrors] = useState<ErrorEntry[]>([])
126
+
const [metrics, setMetrics] = useState<any>(null)
127
+
const [database, setDatabase] = useState<any>(null)
128
+
const [sites, setSites] = useState<any>(null)
129
+
const [health, setHealth] = useState<any>(null)
130
+
const [autoRefresh, setAutoRefresh] = useState(true)
131
+
132
+
// Filters
133
+
const [logLevel, setLogLevel] = useState('')
134
+
const [logService, setLogService] = useState('')
135
+
const [logSearch, setLogSearch] = useState('')
136
+
const [logEventType, setLogEventType] = useState('')
137
+
138
+
const fetchLogs = async () => {
139
+
const params = new URLSearchParams()
140
+
if (logLevel) params.append('level', logLevel)
141
+
if (logService) params.append('service', logService)
142
+
if (logSearch) params.append('search', logSearch)
143
+
if (logEventType) params.append('eventType', logEventType)
144
+
params.append('limit', '100')
145
+
146
+
const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' })
147
+
if (res.ok) {
148
+
const data = await res.json()
149
+
setLogs(data.logs)
150
+
}
151
+
}
152
+
153
+
const fetchErrors = async () => {
154
+
const res = await fetch('/api/admin/errors', { credentials: 'include' })
155
+
if (res.ok) {
156
+
const data = await res.json()
157
+
setErrors(data.errors)
158
+
}
159
+
}
160
+
161
+
const fetchMetrics = async () => {
162
+
const res = await fetch('/api/admin/metrics', { credentials: 'include' })
163
+
if (res.ok) {
164
+
const data = await res.json()
165
+
setMetrics(data)
166
+
}
167
+
}
168
+
169
+
const fetchDatabase = async () => {
170
+
const res = await fetch('/api/admin/database', { credentials: 'include' })
171
+
if (res.ok) {
172
+
const data = await res.json()
173
+
setDatabase(data)
174
+
}
175
+
}
176
+
177
+
const fetchSites = async () => {
178
+
const res = await fetch('/api/admin/sites', { credentials: 'include' })
179
+
if (res.ok) {
180
+
const data = await res.json()
181
+
setSites(data)
182
+
}
183
+
}
184
+
185
+
const fetchHealth = async () => {
186
+
const res = await fetch('/api/admin/health', { credentials: 'include' })
187
+
if (res.ok) {
188
+
const data = await res.json()
189
+
setHealth(data)
190
+
}
191
+
}
192
+
193
+
const logout = async () => {
194
+
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
195
+
window.location.reload()
196
+
}
197
+
198
+
useEffect(() => {
199
+
fetchMetrics()
200
+
fetchDatabase()
201
+
fetchHealth()
202
+
fetchLogs()
203
+
fetchErrors()
204
+
fetchSites()
205
+
}, [])
206
+
207
+
useEffect(() => {
208
+
fetchLogs()
209
+
}, [logLevel, logService, logSearch])
210
+
211
+
useEffect(() => {
212
+
if (!autoRefresh) return
213
+
214
+
const interval = setInterval(() => {
215
+
if (tab === 'overview') {
216
+
fetchMetrics()
217
+
fetchHealth()
218
+
} else if (tab === 'logs') {
219
+
fetchLogs()
220
+
} else if (tab === 'errors') {
221
+
fetchErrors()
222
+
} else if (tab === 'database') {
223
+
fetchDatabase()
224
+
} else if (tab === 'sites') {
225
+
fetchSites()
226
+
}
227
+
}, 5000)
228
+
229
+
return () => clearInterval(interval)
230
+
}, [tab, autoRefresh, logLevel, logService, logSearch])
231
+
232
+
const formatDuration = (ms: number) => {
233
+
if (ms < 1000) return `${ms}ms`
234
+
return `${(ms / 1000).toFixed(2)}s`
235
+
}
236
+
237
+
const formatUptime = (seconds: number) => {
238
+
const hours = Math.floor(seconds / 3600)
239
+
const minutes = Math.floor((seconds % 3600) / 60)
240
+
return `${hours}h ${minutes}m`
241
+
}
242
+
243
+
return (
244
+
<div className="min-h-screen bg-gray-950 text-white">
245
+
{/* Header */}
246
+
<div className="bg-gray-900 border-b border-gray-800 px-6 py-4">
247
+
<div className="flex items-center justify-between">
248
+
<h1 className="text-2xl font-bold">Wisp.place Admin</h1>
249
+
<div className="flex items-center gap-4">
250
+
<label className="flex items-center gap-2 text-sm text-gray-400">
251
+
<input
252
+
type="checkbox"
253
+
checked={autoRefresh}
254
+
onChange={(e) => setAutoRefresh(e.target.checked)}
255
+
className="rounded"
256
+
/>
257
+
Auto-refresh
258
+
</label>
259
+
<button
260
+
onClick={logout}
261
+
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm"
262
+
>
263
+
Logout
264
+
</button>
265
+
</div>
266
+
</div>
267
+
</div>
268
+
269
+
{/* Tabs */}
270
+
<div className="bg-gray-900 border-b border-gray-800 px-6">
271
+
<div className="flex gap-1">
272
+
{['overview', 'logs', 'errors', 'database', 'sites'].map((t) => (
273
+
<button
274
+
key={t}
275
+
onClick={() => setTab(t)}
276
+
className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${
277
+
tab === t
278
+
? 'text-white border-b-2 border-blue-500'
279
+
: 'text-gray-400 hover:text-white'
280
+
}`}
281
+
>
282
+
{t}
283
+
</button>
284
+
))}
285
+
</div>
286
+
</div>
287
+
288
+
{/* Content */}
289
+
<div className="p-6">
290
+
{tab === 'overview' && (
291
+
<div className="space-y-6">
292
+
{/* Health */}
293
+
{health && (
294
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
295
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
296
+
<div className="text-sm text-gray-400 mb-1">Uptime</div>
297
+
<div className="text-2xl font-bold">{formatUptime(health.uptime)}</div>
298
+
</div>
299
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
300
+
<div className="text-sm text-gray-400 mb-1">Memory Used</div>
301
+
<div className="text-2xl font-bold">{health.memory.heapUsed} MB</div>
302
+
</div>
303
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
304
+
<div className="text-sm text-gray-400 mb-1">RSS</div>
305
+
<div className="text-2xl font-bold">{health.memory.rss} MB</div>
306
+
</div>
307
+
</div>
308
+
)}
309
+
310
+
{/* Metrics */}
311
+
{metrics && (
312
+
<div>
313
+
<h2 className="text-xl font-bold mb-4">Performance Metrics</h2>
314
+
<div className="space-y-4">
315
+
{/* Overall */}
316
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
317
+
<h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3>
318
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
319
+
<div>
320
+
<div className="text-sm text-gray-400">Total Requests</div>
321
+
<div className="text-xl font-bold">{metrics.overall.totalRequests}</div>
322
+
</div>
323
+
<div>
324
+
<div className="text-sm text-gray-400">Avg Duration</div>
325
+
<div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div>
326
+
</div>
327
+
<div>
328
+
<div className="text-sm text-gray-400">P95 Duration</div>
329
+
<div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div>
330
+
</div>
331
+
<div>
332
+
<div className="text-sm text-gray-400">Error Rate</div>
333
+
<div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div>
334
+
</div>
335
+
</div>
336
+
</div>
337
+
338
+
{/* Main App */}
339
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
340
+
<h3 className="text-lg font-semibold mb-3">Main App</h3>
341
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
342
+
<div>
343
+
<div className="text-sm text-gray-400">Requests</div>
344
+
<div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div>
345
+
</div>
346
+
<div>
347
+
<div className="text-sm text-gray-400">Avg</div>
348
+
<div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div>
349
+
</div>
350
+
<div>
351
+
<div className="text-sm text-gray-400">P95</div>
352
+
<div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div>
353
+
</div>
354
+
<div>
355
+
<div className="text-sm text-gray-400">Req/min</div>
356
+
<div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div>
357
+
</div>
358
+
</div>
359
+
</div>
360
+
361
+
{/* Hosting Service */}
362
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
363
+
<h3 className="text-lg font-semibold mb-3">Hosting Service</h3>
364
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
365
+
<div>
366
+
<div className="text-sm text-gray-400">Requests</div>
367
+
<div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div>
368
+
</div>
369
+
<div>
370
+
<div className="text-sm text-gray-400">Avg</div>
371
+
<div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div>
372
+
</div>
373
+
<div>
374
+
<div className="text-sm text-gray-400">P95</div>
375
+
<div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div>
376
+
</div>
377
+
<div>
378
+
<div className="text-sm text-gray-400">Req/min</div>
379
+
<div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div>
380
+
</div>
381
+
</div>
382
+
</div>
383
+
</div>
384
+
</div>
385
+
)}
386
+
</div>
387
+
)}
388
+
389
+
{tab === 'logs' && (
390
+
<div className="space-y-4">
391
+
<div className="flex gap-4">
392
+
<select
393
+
value={logLevel}
394
+
onChange={(e) => setLogLevel(e.target.value)}
395
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
396
+
>
397
+
<option value="">All Levels</option>
398
+
<option value="info">Info</option>
399
+
<option value="warn">Warn</option>
400
+
<option value="error">Error</option>
401
+
<option value="debug">Debug</option>
402
+
</select>
403
+
<select
404
+
value={logService}
405
+
onChange={(e) => setLogService(e.target.value)}
406
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
407
+
>
408
+
<option value="">All Services</option>
409
+
<option value="main-app">Main App</option>
410
+
<option value="hosting-service">Hosting Service</option>
411
+
</select>
412
+
<select
413
+
value={logEventType}
414
+
onChange={(e) => setLogEventType(e.target.value)}
415
+
className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
416
+
>
417
+
<option value="">All Event Types</option>
418
+
<option value="DNS Verifier">DNS Verifier</option>
419
+
<option value="Auth">Auth</option>
420
+
<option value="User">User</option>
421
+
<option value="Domain">Domain</option>
422
+
<option value="Site">Site</option>
423
+
<option value="File Upload">File Upload</option>
424
+
<option value="Sync">Sync</option>
425
+
<option value="Maintenance">Maintenance</option>
426
+
<option value="KeyRotation">Key Rotation</option>
427
+
<option value="Cleanup">Cleanup</option>
428
+
<option value="Cache">Cache</option>
429
+
<option value="FirehoseWorker">Firehose Worker</option>
430
+
</select>
431
+
<input
432
+
type="text"
433
+
value={logSearch}
434
+
onChange={(e) => setLogSearch(e.target.value)}
435
+
placeholder="Search logs..."
436
+
className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white"
437
+
/>
438
+
</div>
439
+
440
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
441
+
<div className="max-h-[600px] overflow-y-auto">
442
+
<table className="w-full text-sm">
443
+
<thead className="bg-gray-800 sticky top-0">
444
+
<tr>
445
+
<th className="px-4 py-2 text-left">Time</th>
446
+
<th className="px-4 py-2 text-left">Level</th>
447
+
<th className="px-4 py-2 text-left">Service</th>
448
+
<th className="px-4 py-2 text-left">Event Type</th>
449
+
<th className="px-4 py-2 text-left">Message</th>
450
+
</tr>
451
+
</thead>
452
+
<tbody>
453
+
{logs.map((log) => (
454
+
<tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800">
455
+
<td className="px-4 py-2 text-gray-400 whitespace-nowrap">
456
+
{new Date(log.timestamp).toLocaleTimeString()}
457
+
</td>
458
+
<td className="px-4 py-2">
459
+
<span
460
+
className={`px-2 py-1 rounded text-xs font-medium ${
461
+
log.level === 'error'
462
+
? 'bg-red-900 text-red-200'
463
+
: log.level === 'warn'
464
+
? 'bg-yellow-900 text-yellow-200'
465
+
: log.level === 'info'
466
+
? 'bg-blue-900 text-blue-200'
467
+
: 'bg-gray-700 text-gray-300'
468
+
}`}
469
+
>
470
+
{log.level}
471
+
</span>
472
+
</td>
473
+
<td className="px-4 py-2 text-gray-400">{log.service}</td>
474
+
<td className="px-4 py-2">
475
+
{log.eventType && (
476
+
<span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium">
477
+
{log.eventType}
478
+
</span>
479
+
)}
480
+
</td>
481
+
<td className="px-4 py-2">
482
+
<div>{log.message}</div>
483
+
{log.context && Object.keys(log.context).length > 0 && (
484
+
<div className="text-xs text-gray-500 mt-1">
485
+
{JSON.stringify(log.context)}
486
+
</div>
487
+
)}
488
+
</td>
489
+
</tr>
490
+
))}
491
+
</tbody>
492
+
</table>
493
+
</div>
494
+
</div>
495
+
</div>
496
+
)}
497
+
498
+
{tab === 'errors' && (
499
+
<div className="space-y-4">
500
+
<h2 className="text-xl font-bold">Recent Errors</h2>
501
+
<div className="space-y-3">
502
+
{errors.map((error) => (
503
+
<div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4">
504
+
<div className="flex items-start justify-between mb-2">
505
+
<div className="flex-1">
506
+
<div className="font-semibold text-red-400">{error.message}</div>
507
+
<div className="text-sm text-gray-400 mt-1">
508
+
Service: {error.service} • Count: {error.count} • Last seen:{' '}
509
+
{new Date(error.lastSeen).toLocaleString()}
510
+
</div>
511
+
</div>
512
+
</div>
513
+
{error.stack && (
514
+
<pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto">
515
+
{error.stack}
516
+
</pre>
517
+
)}
518
+
</div>
519
+
))}
520
+
{errors.length === 0 && (
521
+
<div className="text-center text-gray-500 py-8">No errors found</div>
522
+
)}
523
+
</div>
524
+
</div>
525
+
)}
526
+
527
+
{tab === 'database' && database && (
528
+
<div className="space-y-6">
529
+
{/* Stats */}
530
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
531
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
532
+
<div className="text-sm text-gray-400 mb-1">Total Sites</div>
533
+
<div className="text-3xl font-bold">{database.stats.totalSites}</div>
534
+
</div>
535
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
536
+
<div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div>
537
+
<div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div>
538
+
</div>
539
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
540
+
<div className="text-sm text-gray-400 mb-1">Custom Domains</div>
541
+
<div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div>
542
+
</div>
543
+
</div>
544
+
545
+
{/* Recent Sites */}
546
+
<div>
547
+
<h3 className="text-lg font-semibold mb-3">Recent Sites</h3>
548
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
549
+
<table className="w-full text-sm">
550
+
<thead className="bg-gray-800">
551
+
<tr>
552
+
<th className="px-4 py-2 text-left">Site Name</th>
553
+
<th className="px-4 py-2 text-left">Subdomain</th>
554
+
<th className="px-4 py-2 text-left">DID</th>
555
+
<th className="px-4 py-2 text-left">RKey</th>
556
+
<th className="px-4 py-2 text-left">Created</th>
557
+
</tr>
558
+
</thead>
559
+
<tbody>
560
+
{database.recentSites.map((site: any, i: number) => (
561
+
<tr key={i} className="border-t border-gray-800">
562
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
563
+
<td className="px-4 py-2">
564
+
{site.subdomain ? (
565
+
<a
566
+
href={`https://${site.subdomain}`}
567
+
target="_blank"
568
+
rel="noopener noreferrer"
569
+
className="text-blue-400 hover:underline"
570
+
>
571
+
{site.subdomain}
572
+
</a>
573
+
) : (
574
+
<span className="text-gray-500">No domain</span>
575
+
)}
576
+
</td>
577
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
578
+
{site.did.slice(0, 20)}...
579
+
</td>
580
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
581
+
<td className="px-4 py-2 text-gray-400">
582
+
{formatDbDate(site.created_at).toLocaleDateString()}
583
+
</td>
584
+
<td className="px-4 py-2">
585
+
<a
586
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
587
+
target="_blank"
588
+
rel="noopener noreferrer"
589
+
className="text-blue-400 hover:text-blue-300 transition-colors"
590
+
title="View on PDSls.dev"
591
+
>
592
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
593
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
594
+
</svg>
595
+
</a>
596
+
</td>
597
+
</tr>
598
+
))}
599
+
</tbody>
600
+
</table>
601
+
</div>
602
+
</div>
603
+
604
+
{/* Recent Domains */}
605
+
<div>
606
+
<h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3>
607
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
608
+
<table className="w-full text-sm">
609
+
<thead className="bg-gray-800">
610
+
<tr>
611
+
<th className="px-4 py-2 text-left">Domain</th>
612
+
<th className="px-4 py-2 text-left">DID</th>
613
+
<th className="px-4 py-2 text-left">Verified</th>
614
+
<th className="px-4 py-2 text-left">Created</th>
615
+
</tr>
616
+
</thead>
617
+
<tbody>
618
+
{database.recentDomains.map((domain: any, i: number) => (
619
+
<tr key={i} className="border-t border-gray-800">
620
+
<td className="px-4 py-2">{domain.domain}</td>
621
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
622
+
{domain.did.slice(0, 20)}...
623
+
</td>
624
+
<td className="px-4 py-2">
625
+
<span
626
+
className={`px-2 py-1 rounded text-xs ${
627
+
domain.verified
628
+
? 'bg-green-900 text-green-200'
629
+
: 'bg-yellow-900 text-yellow-200'
630
+
}`}
631
+
>
632
+
{domain.verified ? 'Yes' : 'No'}
633
+
</span>
634
+
</td>
635
+
<td className="px-4 py-2 text-gray-400">
636
+
{formatDbDate(domain.created_at).toLocaleDateString()}
637
+
</td>
638
+
</tr>
639
+
))}
640
+
</tbody>
641
+
</table>
642
+
</div>
643
+
</div>
644
+
</div>
645
+
)}
646
+
647
+
{tab === 'sites' && sites && (
648
+
<div className="space-y-6">
649
+
{/* All Sites */}
650
+
<div>
651
+
<h3 className="text-lg font-semibold mb-3">All Sites</h3>
652
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
653
+
<table className="w-full text-sm">
654
+
<thead className="bg-gray-800">
655
+
<tr>
656
+
<th className="px-4 py-2 text-left">Site Name</th>
657
+
<th className="px-4 py-2 text-left">Subdomain</th>
658
+
<th className="px-4 py-2 text-left">DID</th>
659
+
<th className="px-4 py-2 text-left">RKey</th>
660
+
<th className="px-4 py-2 text-left">Created</th>
661
+
</tr>
662
+
</thead>
663
+
<tbody>
664
+
{sites.sites.map((site: any, i: number) => (
665
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
666
+
<td className="px-4 py-2">{site.display_name || 'Untitled'}</td>
667
+
<td className="px-4 py-2">
668
+
{site.subdomain ? (
669
+
<a
670
+
href={`https://${site.subdomain}`}
671
+
target="_blank"
672
+
rel="noopener noreferrer"
673
+
className="text-blue-400 hover:underline"
674
+
>
675
+
{site.subdomain}
676
+
</a>
677
+
) : (
678
+
<span className="text-gray-500">No domain</span>
679
+
)}
680
+
</td>
681
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
682
+
{site.did.slice(0, 30)}...
683
+
</td>
684
+
<td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td>
685
+
<td className="px-4 py-2 text-gray-400">
686
+
{formatDbDate(site.created_at).toLocaleString()}
687
+
</td>
688
+
<td className="px-4 py-2">
689
+
<a
690
+
href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`}
691
+
target="_blank"
692
+
rel="noopener noreferrer"
693
+
className="text-blue-400 hover:text-blue-300 transition-colors"
694
+
title="View on PDSls.dev"
695
+
>
696
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
697
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
698
+
</svg>
699
+
</a>
700
+
</td>
701
+
</tr>
702
+
))}
703
+
</tbody>
704
+
</table>
705
+
</div>
706
+
</div>
707
+
708
+
{/* Custom Domains */}
709
+
<div>
710
+
<h3 className="text-lg font-semibold mb-3">Custom Domains</h3>
711
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
712
+
<table className="w-full text-sm">
713
+
<thead className="bg-gray-800">
714
+
<tr>
715
+
<th className="px-4 py-2 text-left">Domain</th>
716
+
<th className="px-4 py-2 text-left">Verified</th>
717
+
<th className="px-4 py-2 text-left">DID</th>
718
+
<th className="px-4 py-2 text-left">RKey</th>
719
+
<th className="px-4 py-2 text-left">Created</th>
720
+
<th className="px-4 py-2 text-left">PDSls</th>
721
+
</tr>
722
+
</thead>
723
+
<tbody>
724
+
{sites.customDomains.map((domain: any, i: number) => (
725
+
<tr key={i} className="border-t border-gray-800 hover:bg-gray-800">
726
+
<td className="px-4 py-2">
727
+
{domain.verified ? (
728
+
<a
729
+
href={`https://${domain.domain}`}
730
+
target="_blank"
731
+
rel="noopener noreferrer"
732
+
className="text-blue-400 hover:underline"
733
+
>
734
+
{domain.domain}
735
+
</a>
736
+
) : (
737
+
<span className="text-gray-400">{domain.domain}</span>
738
+
)}
739
+
</td>
740
+
<td className="px-4 py-2">
741
+
<span
742
+
className={`px-2 py-1 rounded text-xs ${
743
+
domain.verified
744
+
? 'bg-green-900 text-green-200'
745
+
: 'bg-yellow-900 text-yellow-200'
746
+
}`}
747
+
>
748
+
{domain.verified ? 'Yes' : 'Pending'}
749
+
</span>
750
+
</td>
751
+
<td className="px-4 py-2 text-gray-400 font-mono text-xs">
752
+
{domain.did.slice(0, 30)}...
753
+
</td>
754
+
<td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td>
755
+
<td className="px-4 py-2 text-gray-400">
756
+
{formatDbDate(domain.created_at).toLocaleString()}
757
+
</td>
758
+
<td className="px-4 py-2">
759
+
<a
760
+
href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`}
761
+
target="_blank"
762
+
rel="noopener noreferrer"
763
+
className="text-blue-400 hover:text-blue-300 transition-colors"
764
+
title="View on PDSls.dev"
765
+
>
766
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
767
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
768
+
</svg>
769
+
</a>
770
+
</td>
771
+
</tr>
772
+
))}
773
+
</tbody>
774
+
</table>
775
+
</div>
776
+
</div>
777
+
</div>
778
+
)}
779
+
</div>
780
+
</div>
781
+
)
782
+
}
783
+
784
+
// Main App
785
+
function App() {
786
+
const [authenticated, setAuthenticated] = useState(false)
787
+
const [checking, setChecking] = useState(true)
788
+
789
+
useEffect(() => {
790
+
fetch('/api/admin/status', { credentials: 'include' })
791
+
.then((res) => res.json())
792
+
.then((data) => {
793
+
setAuthenticated(data.authenticated)
794
+
setChecking(false)
795
+
})
796
+
.catch(() => {
797
+
setChecking(false)
798
+
})
799
+
}, [])
800
+
801
+
if (checking) {
802
+
return (
803
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
804
+
<div className="text-white">Loading...</div>
805
+
</div>
806
+
)
807
+
}
808
+
809
+
if (!authenticated) {
810
+
return <Login onLogin={() => setAuthenticated(true)} />
811
+
}
812
+
813
+
return <Dashboard />
814
+
}
815
+
816
+
createRoot(document.getElementById('root')!).render(
817
+
<StrictMode>
818
+
<App />
819
+
</StrictMode>
820
+
)
+13
public/admin/index.html
+13
public/admin/index.html
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>Admin Dashboard - Wisp.place</title>
7
+
<link rel="stylesheet" href="./styles.css" />
8
+
</head>
9
+
<body>
10
+
<div id="root"></div>
11
+
<script type="module" src="./admin.tsx"></script>
12
+
</body>
13
+
</html>
+1
public/admin/styles.css
+1
public/admin/styles.css
···
1
+
@import "tailwindcss";
+286
-33
public/editor/editor.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}