From 616a5203aea7df479fb386b1c4300a8474b63d9e Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Sat, 1 Nov 2025 22:09:26 -0400 Subject: [PATCH] observability --- bun.lock | 149 ++++ change-admin-password.ts | 46 ++ claude.md | 33 + create-admin.ts | 31 + hosting-service/src/index.ts | 5 +- hosting-service/src/lib/db.ts | 43 ++ hosting-service/src/lib/firehose.ts | 24 +- hosting-service/src/lib/observability.ts | 328 +++++++++ hosting-service/src/lib/utils.ts | 47 +- hosting-service/src/server.ts | 32 +- package.json | 7 +- public/admin/admin.tsx | 820 +++++++++++++++++++++++ public/admin/index.html | 13 + public/admin/styles.css | 1 + public/editor/editor.tsx | 319 ++++++++- public/index.tsx | 515 +++++++------- public/onboarding/onboarding.tsx | 12 +- src/index.ts | 44 +- src/lib/admin-auth.ts | 208 ++++++ src/lib/db.ts | 10 + src/lib/observability.ts | 337 ++++++++++ src/routes/admin.ts | 305 +++++++++ src/routes/auth.ts | 13 +- src/routes/site.ts | 60 ++ src/routes/wisp.ts | 106 +-- 25 files changed, 3111 insertions(+), 397 deletions(-) create mode 100644 change-admin-password.ts create mode 100644 claude.md create mode 100644 create-admin.ts create mode 100644 hosting-service/src/lib/observability.ts create mode 100644 public/admin/admin.tsx create mode 100644 public/admin/index.html create mode 100644 public/admin/styles.css create mode 100644 src/lib/admin-auth.ts create mode 100644 src/lib/observability.ts create mode 100644 src/routes/admin.ts create mode 100644 src/routes/site.ts diff --git a/bun.lock b/bun.lock index 4319e81..ed04573 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@elysiajs/cors": "^1.4.0", "@elysiajs/eden": "^1.4.3", "@elysiajs/openapi": "^1.4.11", + "@elysiajs/opentelemetry": "^1.4.6", "@elysiajs/static": "^1.4.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", @@ -39,6 +40,10 @@ }, }, }, + "trustedDependencies": [ + "core-js", + "protobufjs", + ], "packages": { "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="], @@ -110,14 +115,78 @@ "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], + "@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="], + "@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="], + + "@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="], + + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="], + + "@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="], + + "@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="], + + "@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="], + + "@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="], + + "@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="], + + "@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="], + + "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="], + + "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="], @@ -140,6 +209,26 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -208,10 +297,18 @@ "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="], + "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -252,8 +349,12 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -292,6 +393,8 @@ "elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -300,6 +403,8 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -330,6 +435,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], @@ -352,6 +459,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], @@ -360,10 +469,18 @@ "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], @@ -384,6 +501,8 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], @@ -404,6 +523,8 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -420,6 +541,8 @@ "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], @@ -446,6 +569,12 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], @@ -460,6 +589,8 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -474,12 +605,18 @@ "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], @@ -524,8 +661,16 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="], "zlib": ["zlib@1.0.5", "", {}, "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w=="], @@ -540,10 +685,14 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], } } diff --git a/change-admin-password.ts b/change-admin-password.ts new file mode 100644 index 0000000..f6f916f --- /dev/null +++ b/change-admin-password.ts @@ -0,0 +1,46 @@ +// Change admin password +import { adminAuth } from './src/lib/admin-auth' +import { db } from './src/lib/db' +import { randomBytes, createHash } from 'crypto' + +// Get username and new password from command line +const username = process.argv[2] +const newPassword = process.argv[3] + +if (!username || !newPassword) { + console.error('Usage: bun run change-admin-password.ts ') + process.exit(1) +} + +if (newPassword.length < 8) { + console.error('Password must be at least 8 characters') + process.exit(1) +} + +// Hash password +function hashPassword(password: string, salt: string): string { + return createHash('sha256').update(password + salt).digest('hex') +} + +function generateSalt(): string { + return randomBytes(32).toString('hex') +} + +// Initialize +await adminAuth.init() + +// Check if user exists +const result = await db`SELECT username FROM admin_users WHERE username = ${username}` +if (result.length === 0) { + console.error(`Admin user '${username}' not found`) + process.exit(1) +} + +// Update password +const salt = generateSalt() +const passwordHash = hashPassword(newPassword, salt) + +await db`UPDATE admin_users SET password_hash = ${passwordHash}, salt = ${salt} WHERE username = ${username}` + +console.log(`✓ Password updated for admin user '${username}'`) +process.exit(0) diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..cd4aeba --- /dev/null +++ b/claude.md @@ -0,0 +1,33 @@ + Wisp.place - Decentralized Static Site Hosting + + Architecture Overview + + Wisp.Place a two-service application that provides static site hosting on the AT + Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files. + + Service 1: Main App (Port 8000, Bun runtime, elysia.js) + - User-facing editor and API + - OAuth authentication (AT Protocol) + - File upload processing (gzip + base64 encoding) + - Domain management (subdomains + custom domains) + - DNS verification worker + - React frontend + + Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js) + - AT Protocol Firehose listener for real-time updates + - Serves hosted websites from local cache + - Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain) + - Distributed locking for multi-instance coordination + + Tech Stack + + - Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK + - Frontend: React 19, Tailwind CSS v4, Shadcn UI + + Key Features + + - AT Protocol Integration: Sites stored as place.wisp.fs records in user repos + - File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS + - Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification + - Real-time Sync: Firehose worker listens for site updates and caches files locally + - Atomic Updates: Safe cache swapping without downtime diff --git a/create-admin.ts b/create-admin.ts new file mode 100644 index 0000000..33db1a6 --- /dev/null +++ b/create-admin.ts @@ -0,0 +1,31 @@ +// Quick script to create admin user with randomly generated password +import { adminAuth } from './src/lib/admin-auth' +import { randomBytes } from 'crypto' + +// Generate a secure random password +function generatePassword(length: number = 20): string { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' + const bytes = randomBytes(length) + let password = '' + for (let i = 0; i < length; i++) { + password += chars[bytes[i] % chars.length] + } + return password +} + +const username = 'admin' +const password = generatePassword(20) + +await adminAuth.init() +await adminAuth.createAdmin(username, password) + +console.log('\n╔════════════════════════════════════════════════════════════════╗') +console.log('║ ADMIN USER CREATED SUCCESSFULLY ║') +console.log('╚════════════════════════════════════════════════════════════════╝\n') +console.log(`Username: ${username}`) +console.log(`Password: ${password}`) +console.log('\n⚠️ IMPORTANT: Save this password securely!') +console.log('This password will not be shown again.\n') +console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') + +process.exit(0) diff --git a/hosting-service/src/index.ts b/hosting-service/src/index.ts index e1e31e3..4ebd49f 100644 --- a/hosting-service/src/index.ts +++ b/hosting-service/src/index.ts @@ -1,5 +1,6 @@ import app from './server'; import { FirehoseWorker } from './lib/firehose'; +import { logger } from './lib/observability'; import { mkdirSync, existsSync } from 'fs'; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; @@ -11,9 +12,9 @@ if (!existsSync(CACHE_DIR)) { console.log('Created cache directory:', CACHE_DIR); } -// Start firehose worker +// Start firehose worker with observability logger const firehose = new FirehoseWorker((msg, data) => { - console.log(msg, data); + logger.info(msg, data); }); firehose.start(); diff --git a/hosting-service/src/lib/db.ts b/hosting-service/src/lib/db.ts index e5ff648..dfd4726 100644 --- a/hosting-service/src/lib/db.ts +++ b/hosting-service/src/lib/db.ts @@ -158,4 +158,47 @@ export async function upsertSite(did: string, rkey: string, displayName?: string } } +/** + * Generate a numeric lock ID from a string key + * PostgreSQL advisory locks use bigint (64-bit signed integer) + */ +function stringToLockId(key: string): bigint { + let hash = 0n; + for (let i = 0; i < key.length; i++) { + const char = BigInt(key.charCodeAt(i)); + hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range + } + return hash; +} + +/** + * Acquire a distributed lock using PostgreSQL advisory locks + * Returns true if lock was acquired, false if already held by another instance + * Lock is automatically released when the transaction ends or connection closes + */ +export async function tryAcquireLock(key: string): Promise { + const lockId = stringToLockId(key); + + try { + const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`; + return result[0]?.acquired === true; + } catch (err) { + console.error('Failed to acquire lock', { key, error: err }); + return false; + } +} + +/** + * Release a distributed lock + */ +export async function releaseLock(key: string): Promise { + const lockId = stringToLockId(key); + + try { + await sql`SELECT pg_advisory_unlock(${lockId})`; + } catch (err) { + console.error('Failed to release lock', { key, error: err }); + } +} + export { sql }; diff --git a/hosting-service/src/lib/firehose.ts b/hosting-service/src/lib/firehose.ts index 7e57049..dff2413 100644 --- a/hosting-service/src/lib/firehose.ts +++ b/hosting-service/src/lib/firehose.ts @@ -1,6 +1,6 @@ import { existsSync, rmSync } from 'fs'; import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; -import { upsertSite } from './db'; +import { upsertSite, tryAcquireLock, releaseLock } from './db'; import { safeFetch } from './safe-fetch'; import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; import { Firehose } from '@atproto/sync'; @@ -158,17 +158,33 @@ export class FirehoseWorker { } // Cache the record with verified CID (uses atomic swap internally) + // All instances cache locally for edge serving await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); - // Upsert site to database - await upsertSite(did, site, fsRecord.site); + // Acquire distributed lock only for database write to prevent duplicate writes + const lockKey = `db:upsert:${did}:${site}`; + const lockAcquired = await tryAcquireLock(lockKey); - this.log('Successfully processed create/update', { did, site }); + if (!lockAcquired) { + this.log('Another instance is writing to DB, skipping upsert', { did, site }); + this.log('Successfully processed create/update (cached locally)', { did, site }); + return; + } + + try { + // Upsert site to database (only one instance does this) + await upsertSite(did, site, fsRecord.site); + this.log('Successfully processed create/update (cached + DB updated)', { did, site }); + } finally { + // Always release lock, even if DB write fails + await releaseLock(lockKey); + } } private async handleDelete(did: string, site: string) { this.log('Processing delete', { did, site }); + // All instances should delete their local cache (no lock needed) const pdsEndpoint = await getPdsForDid(did); if (!pdsEndpoint) { this.log('Could not resolve PDS for DID', { did }); diff --git a/hosting-service/src/lib/observability.ts b/hosting-service/src/lib/observability.ts new file mode 100644 index 0000000..22e5250 --- /dev/null +++ b/hosting-service/src/lib/observability.ts @@ -0,0 +1,328 @@ +// DIY Observability for Hosting Service +import type { Context } from 'elysia' + +// Types +export interface LogEntry { + id: string + timestamp: Date + level: 'info' | 'warn' | 'error' | 'debug' + message: string + service: string + context?: Record + traceId?: string + eventType?: string +} + +export interface ErrorEntry { + id: string + timestamp: Date + message: string + stack?: string + service: string + context?: Record + count: number + lastSeen: Date +} + +export interface MetricEntry { + timestamp: Date + path: string + method: string + statusCode: number + duration: number + service: string +} + +// In-memory storage with rotation +const MAX_LOGS = 5000 +const MAX_ERRORS = 500 +const MAX_METRICS = 10000 + +const logs: LogEntry[] = [] +const errors: Map = new Map() +const metrics: MetricEntry[] = [] + +// Helper to generate unique IDs +let logCounter = 0 +let errorCounter = 0 + +function generateId(prefix: string, counter: number): string { + return `${prefix}-${Date.now()}-${counter}` +} + +// Helper to extract event type from message +function extractEventType(message: string): string | undefined { + const match = message.match(/^\[([^\]]+)\]/) + return match ? match[1] : undefined +} + +// Log collector +export const logCollector = { + log(level: LogEntry['level'], message: string, service: string, context?: Record, traceId?: string) { + const entry: LogEntry = { + id: generateId('log', logCounter++), + timestamp: new Date(), + level, + message, + service, + context, + traceId, + eventType: extractEventType(message) + } + + logs.unshift(entry) + + // Rotate if needed + if (logs.length > MAX_LOGS) { + logs.splice(MAX_LOGS) + } + + // Also log to console for compatibility + const contextStr = context ? ` ${JSON.stringify(context)}` : '' + const traceStr = traceId ? ` [trace:${traceId}]` : '' + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) + }, + + info(message: string, service: string, context?: Record, traceId?: string) { + this.log('info', message, service, context, traceId) + }, + + warn(message: string, service: string, context?: Record, traceId?: string) { + this.log('warn', message, service, context, traceId) + }, + + error(message: string, service: string, error?: any, context?: Record, traceId?: string) { + const ctx = { ...context } + if (error instanceof Error) { + ctx.error = error.message + ctx.stack = error.stack + } else if (error) { + ctx.error = String(error) + } + this.log('error', message, service, ctx, traceId) + + // Also track in errors + errorTracker.track(message, service, error, context) + }, + + debug(message: string, service: string, context?: Record, traceId?: string) { + if (process.env.NODE_ENV !== 'production') { + this.log('debug', message, service, context, traceId) + } + }, + + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { + let filtered = [...logs] + + if (filter?.level) { + filtered = filtered.filter(log => log.level === filter.level) + } + + if (filter?.service) { + filtered = filtered.filter(log => log.service === filter.service) + } + + if (filter?.eventType) { + filtered = filtered.filter(log => log.eventType === filter.eventType) + } + + if (filter?.search) { + const search = filter.search.toLowerCase() + filtered = filtered.filter(log => + log.message.toLowerCase().includes(search) || + JSON.stringify(log.context).toLowerCase().includes(search) + ) + } + + const limit = filter?.limit || 100 + return filtered.slice(0, limit) + }, + + clear() { + logs.length = 0 + } +} + +// Error tracker with deduplication +export const errorTracker = { + track(message: string, service: string, error?: any, context?: Record) { + const key = `${service}:${message}` + + const existing = errors.get(key) + if (existing) { + existing.count++ + existing.lastSeen = new Date() + if (context) { + existing.context = { ...existing.context, ...context } + } + } else { + const entry: ErrorEntry = { + id: generateId('error', errorCounter++), + timestamp: new Date(), + message, + service, + context, + count: 1, + lastSeen: new Date() + } + + if (error instanceof Error) { + entry.stack = error.stack + } + + errors.set(key, entry) + + // Rotate if needed + if (errors.size > MAX_ERRORS) { + const oldest = Array.from(errors.keys())[0] + errors.delete(oldest) + } + } + }, + + getErrors(filter?: { service?: string; limit?: number }) { + let filtered = Array.from(errors.values()) + + if (filter?.service) { + filtered = filtered.filter(err => err.service === filter.service) + } + + // Sort by last seen (most recent first) + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) + + const limit = filter?.limit || 100 + return filtered.slice(0, limit) + }, + + clear() { + errors.clear() + } +} + +// Metrics collector +export const metricsCollector = { + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { + const entry: MetricEntry = { + timestamp: new Date(), + path, + method, + statusCode, + duration, + service + } + + metrics.unshift(entry) + + // Rotate if needed + if (metrics.length > MAX_METRICS) { + metrics.splice(MAX_METRICS) + } + }, + + getMetrics(filter?: { service?: string; timeWindow?: number }) { + let filtered = [...metrics] + + if (filter?.service) { + filtered = filtered.filter(m => m.service === filter.service) + } + + if (filter?.timeWindow) { + const cutoff = Date.now() - filter.timeWindow + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) + } + + return filtered + }, + + getStats(service?: string, timeWindow: number = 3600000) { + const filtered = this.getMetrics({ service, timeWindow }) + + if (filtered.length === 0) { + return { + totalRequests: 0, + avgDuration: 0, + p50Duration: 0, + p95Duration: 0, + p99Duration: 0, + errorRate: 0, + requestsPerMinute: 0 + } + } + + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) + const totalDuration = durations.reduce((sum, d) => sum + d, 0) + const errors = filtered.filter(m => m.statusCode >= 400).length + + const p50 = durations[Math.floor(durations.length * 0.5)] + const p95 = durations[Math.floor(durations.length * 0.95)] + const p99 = durations[Math.floor(durations.length * 0.99)] + + const timeWindowMinutes = timeWindow / 60000 + + return { + totalRequests: filtered.length, + avgDuration: Math.round(totalDuration / filtered.length), + p50Duration: Math.round(p50), + p95Duration: Math.round(p95), + p99Duration: Math.round(p99), + errorRate: (errors / filtered.length) * 100, + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) + } + }, + + clear() { + metrics.length = 0 + } +} + +// Elysia middleware for request timing +export function observabilityMiddleware(service: string) { + return { + beforeHandle: ({ request }: any) => { + (request as any).__startTime = Date.now() + }, + afterHandle: ({ request, set }: any) => { + const duration = Date.now() - ((request as any).__startTime || Date.now()) + const url = new URL(request.url) + + metricsCollector.recordRequest( + url.pathname, + request.method, + set.status || 200, + duration, + service + ) + }, + onError: ({ request, error, set }: any) => { + const duration = Date.now() - ((request as any).__startTime || Date.now()) + const url = new URL(request.url) + + metricsCollector.recordRequest( + url.pathname, + request.method, + set.status || 500, + duration, + service + ) + + logCollector.error( + `Request failed: ${request.method} ${url.pathname}`, + service, + error, + { statusCode: set.status || 500 } + ) + } + } +} + +// Export singleton logger for easy access +export const logger = { + info: (message: string, context?: Record) => + logCollector.info(message, 'hosting-service', context), + warn: (message: string, context?: Record) => + logCollector.warn(message, 'hosting-service', context), + error: (message: string, error?: any, context?: Record) => + logCollector.error(message, 'hosting-service', error, context), + debug: (message: string, context?: Record) => + logCollector.debug(message, 'hosting-service', context) +} diff --git a/hosting-service/src/lib/utils.ts b/hosting-service/src/lib/utils.ts index c03ec5b..67f5164 100644 --- a/hosting-service/src/lib/utils.ts +++ b/hosting-service/src/lib/utils.ts @@ -1,5 +1,5 @@ import { AtpAgent } from '@atproto/api'; -import type { WispFsRecord, Directory, Entry, File } from './types'; +import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs'; import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; import { writeFile, readFile, rename } from 'fs/promises'; import { safeFetchJson, safeFetchBlob } from './safe-fetch'; @@ -206,17 +206,44 @@ async function cacheFiles( pathPrefix: string, dirSuffix: string = '' ): Promise { - for (const entry of entries) { - const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name; - const node = entry.node; - - if ('type' in node && node.type === 'directory' && 'entries' in node) { - await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath, dirSuffix); - } else if ('type' in node && node.type === 'file' && 'blob' in node) { - const fileNode = node as File; - await cacheFileBlob(did, site, currentPath, fileNode.blob, pdsEndpoint, fileNode.encoding, fileNode.mimeType, fileNode.base64, dirSuffix); + // Collect all file blob download tasks first + const downloadTasks: Array<() => Promise> = []; + + function collectFileTasks( + entries: Entry[], + currentPathPrefix: string + ) { + for (const entry of entries) { + const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name; + const node = entry.node; + + if ('type' in node && node.type === 'directory' && 'entries' in node) { + collectFileTasks(node.entries, currentPath); + } else if ('type' in node && node.type === 'file' && 'blob' in node) { + const fileNode = node as File; + downloadTasks.push(() => cacheFileBlob( + did, + site, + currentPath, + fileNode.blob, + pdsEndpoint, + fileNode.encoding, + fileNode.mimeType, + fileNode.base64, + dirSuffix + )); + } } } + + collectFileTasks(entries, pathPrefix); + + // Execute downloads concurrently with a limit of 3 at a time + const concurrencyLimit = 3; + for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) { + const batch = downloadTasks.slice(i, i + concurrencyLimit); + await Promise.all(batch.map(task => task())); + } } async function cacheFileBlob( diff --git a/hosting-service/src/server.ts b/hosting-service/src/server.ts index 05fd0cd..6f6a684 100644 --- a/hosting-service/src/server.ts +++ b/hosting-service/src/server.ts @@ -6,6 +6,7 @@ import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCac import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; import { existsSync, readFileSync } from 'fs'; import { lookup } from 'mime-types'; +import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability'; const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; @@ -200,27 +201,31 @@ async function ensureSiteCached(did: string, rkey: string): Promise { // Fetch and cache the site const siteData = await fetchSiteRecord(did, rkey); if (!siteData) { - console.error('Site record not found', did, rkey); + logger.error('Site record not found', null, { did, rkey }); return false; } const pdsEndpoint = await getPdsForDid(did); if (!pdsEndpoint) { - console.error('PDS not found for DID', did); + logger.error('PDS not found for DID', null, { did }); return false; } try { await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); + logger.info('Site cached successfully', { did, rkey }); return true; } catch (err) { - console.error('Failed to cache site', did, rkey, err); + logger.error('Failed to cache site', err, { did, rkey }); return false; } } const app = new Elysia({ adapter: node() }) .use(opentelemetry()) + .onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle) + .onAfterHandle(observabilityMiddleware('hosting-service').afterHandle) + .onError(observabilityMiddleware('hosting-service').onError) .get('/*', async ({ request, set }) => { const url = new URL(request.url); const hostname = request.headers.get('host') || ''; @@ -351,6 +356,27 @@ const app = new Elysia({ adapter: node() }) } return serveFromCache(customDomain.did, rkey, path); + }) + // Internal observability endpoints (for admin panel) + .get('/__internal__/observability/logs', ({ query }) => { + const filter: any = {}; + if (query.level) filter.level = query.level; + if (query.service) filter.service = query.service; + if (query.search) filter.search = query.search; + if (query.eventType) filter.eventType = query.eventType; + if (query.limit) filter.limit = parseInt(query.limit as string); + return { logs: logCollector.getLogs(filter) }; + }) + .get('/__internal__/observability/errors', ({ query }) => { + const filter: any = {}; + if (query.service) filter.service = query.service; + if (query.limit) filter.limit = parseInt(query.limit as string); + return { errors: errorTracker.getErrors(filter) }; + }) + .get('/__internal__/observability/metrics', ({ query }) => { + const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; + const stats = metricsCollector.getStats('hosting-service', timeWindow); + return { stats, timeWindow }; }); export default app; diff --git a/package.json b/package.json index 1f8e7bc..33feec7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@elysiajs/cors": "^1.4.0", "@elysiajs/eden": "^1.4.3", "@elysiajs/openapi": "^1.4.11", + "@elysiajs/opentelemetry": "^1.4.6", "@elysiajs/static": "^1.4.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", @@ -41,5 +42,9 @@ "bun-plugin-tailwind": "^0.1.2", "bun-types": "latest" }, - "module": "src/index.js" + "module": "src/index.js", + "trustedDependencies": [ + "core-js", + "protobufjs" + ] } diff --git a/public/admin/admin.tsx b/public/admin/admin.tsx new file mode 100644 index 0000000..9de13d8 --- /dev/null +++ b/public/admin/admin.tsx @@ -0,0 +1,820 @@ +import { StrictMode, useState, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import './styles.css' + +// Types +interface LogEntry { + id: string + timestamp: string + level: 'info' | 'warn' | 'error' | 'debug' + message: string + service: string + context?: Record + eventType?: string +} + +interface ErrorEntry { + id: string + timestamp: string + message: string + stack?: string + service: string + count: number + lastSeen: string +} + +interface MetricsStats { + totalRequests: number + avgDuration: number + p50Duration: number + p95Duration: number + p99Duration: number + errorRate: number + requestsPerMinute: number +} + +// Helper function to format Unix timestamp from database +function formatDbDate(timestamp: number | string): Date { + const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp + return new Date(num * 1000) // Convert seconds to milliseconds +} + +// Login Component +function Login({ onLogin }: { onLogin: () => void }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const res = await fetch('/api/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + credentials: 'include' + }) + + if (res.ok) { + onLogin() + } else { + setError('Invalid credentials') + } + } catch (err) { + setError('Failed to login') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

Admin Login

+
+
+ + setUsername(e.target.value)} + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" + required + /> +
+ {error && ( +
{error}
+ )} + +
+
+
+
+ ) +} + +// Dashboard Component +function Dashboard() { + const [tab, setTab] = useState('overview') + const [logs, setLogs] = useState([]) + const [errors, setErrors] = useState([]) + const [metrics, setMetrics] = useState(null) + const [database, setDatabase] = useState(null) + const [sites, setSites] = useState(null) + const [health, setHealth] = useState(null) + const [autoRefresh, setAutoRefresh] = useState(true) + + // Filters + const [logLevel, setLogLevel] = useState('') + const [logService, setLogService] = useState('') + const [logSearch, setLogSearch] = useState('') + const [logEventType, setLogEventType] = useState('') + + const fetchLogs = async () => { + const params = new URLSearchParams() + if (logLevel) params.append('level', logLevel) + if (logService) params.append('service', logService) + if (logSearch) params.append('search', logSearch) + if (logEventType) params.append('eventType', logEventType) + params.append('limit', '100') + + const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setLogs(data.logs) + } + } + + const fetchErrors = async () => { + const res = await fetch('/api/admin/errors', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setErrors(data.errors) + } + } + + const fetchMetrics = async () => { + const res = await fetch('/api/admin/metrics', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setMetrics(data) + } + } + + const fetchDatabase = async () => { + const res = await fetch('/api/admin/database', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setDatabase(data) + } + } + + const fetchSites = async () => { + const res = await fetch('/api/admin/sites', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setSites(data) + } + } + + const fetchHealth = async () => { + const res = await fetch('/api/admin/health', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setHealth(data) + } + } + + const logout = async () => { + await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) + window.location.reload() + } + + useEffect(() => { + fetchMetrics() + fetchDatabase() + fetchHealth() + fetchLogs() + fetchErrors() + fetchSites() + }, []) + + useEffect(() => { + fetchLogs() + }, [logLevel, logService, logSearch]) + + useEffect(() => { + if (!autoRefresh) return + + const interval = setInterval(() => { + if (tab === 'overview') { + fetchMetrics() + fetchHealth() + } else if (tab === 'logs') { + fetchLogs() + } else if (tab === 'errors') { + fetchErrors() + } else if (tab === 'database') { + fetchDatabase() + } else if (tab === 'sites') { + fetchSites() + } + }, 5000) + + return () => clearInterval(interval) + }, [tab, autoRefresh, logLevel, logService, logSearch]) + + const formatDuration = (ms: number) => { + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(2)}s` + } + + const formatUptime = (seconds: number) => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return `${hours}h ${minutes}m` + } + + return ( +
+ {/* Header */} +
+
+

Wisp.place Admin

+
+ + +
+
+
+ + {/* Tabs */} +
+
+ {['overview', 'logs', 'errors', 'database', 'sites'].map((t) => ( + + ))} +
+
+ + {/* Content */} +
+ {tab === 'overview' && ( +
+ {/* Health */} + {health && ( +
+
+
Uptime
+
{formatUptime(health.uptime)}
+
+
+
Memory Used
+
{health.memory.heapUsed} MB
+
+
+
RSS
+
{health.memory.rss} MB
+
+
+ )} + + {/* Metrics */} + {metrics && ( +
+

Performance Metrics

+
+ {/* Overall */} +
+

Overall (Last Hour)

+
+
+
Total Requests
+
{metrics.overall.totalRequests}
+
+
+
Avg Duration
+
{metrics.overall.avgDuration}ms
+
+
+
P95 Duration
+
{metrics.overall.p95Duration}ms
+
+
+
Error Rate
+
{metrics.overall.errorRate.toFixed(2)}%
+
+
+
+ + {/* Main App */} +
+

Main App

+
+
+
Requests
+
{metrics.mainApp.totalRequests}
+
+
+
Avg
+
{metrics.mainApp.avgDuration}ms
+
+
+
P95
+
{metrics.mainApp.p95Duration}ms
+
+
+
Req/min
+
{metrics.mainApp.requestsPerMinute}
+
+
+
+ + {/* Hosting Service */} +
+

Hosting Service

+
+
+
Requests
+
{metrics.hostingService.totalRequests}
+
+
+
Avg
+
{metrics.hostingService.avgDuration}ms
+
+
+
P95
+
{metrics.hostingService.p95Duration}ms
+
+
+
Req/min
+
{metrics.hostingService.requestsPerMinute}
+
+
+
+
+
+ )} +
+ )} + + {tab === 'logs' && ( +
+
+ + + + setLogSearch(e.target.value)} + placeholder="Search logs..." + className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" + /> +
+ +
+
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
TimeLevelServiceEvent TypeMessage
+ {new Date(log.timestamp).toLocaleTimeString()} + + + {log.level} + + {log.service} + {log.eventType && ( + + {log.eventType} + + )} + +
{log.message}
+ {log.context && Object.keys(log.context).length > 0 && ( +
+ {JSON.stringify(log.context)} +
+ )} +
+
+
+
+ )} + + {tab === 'errors' && ( +
+

Recent Errors

+
+ {errors.map((error) => ( +
+
+
+
{error.message}
+
+ Service: {error.service} • Count: {error.count} • Last seen:{' '} + {new Date(error.lastSeen).toLocaleString()} +
+
+
+ {error.stack && ( +
+											{error.stack}
+										
+ )} +
+ ))} + {errors.length === 0 && ( +
No errors found
+ )} +
+
+ )} + + {tab === 'database' && database && ( +
+ {/* Stats */} +
+
+
Total Sites
+
{database.stats.totalSites}
+
+
+
Wisp Subdomains
+
{database.stats.totalWispSubdomains}
+
+
+
Custom Domains
+
{database.stats.totalCustomDomains}
+
+
+ + {/* Recent Sites */} +
+

Recent Sites

+
+ + + + + + + + + + + + {database.recentSites.map((site: any, i: number) => ( + + + + + + + + + ))} + +
Site NameSubdomainDIDRKeyCreated
{site.display_name || 'Untitled'} + {site.subdomain ? ( + + {site.subdomain} + + ) : ( + No domain + )} + + {site.did.slice(0, 20)}... + {site.rkey || 'self'} + {formatDbDate(site.created_at).toLocaleDateString()} + + + + + + +
+
+
+ + {/* Recent Domains */} +
+

Recent Custom Domains

+
+ + + + + + + + + + + {database.recentDomains.map((domain: any, i: number) => ( + + + + + + + ))} + +
DomainDIDVerifiedCreated
{domain.domain} + {domain.did.slice(0, 20)}... + + + {domain.verified ? 'Yes' : 'No'} + + + {formatDbDate(domain.created_at).toLocaleDateString()} +
+
+
+
+ )} + + {tab === 'sites' && sites && ( +
+ {/* All Sites */} +
+

All Sites

+
+ + + + + + + + + + + + {sites.sites.map((site: any, i: number) => ( + + + + + + + + + ))} + +
Site NameSubdomainDIDRKeyCreated
{site.display_name || 'Untitled'} + {site.subdomain ? ( + + {site.subdomain} + + ) : ( + No domain + )} + + {site.did.slice(0, 30)}... + {site.rkey || 'self'} + {formatDbDate(site.created_at).toLocaleString()} + + + + + + +
+
+
+ + {/* Custom Domains */} +
+

Custom Domains

+
+ + + + + + + + + + + + + {sites.customDomains.map((domain: any, i: number) => ( + + + + + + + + + ))} + +
DomainVerifiedDIDRKeyCreatedPDSls
+ {domain.verified ? ( + + {domain.domain} + + ) : ( + {domain.domain} + )} + + + {domain.verified ? 'Yes' : 'Pending'} + + + {domain.did.slice(0, 30)}... + {domain.rkey || 'self'} + {formatDbDate(domain.created_at).toLocaleString()} + + + + + + +
+
+
+
+ )} +
+
+ ) +} + +// Main App +function App() { + const [authenticated, setAuthenticated] = useState(false) + const [checking, setChecking] = useState(true) + + useEffect(() => { + fetch('/api/admin/status', { credentials: 'include' }) + .then((res) => res.json()) + .then((data) => { + setAuthenticated(data.authenticated) + setChecking(false) + }) + .catch(() => { + setChecking(false) + }) + }, []) + + if (checking) { + return ( +
+
Loading...
+
+ ) + } + + if (!authenticated) { + return setAuthenticated(true)} /> + } + + return +} + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..8a00bdb --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,13 @@ + + + + + + Admin Dashboard - Wisp.place + + + +
+ + + diff --git a/public/admin/styles.css b/public/admin/styles.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/public/admin/styles.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/public/editor/editor.tsx b/public/editor/editor.tsx index 21bec5a..2a6103f 100644 --- a/public/editor/editor.tsx +++ b/public/editor/editor.tsx @@ -88,9 +88,12 @@ function Dashboard() { const [configuringSite, setConfiguringSite] = useState(null) const [selectedDomain, setSelectedDomain] = useState('') const [isSavingConfig, setIsSavingConfig] = useState(false) + const [isDeletingSite, setIsDeletingSite] = useState(false) // Upload state - const [siteName, setSiteName] = useState('') + const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') + const [selectedSiteRkey, setSelectedSiteRkey] = useState('') + const [newSiteName, setNewSiteName] = useState('') const [selectedFiles, setSelectedFiles] = useState(null) const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState('') @@ -106,6 +109,14 @@ function Dashboard() { }>({}) const [viewDomainDNS, setViewDomainDNS] = useState(null) + // Wisp domain claim state + const [wispHandle, setWispHandle] = useState('') + const [isClaimingWisp, setIsClaimingWisp] = useState(false) + const [wispAvailability, setWispAvailability] = useState<{ + available: boolean | null + checking: boolean + }>({ available: null, checking: false }) + // Fetch user info on mount useEffect(() => { fetchUserInfo() @@ -113,6 +124,13 @@ function Dashboard() { fetchDomains() }, []) + // Auto-switch to 'new' mode if no sites exist + useEffect(() => { + if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { + setSiteMode('new') + } + }, [sites, sitesLoading, siteMode]) + const fetchUserInfo = async () => { try { const response = await fetch('/api/user/info') @@ -207,8 +225,10 @@ function Dashboard() { } const handleUpload = async () => { + const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName + if (!siteName) { - alert('Please enter a site name') + alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') return } @@ -236,7 +256,8 @@ function Dashboard() { setUploadProgress('Upload complete!') setSkippedFiles(data.skippedFiles || []) setUploadedCount(data.uploadedCount || data.fileCount || 0) - setSiteName('') + setSelectedSiteRkey('') + setNewSiteName('') setSelectedFiles(null) // Refresh sites list @@ -430,6 +451,96 @@ function Dashboard() { } } + const handleDeleteSite = async () => { + if (!configuringSite) return + + if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { + return + } + + setIsDeletingSite(true) + try { + const response = await fetch(`/api/site/${configuringSite.rkey}`, { + method: 'DELETE' + }) + + const data = await response.json() + if (data.success) { + // Refresh sites list + await fetchSites() + // Refresh domains in case this site was mapped + await fetchDomains() + setConfiguringSite(null) + } else { + throw new Error(data.error || 'Failed to delete site') + } + } catch (err) { + console.error('Delete site error:', err) + alert( + `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` + ) + } finally { + setIsDeletingSite(false) + } + } + + const checkWispAvailability = async (handle: string) => { + const trimmedHandle = handle.trim().toLowerCase() + if (!trimmedHandle) { + setWispAvailability({ available: null, checking: false }) + return + } + + setWispAvailability({ available: null, checking: true }) + try { + const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) + const data = await response.json() + setWispAvailability({ available: data.available, checking: false }) + } catch (err) { + console.error('Check availability error:', err) + setWispAvailability({ available: false, checking: false }) + } + } + + const handleClaimWispDomain = async () => { + const trimmedHandle = wispHandle.trim().toLowerCase() + if (!trimmedHandle) { + alert('Please enter a handle') + return + } + + setIsClaimingWisp(true) + try { + const response = await fetch('/api/domain/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle: trimmedHandle }) + }) + + const data = await response.json() + if (data.success) { + setWispHandle('') + setWispAvailability({ available: null, checking: false }) + await fetchDomains() + } else { + throw new Error(data.error || 'Failed to claim domain') + } + } catch (err) { + console.error('Claim domain error:', err) + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + + // Handle "Already claimed" error more gracefully + if (errorMessage.includes('Already claimed')) { + alert('You have already claimed a wisp.place subdomain. Please refresh the page.') + await fetchDomains() + } else { + alert(`Failed to claim domain: ${errorMessage}`) + } + } finally { + setIsClaimingWisp(false) + } + } + if (loading) { return (
@@ -586,11 +697,71 @@ function Dashboard() {

) : ( -
-

No wisp.place subdomain claimed yet.

-

- You should have claimed one during onboarding! -

+
+
+

+ Claim your free wisp.place subdomain +

+
+
+ +
+
+ { + setWispHandle(e.target.value) + if (e.target.value.trim()) { + checkWispAvailability(e.target.value) + } else { + setWispAvailability({ available: null, checking: false }) + } + }} + disabled={isClaimingWisp} + className="pr-24" + /> + + .wisp.place + +
+
+ {wispAvailability.checking && ( +

+ + Checking availability... +

+ )} + {!wispAvailability.checking && wispAvailability.available === true && ( +

+ + Available +

+ )} + {!wispAvailability.checking && wispAvailability.available === false && ( +

+ + Not available +

+ )} +
+ +
+
)} @@ -712,15 +883,67 @@ function Dashboard() { -
- - setSiteName(e.target.value)} +
+ setSiteMode(value as 'existing' | 'new')} disabled={isUploading} - /> + > +
+ + +
+
+ + +
+
+ + {siteMode === 'existing' ? ( +
+ + {sitesLoading ? ( +
+ +
+ ) : sites.length === 0 ? ( +
+ No sites available. Create a new site instead. +
+ ) : ( + + )} +
+ ) : ( +
+ + setNewSiteName(e.target.value)} + disabled={isUploading} + /> +
+ )} +

File limits: 100MB per file, 300MB total

@@ -828,7 +1051,11 @@ function Dashboard() { @@ -994,27 +1225,49 @@ function Dashboard() {
)} - + - +
+ + +
diff --git a/public/index.tsx b/public/index.tsx index 001f288..0faad7f 100644 --- a/public/index.tsx +++ b/public/index.tsx @@ -25,291 +25,306 @@ function App() { }, [showForm]) return ( -
- {/* Header */} -
-
-
-
- + <> +
+ {/* Header */} +
+
+
+
+ +
+ + wisp.place + +
+
+ +
- - wisp.place - -
-
- -
-
-
+ - {/* Hero Section */} -
-
-
- - - Built on AT Protocol - -
+ {/* Hero Section */} +
+
+
+ + + Built on AT Protocol + +
-

- Host your sites on the{' '} - decentralized web -

+

+ Your Website.Your Control. Lightning Fast. +

-

- Deploy static sites to a truly open network. Your - content, your control, your identity. No platform - lock-in, ever. -

+

+ Host static sites in your AT Protocol account. You + keep ownership and control. We just serve them fast + through our CDN. +

-
-
- -
+ +
-
-
{ - e.preventDefault() - try { - const handle = inputRef.current?.value - const res = await fetch( - '/api/auth/signin', - { - method: 'POST', - headers: { - 'Content-Type': - 'application/json' - }, - body: JSON.stringify({ handle }) +
+ { + e.preventDefault() + try { + const handle = + inputRef.current?.value + const res = await fetch( + '/api/auth/signin', + { + method: 'POST', + headers: { + 'Content-Type': + 'application/json' + }, + body: JSON.stringify({ + handle + }) + } + ) + if (!res.ok) + throw new Error( + 'Request failed' + ) + const data = await res.json() + if (data.url) { + window.location.href = data.url + } else { + alert('Unexpected response') } - ) - if (!res.ok) - throw new Error('Request failed') - const data = await res.json() - if (data.url) { - window.location.href = data.url - } else { - alert('Unexpected response') + } catch (error) { + console.error( + 'Login failed:', + error + ) + alert('Authentication failed') } - } catch (error) { - console.error('Login failed:', error) - alert('Authentication failed') - } - }} - className="space-y-3" - > - - - + + + +
-
-
+ - {/* Stats Section */} -
-
- {[ - { value: '100%', label: 'Decentralized' }, - { value: '0ms', label: 'Cold Start' }, - { value: '∞', label: 'Scalability' }, - { value: 'You', label: 'Own Your Data' } - ].map((stat, i) => ( -
-
- {stat.value} + {/* How It Works */} +
+
+

+ How it works +

+
+
+
+ 01 +
+
+

+ Upload your static site +

+

+ Your HTML, CSS, and JavaScript files are + stored in your AT Protocol account as + gzipped blobs and a manifest record. +

+
+
+
+
+ 02 +
+
+

+ We serve it globally +

+

+ Wisp.place reads your site from your + account and delivers it through our CDN + for fast loading anywhere. +

+
-
- {stat.label} +
+
+ 03 +
+
+

+ You stay in control +

+

+ Update or remove your site anytime + through your AT Protocol account. No + lock-in, no middleman ownership. +

+
- ))} -
-
- - {/* Features Grid */} -
-
-

- Built for the open web -

-

- Everything you need to deploy and manage static sites on - a decentralized network -

-
- -
- {[ - { - icon: Shield, - title: 'True Ownership', - description: - 'Your content lives on the AT Protocol network. No single company can take it down or lock you out.' - }, - { - icon: Zap, - title: 'Lightning Fast', - description: - 'Distributed edge network ensures your sites load instantly from anywhere in the world.' - }, - { - icon: Lock, - title: 'Cryptographic Security', - description: - 'Content-addressed storage and cryptographic verification ensure integrity and authenticity.' - }, - { - icon: Code, - title: 'Developer Friendly', - description: - 'Simple CLI, Git integration, and familiar workflows. Deploy with a single command.' - }, - { - icon: Server, - title: 'Zero Vendor Lock-in', - description: - 'Built on open protocols. Migrate your sites anywhere, anytime. Your data is portable.' - }, - { - icon: Globe, - title: 'Global Network', - description: - 'Leverage the power of decentralized infrastructure for unmatched reliability and uptime.' - } - ].map((feature, i) => ( - -
- -
-

- {feature.title} -

-

- {feature.description} -

-
- ))} -
-
+
+
- {/* How It Works */} -
-
-

- Deploy in three steps -

+ {/* Features Grid */} +
+
+

+ Why Wisp.place? +

+

+ Static site hosting that respects your ownership +

+
-
+
{[ { - step: '01', - title: 'Upload your site', + icon: Shield, + title: 'You Own Your Content', + description: + 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' + }, + { + icon: Zap, + title: 'CDN Performance', + description: + 'We cache and serve your site from edge locations worldwide for fast load times.' + }, + { + icon: Lock, + title: 'No Vendor Lock-in', description: - 'Link your Git repository or upload a folder containing your static site directly.' + 'Your data stays in your account. Switch providers or self-host whenever you want.' }, { - step: '02', - title: 'Name and set domain', + icon: Code, + title: 'Simple Deployment', description: - 'Name your site and set domain routing to it. You can bring your own domain too.' + 'Upload your static files and we handle the rest. No complex configuration needed.' }, { - step: '03', - title: 'Deploy to AT Protocol', + icon: Server, + title: 'AT Protocol Native', description: - 'Your site is published to the decentralized network with a permanent, verifiable identity.' + 'Built for the decentralized web. Your site has a verifiable identity on the network.' + }, + { + icon: Globe, + title: 'Custom Domains', + description: + 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' } - ].map((step, i) => ( -
-
- {step.step} -
-
-

- {step.title} -

-

- {step.description} -

+ ].map((feature, i) => ( + +
+
-
+

+ {feature.title} +

+

+ {feature.description} +

+ ))}
-
-
+
- {/* Footer */} -
-
-
-

- Built by{' '} - - @nekomimi.pet - + {/* CTA Section */} +

+
+

+ Ready to deploy? +

+

+ Host your static site on your own AT Protocol + account today

+ +
+
+ + {/* Footer */} +
-
-
+ +
+ ) } diff --git a/public/onboarding/onboarding.tsx b/public/onboarding/onboarding.tsx index 2f0787e..b460377 100644 --- a/public/onboarding/onboarding.tsx +++ b/public/onboarding/onboarding.tsx @@ -75,11 +75,19 @@ function Onboarding() { setClaimedDomain(data.domain) setStep('upload') } else { - alert('Failed to claim domain. Please try again.') + throw new Error(data.error || 'Failed to claim domain') } } catch (err) { console.error('Error claiming domain:', err) - alert('Failed to claim domain. Please try again.') + const errorMessage = err instanceof Error ? err.message : 'Unknown error' + + // Handle "Already claimed" error - redirect to editor + if (errorMessage.includes('Already claimed')) { + alert('You have already claimed a wisp.place subdomain. Redirecting to editor...') + window.location.href = '/editor' + } else { + alert(`Failed to claim domain: ${errorMessage}`) + } } finally { setIsClaimingDomain(false) } diff --git a/src/index.ts b/src/index.ts index 83ac100..40201c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { Elysia } from 'elysia' import { cors } from '@elysiajs/cors' +import { openapi, fromTypes } from '@elysiajs/openapi' import { staticPlugin } from '@elysiajs/static' import type { Config } from './lib/types' @@ -15,15 +16,21 @@ import { authRoutes } from './routes/auth' import { wispRoutes } from './routes/wisp' import { domainRoutes } from './routes/domain' import { userRoutes } from './routes/user' +import { siteRoutes } from './routes/site' import { csrfProtection } from './lib/csrf' import { DNSVerificationWorker } from './lib/dns-verification-worker' -import { logger } from './lib/logger' +import { logger, logCollector, observabilityMiddleware } from './lib/observability' +import { promptAdminSetup } from './lib/admin-auth' +import { adminRoutes } from './routes/admin' const config: Config = { domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`, clientName: Bun.env.CLIENT_NAME ?? 'PDS-View' } +// Initialize admin setup (prompt if no admin exists) +await promptAdminSetup() + const client = await getOAuthClient(config) // Periodic maintenance: cleanup expired sessions and rotate keys @@ -40,20 +47,27 @@ runMaintenance() // Schedule maintenance to run every hour setInterval(runMaintenance, 60 * 60 * 1000) -// Start DNS verification worker (runs every hour) +// Start DNS verification worker (runs every 10 minutes) const dnsVerifier = new DNSVerificationWorker( - 60 * 60 * 1000, // 1 hour + 10 * 60 * 1000, // 10 minutes (msg, data) => { - logger.info('[DNS Verifier]', msg, data || '') + logCollector.info(`[DNS Verifier] ${msg}`, 'main-app', data ? { data } : undefined) } ) dnsVerifier.start() -logger.info('[DNS Verifier] Started - checking custom domains every hour') +logger.info('DNS Verifier Started - checking custom domains every 10 minutes') export const app = new Elysia() - // Security headers middleware - .onAfterHandle(({ set }) => { + .use(openapi({ + references: fromTypes() + })) + // Observability middleware + .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle) + .onAfterHandle((ctx) => { + observabilityMiddleware('main-app').afterHandle(ctx) + // Security headers middleware + const { set } = ctx // Prevent clickjacking attacks set.headers['X-Frame-Options'] = 'DENY' // Prevent MIME type sniffing @@ -77,16 +91,19 @@ export const app = new Elysia() set.headers['X-XSS-Protection'] = '1; mode=block' set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' }) - .use( - await staticPlugin({ - prefix: '/' - }) - ) + .onError(observabilityMiddleware('main-app').onError) .use(csrfProtection()) .use(authRoutes(client)) .use(wispRoutes(client)) .use(domainRoutes(client)) .use(userRoutes(client)) + .use(siteRoutes(client)) + .use(adminRoutes()) + .use( + await staticPlugin({ + prefix: '/' + }) + ) .get('/client-metadata.json', (c) => { return createClientMetadata(config) }) @@ -110,6 +127,9 @@ export const app = new Elysia() dnsVerifier: dnsVerifierHealth } }) + .get('/api/admin/test', () => { + return { message: 'Admin routes test works!' } + }) .post('/api/admin/verify-dns', async () => { try { await dnsVerifier.trigger() diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts new file mode 100644 index 0000000..6043274 --- /dev/null +++ b/src/lib/admin-auth.ts @@ -0,0 +1,208 @@ +// Admin authentication system +import { db } from './db' +import { randomBytes, createHash } from 'crypto' + +interface AdminUser { + id: number + username: string + password_hash: string + created_at: Date +} + +interface AdminSession { + sessionId: string + username: string + expiresAt: Date +} + +// In-memory session storage +const sessions = new Map() +const SESSION_DURATION = 24 * 60 * 60 * 1000 // 24 hours + +// Hash password using SHA-256 with salt +function hashPassword(password: string, salt: string): string { + return createHash('sha256').update(password + salt).digest('hex') +} + +// Generate random salt +function generateSalt(): string { + return randomBytes(32).toString('hex') +} + +// Generate session ID +function generateSessionId(): string { + return randomBytes(32).toString('hex') +} + +// Generate a secure random password +function generatePassword(length: number = 20): string { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*' + const bytes = randomBytes(length) + let password = '' + for (let i = 0; i < length; i++) { + password += chars[bytes[i] % chars.length] + } + return password +} + +export const adminAuth = { + // Initialize admin table + async init() { + await db` + CREATE TABLE IF NOT EXISTS admin_users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ` + }, + + // Check if any admin exists + async hasAdmin(): Promise { + const result = await db`SELECT COUNT(*) as count FROM admin_users` + return result[0].count > 0 + }, + + // Create admin user + async createAdmin(username: string, password: string): Promise { + try { + const salt = generateSalt() + const passwordHash = hashPassword(password, salt) + + await db`INSERT INTO admin_users (username, password_hash, salt) VALUES (${username}, ${passwordHash}, ${salt})` + + console.log(`✓ Admin user '${username}' created successfully`) + return true + } catch (error) { + console.error('Failed to create admin user:', error) + return false + } + }, + + // Verify admin credentials + async verify(username: string, password: string): Promise { + try { + const result = await db`SELECT password_hash, salt FROM admin_users WHERE username = ${username}` + + if (result.length === 0) { + return false + } + + const { password_hash, salt } = result[0] + const hash = hashPassword(password, salt as string) + return hash === password_hash + } catch (error) { + console.error('Failed to verify admin:', error) + return false + } + }, + + // Create session + createSession(username: string): string { + const sessionId = generateSessionId() + const expiresAt = new Date(Date.now() + SESSION_DURATION) + + sessions.set(sessionId, { + sessionId, + username, + expiresAt + }) + + // Clean up expired sessions + this.cleanupSessions() + + return sessionId + }, + + // Verify session + verifySession(sessionId: string): AdminSession | null { + const session = sessions.get(sessionId) + + if (!session) { + return null + } + + if (session.expiresAt.getTime() < Date.now()) { + sessions.delete(sessionId) + return null + } + + return session + }, + + // Delete session + deleteSession(sessionId: string) { + sessions.delete(sessionId) + }, + + // Cleanup expired sessions + cleanupSessions() { + const now = Date.now() + for (const [sessionId, session] of sessions.entries()) { + if (session.expiresAt.getTime() < now) { + sessions.delete(sessionId) + } + } + } +} + +// Prompt for admin creation on startup +export async function promptAdminSetup() { + await adminAuth.init() + + const hasAdmin = await adminAuth.hasAdmin() + if (hasAdmin) { + return + } + + // Skip prompt if SKIP_ADMIN_SETUP is set + if (process.env.SKIP_ADMIN_SETUP === 'true') { + console.log('\n╔════════════════════════════════════════════════════════════════╗') + console.log('║ ADMIN SETUP REQUIRED ║') + console.log('╚════════════════════════════════════════════════════════════════╝\n') + console.log('No admin user found.') + console.log('Create one with: bun run create-admin.ts\n') + return + } + + console.log('\n===========================================') + console.log(' ADMIN SETUP REQUIRED') + console.log('===========================================\n') + console.log('No admin user found. Creating one automatically...\n') + + // Auto-generate admin credentials with random password + const username = 'admin' + const password = generatePassword(20) + + await adminAuth.createAdmin(username, password) + + console.log('╔════════════════════════════════════════════════════════════════╗') + console.log('║ ADMIN USER CREATED SUCCESSFULLY ║') + console.log('╚════════════════════════════════════════════════════════════════╝\n') + console.log(`Username: ${username}`) + console.log(`Password: ${password}`) + console.log('\n⚠️ IMPORTANT: Save this password securely!') + console.log('This password will not be shown again.\n') + console.log('Change it with: bun run change-admin-password.ts admin NEW_PASSWORD\n') +} + +// Elysia middleware to protect admin routes +export function requireAdmin({ cookie, set }: any) { + const sessionId = cookie.admin_session?.value + + if (!sessionId) { + set.status = 401 + return { error: 'Unauthorized' } + } + + const session = adminAuth.verifySession(sessionId) + if (!session) { + set.status = 401 + return { error: 'Unauthorized' } + } + + // Session is valid, continue + return +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 141c3b5..81e67de 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -527,3 +527,13 @@ export const upsertSite = async (did: string, rkey: string, displayName?: string return { success: false, error: err }; } }; + +export const deleteSite = async (did: string, rkey: string) => { + try { + await db`DELETE FROM sites WHERE did = ${did} AND rkey = ${rkey}`; + return { success: true }; + } catch (err) { + console.error('Failed to delete site', err); + return { success: false, error: err }; + } +}; diff --git a/src/lib/observability.ts b/src/lib/observability.ts new file mode 100644 index 0000000..d757099 --- /dev/null +++ b/src/lib/observability.ts @@ -0,0 +1,337 @@ +// DIY Observability - Logs, Metrics, and Error Tracking +import type { Context } from 'elysia' + +// Types +export interface LogEntry { + id: string + timestamp: Date + level: 'info' | 'warn' | 'error' | 'debug' + message: string + service: string + context?: Record + traceId?: string + eventType?: string +} + +export interface ErrorEntry { + id: string + timestamp: Date + message: string + stack?: string + service: string + context?: Record + count: number // How many times this error occurred + lastSeen: Date +} + +export interface MetricEntry { + timestamp: Date + path: string + method: string + statusCode: number + duration: number // in milliseconds + service: string +} + +export interface DatabaseStats { + totalSites: number + totalDomains: number + totalCustomDomains: number + recentSites: any[] + recentDomains: any[] +} + +// In-memory storage with rotation +const MAX_LOGS = 5000 +const MAX_ERRORS = 500 +const MAX_METRICS = 10000 + +const logs: LogEntry[] = [] +const errors: Map = new Map() +const metrics: MetricEntry[] = [] + +// Helper to generate unique IDs +let logCounter = 0 +let errorCounter = 0 + +function generateId(prefix: string, counter: number): string { + return `${prefix}-${Date.now()}-${counter}` +} + +// Helper to extract event type from message +function extractEventType(message: string): string | undefined { + const match = message.match(/^\[([^\]]+)\]/) + return match ? match[1] : undefined +} + +// Log collector +export const logCollector = { + log(level: LogEntry['level'], message: string, service: string, context?: Record, traceId?: string) { + const entry: LogEntry = { + id: generateId('log', logCounter++), + timestamp: new Date(), + level, + message, + service, + context, + traceId, + eventType: extractEventType(message) + } + + logs.unshift(entry) + + // Rotate if needed + if (logs.length > MAX_LOGS) { + logs.splice(MAX_LOGS) + } + + // Also log to console for compatibility + const contextStr = context ? ` ${JSON.stringify(context)}` : '' + const traceStr = traceId ? ` [trace:${traceId}]` : '' + console[level === 'debug' ? 'log' : level](`[${service}] ${message}${contextStr}${traceStr}`) + }, + + info(message: string, service: string, context?: Record, traceId?: string) { + this.log('info', message, service, context, traceId) + }, + + warn(message: string, service: string, context?: Record, traceId?: string) { + this.log('warn', message, service, context, traceId) + }, + + error(message: string, service: string, error?: any, context?: Record, traceId?: string) { + const ctx = { ...context } + if (error instanceof Error) { + ctx.error = error.message + ctx.stack = error.stack + } else if (error) { + ctx.error = String(error) + } + this.log('error', message, service, ctx, traceId) + + // Also track in errors + errorTracker.track(message, service, error, context) + }, + + debug(message: string, service: string, context?: Record, traceId?: string) { + if (process.env.NODE_ENV !== 'production') { + this.log('debug', message, service, context, traceId) + } + }, + + getLogs(filter?: { level?: string; service?: string; limit?: number; search?: string; eventType?: string }) { + let filtered = [...logs] + + if (filter?.level) { + filtered = filtered.filter(log => log.level === filter.level) + } + + if (filter?.service) { + filtered = filtered.filter(log => log.service === filter.service) + } + + if (filter?.eventType) { + filtered = filtered.filter(log => log.eventType === filter.eventType) + } + + if (filter?.search) { + const search = filter.search.toLowerCase() + filtered = filtered.filter(log => + log.message.toLowerCase().includes(search) || + (log.context ? JSON.stringify(log.context).toLowerCase().includes(search) : false) + ) + } + + const limit = filter?.limit || 100 + return filtered.slice(0, limit) + }, + + clear() { + logs.length = 0 + } +} + +// Error tracker with deduplication +export const errorTracker = { + track(message: string, service: string, error?: any, context?: Record) { + const key = `${service}:${message}` + + const existing = errors.get(key) + if (existing) { + existing.count++ + existing.lastSeen = new Date() + if (context) { + existing.context = { ...existing.context, ...context } + } + } else { + const entry: ErrorEntry = { + id: generateId('error', errorCounter++), + timestamp: new Date(), + message, + service, + context, + count: 1, + lastSeen: new Date() + } + + if (error instanceof Error) { + entry.stack = error.stack + } + + errors.set(key, entry) + + // Rotate if needed + if (errors.size > MAX_ERRORS) { + const oldest = Array.from(errors.keys())[0] + errors.delete(oldest) + } + } + }, + + getErrors(filter?: { service?: string; limit?: number }) { + let filtered = Array.from(errors.values()) + + if (filter?.service) { + filtered = filtered.filter(err => err.service === filter.service) + } + + // Sort by last seen (most recent first) + filtered.sort((a, b) => b.lastSeen.getTime() - a.lastSeen.getTime()) + + const limit = filter?.limit || 100 + return filtered.slice(0, limit) + }, + + clear() { + errors.clear() + } +} + +// Metrics collector +export const metricsCollector = { + recordRequest(path: string, method: string, statusCode: number, duration: number, service: string) { + const entry: MetricEntry = { + timestamp: new Date(), + path, + method, + statusCode, + duration, + service + } + + metrics.unshift(entry) + + // Rotate if needed + if (metrics.length > MAX_METRICS) { + metrics.splice(MAX_METRICS) + } + }, + + getMetrics(filter?: { service?: string; timeWindow?: number }) { + let filtered = [...metrics] + + if (filter?.service) { + filtered = filtered.filter(m => m.service === filter.service) + } + + if (filter?.timeWindow) { + const cutoff = Date.now() - filter.timeWindow + filtered = filtered.filter(m => m.timestamp.getTime() > cutoff) + } + + return filtered + }, + + getStats(service?: string, timeWindow: number = 3600000) { + const filtered = this.getMetrics({ service, timeWindow }) + + if (filtered.length === 0) { + return { + totalRequests: 0, + avgDuration: 0, + p50Duration: 0, + p95Duration: 0, + p99Duration: 0, + errorRate: 0, + requestsPerMinute: 0 + } + } + + const durations = filtered.map(m => m.duration).sort((a, b) => a - b) + const totalDuration = durations.reduce((sum, d) => sum + d, 0) + const errors = filtered.filter(m => m.statusCode >= 400).length + + const p50 = durations[Math.floor(durations.length * 0.5)] + const p95 = durations[Math.floor(durations.length * 0.95)] + const p99 = durations[Math.floor(durations.length * 0.99)] + + const timeWindowMinutes = timeWindow / 60000 + + return { + totalRequests: filtered.length, + avgDuration: Math.round(totalDuration / filtered.length), + p50Duration: Math.round(p50), + p95Duration: Math.round(p95), + p99Duration: Math.round(p99), + errorRate: (errors / filtered.length) * 100, + requestsPerMinute: Math.round(filtered.length / timeWindowMinutes) + } + }, + + clear() { + metrics.length = 0 + } +} + +// Elysia middleware for request timing +export function observabilityMiddleware(service: string) { + return { + beforeHandle: ({ request }: any) => { + // Store start time on request object + (request as any).__startTime = Date.now() + }, + afterHandle: ({ request, set }: any) => { + const duration = Date.now() - ((request as any).__startTime || Date.now()) + const url = new URL(request.url) + + metricsCollector.recordRequest( + url.pathname, + request.method, + set.status || 200, + duration, + service + ) + }, + onError: ({ request, error, set }: any) => { + const duration = Date.now() - ((request as any).__startTime || Date.now()) + const url = new URL(request.url) + + metricsCollector.recordRequest( + url.pathname, + request.method, + set.status || 500, + duration, + service + ) + + logCollector.error( + `Request failed: ${request.method} ${url.pathname}`, + service, + error, + { statusCode: set.status || 500 } + ) + } + } +} + +// Export singleton logger for easy access +export const logger = { + info: (message: string, context?: Record) => + logCollector.info(message, 'main-app', context), + warn: (message: string, context?: Record) => + logCollector.warn(message, 'main-app', context), + error: (message: string, error?: any, context?: Record) => + logCollector.error(message, 'main-app', error, context), + debug: (message: string, context?: Record) => + logCollector.debug(message, 'main-app', context) +} diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..747c4d7 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,305 @@ +// Admin API routes +import { Elysia, t } from 'elysia' +import { adminAuth, requireAdmin } from '../lib/admin-auth' +import { logCollector, errorTracker, metricsCollector } from '../lib/observability' +import { db } from '../lib/db' + +export const adminRoutes = () => + new Elysia({ prefix: '/api/admin' }) + // Login + .post( + '/login', + async ({ body, cookie, set }) => { + const { username, password } = body + + const valid = await adminAuth.verify(username, password) + if (!valid) { + set.status = 401 + return { error: 'Invalid credentials' } + } + + const sessionId = adminAuth.createSession(username) + + // Set cookie + cookie.admin_session.set({ + value: sessionId, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 24 * 60 * 60 // 24 hours + }) + + return { success: true } + }, + { + body: t.Object({ + username: t.String(), + password: t.String() + }) + } + ) + + // Logout + .post('/logout', ({ cookie }) => { + const sessionId = cookie.admin_session?.value + if (sessionId && typeof sessionId === 'string') { + adminAuth.deleteSession(sessionId) + } + cookie.admin_session.remove() + return { success: true } + }) + + // Check auth status + .get('/status', ({ cookie }) => { + const sessionId = cookie.admin_session?.value + if (!sessionId || typeof sessionId !== 'string') { + return { authenticated: false } + } + + const session = adminAuth.verifySession(sessionId) + if (!session) { + return { authenticated: false } + } + + return { + authenticated: true, + username: session.username + } + }) + + // Get logs (protected) + .get('/logs', async ({ query, cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + const filter: any = {} + + if (query.level) filter.level = query.level + if (query.service) filter.service = query.service + if (query.search) filter.search = query.search + if (query.eventType) filter.eventType = query.eventType + if (query.limit) filter.limit = parseInt(query.limit as string) + + // Get logs from main app + const mainLogs = logCollector.getLogs(filter) + + // Get logs from hosting service + let hostingLogs: any[] = [] + try { + const hostingPort = process.env.HOSTING_PORT || '3001' + const params = new URLSearchParams() + if (query.level) params.append('level', query.level as string) + if (query.service) params.append('service', query.service as string) + if (query.search) params.append('search', query.search as string) + if (query.eventType) params.append('eventType', query.eventType as string) + params.append('limit', String(filter.limit || 100)) + + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`) + if (response.ok) { + const data = await response.json() + hostingLogs = data.logs + } + } catch (err) { + // Hosting service might not be running + } + + // Merge and sort by timestamp + const allLogs = [...mainLogs, ...hostingLogs].sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + + return { logs: allLogs.slice(0, filter.limit || 100) } + }) + + // Get errors (protected) + .get('/errors', async ({ query, cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + const filter: any = {} + + if (query.service) filter.service = query.service + if (query.limit) filter.limit = parseInt(query.limit as string) + + // Get errors from main app + const mainErrors = errorTracker.getErrors(filter) + + // Get errors from hosting service + let hostingErrors: any[] = [] + try { + const hostingPort = process.env.HOSTING_PORT || '3001' + const params = new URLSearchParams() + if (query.service) params.append('service', query.service as string) + params.append('limit', String(filter.limit || 100)) + + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`) + if (response.ok) { + const data = await response.json() + hostingErrors = data.errors + } + } catch (err) { + // Hosting service might not be running + } + + // Merge and sort by last seen + const allErrors = [...mainErrors, ...hostingErrors].sort((a, b) => + new Date(b.lastSeen).getTime() - new Date(a.lastSeen).getTime() + ) + + return { errors: allErrors.slice(0, filter.limit || 100) } + }) + + // Get metrics (protected) + .get('/metrics', async ({ query, cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + const timeWindow = query.timeWindow + ? parseInt(query.timeWindow as string) + : 3600000 // 1 hour default + + const mainAppStats = metricsCollector.getStats('main-app', timeWindow) + const overallStats = metricsCollector.getStats(undefined, timeWindow) + + // Get hosting service stats from its own endpoint + let hostingServiceStats = { + totalRequests: 0, + avgDuration: 0, + p50Duration: 0, + p95Duration: 0, + p99Duration: 0, + errorRate: 0, + requestsPerMinute: 0 + } + + try { + const hostingPort = process.env.HOSTING_PORT || '3001' + const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`) + if (response.ok) { + const data = await response.json() + hostingServiceStats = data.stats + } + } catch (err) { + // Hosting service might not be running + } + + return { + overall: overallStats, + mainApp: mainAppStats, + hostingService: hostingServiceStats, + timeWindow + } + }) + + // Get database stats (protected) + .get('/database', async ({ cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + try { + // Get total counts + const allSitesResult = await db`SELECT COUNT(*) as count FROM sites` + const wispSubdomainsResult = await db`SELECT COUNT(*) as count FROM domains WHERE domain LIKE '%.wisp.place'` + const customDomainsResult = await db`SELECT COUNT(*) as count FROM custom_domains WHERE verified = true` + + // Get recent sites (including those without domains) + const recentSites = await db` + SELECT + s.did, + s.rkey, + s.display_name, + s.created_at, + d.domain as subdomain + FROM sites s + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' + ORDER BY s.created_at DESC + LIMIT 10 + ` + + // Get recent domains + const recentDomains = await db`SELECT domain, did, rkey, verified, created_at FROM custom_domains ORDER BY created_at DESC LIMIT 10` + + return { + stats: { + totalSites: allSitesResult[0].count, + totalWispSubdomains: wispSubdomainsResult[0].count, + totalCustomDomains: customDomainsResult[0].count + }, + recentSites: recentSites, + recentDomains: recentDomains + } + } catch (error) { + set.status = 500 + return { + error: 'Failed to fetch database stats', + message: error instanceof Error ? error.message : String(error) + } + } + }) + + // Get sites listing (protected) + .get('/sites', async ({ query, cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + const limit = query.limit ? parseInt(query.limit as string) : 50 + const offset = query.offset ? parseInt(query.offset as string) : 0 + + try { + const sites = await db` + SELECT + s.did, + s.rkey, + s.display_name, + s.created_at, + d.domain as subdomain + FROM sites s + LEFT JOIN domains d ON s.did = d.did AND s.rkey = d.rkey AND d.domain LIKE '%.wisp.place' + ORDER BY s.created_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + + const customDomains = await db` + SELECT + domain, + did, + rkey, + verified, + created_at + FROM custom_domains + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + + return { + sites: sites, + customDomains: customDomains + } + } catch (error) { + set.status = 500 + return { + error: 'Failed to fetch sites', + message: error instanceof Error ? error.message : String(error) + } + } + }) + + // Get system health (protected) + .get('/health', ({ cookie, set }) => { + const check = requireAdmin({ cookie, set }) + if (check) return check + + const uptime = process.uptime() + const memory = process.memoryUsage() + + return { + uptime: Math.floor(uptime), + memory: { + heapUsed: Math.round(memory.heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(memory.heapTotal / 1024 / 1024), // MB + rss: Math.round(memory.rss / 1024 / 1024) // MB + }, + timestamp: new Date().toISOString() + } + }) + diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 03aa704..6595f29 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -3,18 +3,23 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node' import { getSitesByDid, getDomainByDid } from '../lib/db' import { syncSitesFromPDS } from '../lib/sync-sites' import { authenticateRequest } from '../lib/wisp-auth' -import { logger } from '../lib/logger' +import { logger } from '../lib/observability' export const authRoutes = (client: NodeOAuthClient) => new Elysia() .post('/api/auth/signin', async (c) => { + let handle = 'unknown' try { - const { handle } = await c.request.json() + const body = c.body as { handle: string } + handle = body.handle + logger.info('Sign-in attempt', { handle }) const state = crypto.randomUUID() const url = await client.authorize(handle, { state }) + logger.info('Authorization URL generated', { handle }) return { url: url.toString() } } catch (err) { - logger.error('[Auth] Signin error', err) - return { error: 'Authentication failed' } + logger.error('Signin error', err, { handle }) + console.error('[Auth] Full error:', err) + return { error: 'Authentication failed', details: err instanceof Error ? err.message : String(err) } } }) .get('/api/auth/callback', async (c) => { diff --git a/src/routes/site.ts b/src/routes/site.ts new file mode 100644 index 0000000..7fed391 --- /dev/null +++ b/src/routes/site.ts @@ -0,0 +1,60 @@ +import { Elysia } from 'elysia' +import { requireAuth } from '../lib/wisp-auth' +import { NodeOAuthClient } from '@atproto/oauth-client-node' +import { Agent } from '@atproto/api' +import { deleteSite } from '../lib/db' +import { logger } from '../lib/logger' + +export const siteRoutes = (client: NodeOAuthClient) => + new Elysia({ prefix: '/api/site' }) + .derive(async ({ cookie }) => { + const auth = await requireAuth(client, cookie) + return { auth } + }) + .delete('/:rkey', async ({ params, auth }) => { + const { rkey } = params + + if (!rkey) { + return { + success: false, + error: 'Site rkey is required' + } + } + + try { + // Create agent with OAuth session + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) + + // Delete the record from AT Protocol + try { + await agent.com.atproto.repo.deleteRecord({ + repo: auth.did, + collection: 'place.wisp.fs', + rkey: rkey + }) + logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`) + } catch (err) { + logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err) + throw new Error('Failed to delete site from AT Protocol') + } + + // Delete from database + const result = await deleteSite(auth.did, rkey) + if (!result.success) { + throw new Error('Failed to delete site from database') + } + + logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`) + + return { + success: true, + message: 'Site deleted successfully' + } + } catch (err) { + logger.error('[Site] Delete error', err) + return { + success: false, + error: err instanceof Error ? err.message : 'Failed to delete site' + } + } + }) diff --git a/src/routes/wisp.ts b/src/routes/wisp.ts index e75d41e..1db5c80 100644 --- a/src/routes/wisp.ts +++ b/src/routes/wisp.ts @@ -12,8 +12,9 @@ import { compressFile } from '../lib/wisp-utils' import { upsertSite } from '../lib/db' -import { logger } from '../lib/logger' +import { logger } from '../lib/observability' import { validateRecord } from '../lexicon/types/place/wisp/fs' +import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants' function isValidSiteName(siteName: string): boolean { if (!siteName || typeof siteName !== 'string') return false; @@ -112,51 +113,13 @@ export const wispRoutes = (client: NodeOAuthClient) => const uploadedFiles: UploadedFile[] = []; const skippedFiles: Array<{ name: string; reason: string }> = []; - // Define allowed file extensions for static site hosting - const allowedExtensions = new Set([ - // HTML - '.html', '.htm', - // CSS - '.css', - // JavaScript - '.js', '.mjs', '.jsx', '.ts', '.tsx', - // Images - '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', - // Fonts - '.woff', '.woff2', '.ttf', '.otf', '.eot', - // Documents - '.pdf', '.txt', - // JSON (for config files, but not .map files) - '.json', - // Audio/Video - '.mp3', '.mp4', '.webm', '.ogg', '.wav', - // Other web assets - '.xml', '.rss', '.atom' - ]); - - // Files to explicitly exclude - const excludedFiles = new Set([ - '.map', '.DS_Store', 'Thumbs.db' - ]); + for (let i = 0; i < fileArray.length; i++) { const file = fileArray[i]; - const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); - - // Skip excluded files - if (excludedFiles.has(fileExtension)) { - skippedFiles.push({ name: file.name, reason: 'excluded file type' }); - continue; - } - - // Skip files that aren't in allowed extensions - if (!allowedExtensions.has(fileExtension)) { - skippedFiles.push({ name: file.name, reason: 'unsupported file type' }); - continue; - } // Skip files that are too large (limit to 100MB per file) - const maxSize = 100 * 1024 * 1024; // 100MB + const maxSize = MAX_FILE_SIZE; // 100MB if (file.size > maxSize) { skippedFiles.push({ name: file.name, @@ -169,43 +132,36 @@ export const wispRoutes = (client: NodeOAuthClient) => const originalContent = Buffer.from(arrayBuffer); const originalMimeType = file.type || 'application/octet-stream'; - // Determine if we should compress this file - const shouldCompress = shouldCompressFile(originalMimeType); - - if (shouldCompress) { - const compressedContent = compressFile(originalContent); - // Base64 encode the gzipped content to prevent PDS content sniffing - const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); - const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); - logger.info(`[Wisp] Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); - - uploadedFiles.push({ - name: file.name, - content: base64Content, - mimeType: originalMimeType, - size: base64Content.length, - compressed: true, - originalMimeType - }); - } else { - uploadedFiles.push({ - name: file.name, - content: originalContent, - mimeType: originalMimeType, - size: file.size, - compressed: false - }); - } + // Compress and base64 encode ALL files + const compressedContent = compressFile(originalContent); + // Base64 encode the gzipped content to prevent PDS content sniffing + const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8'); + const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); + logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); + + uploadedFiles.push({ + name: file.name, + content: base64Content, + mimeType: originalMimeType, + size: base64Content.length, + compressed: true, + originalMimeType + }); } // Check total size limit (300MB) const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); - const maxTotalSize = 300 * 1024 * 1024; // 300MB + const maxTotalSize = MAX_SITE_SIZE; // 300MB if (totalSize > maxTotalSize) { throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); } + // Check file count limit (2000 files) + if (uploadedFiles.length > MAX_FILE_COUNT) { + throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`); + } + if (uploadedFiles.length === 0) { // Create empty manifest @@ -264,7 +220,7 @@ export const wispRoutes = (client: NodeOAuthClient) => : file.mimeType; const compressionInfo = file.compressed ? ' (gzipped)' : ''; - logger.info(`[Wisp] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); + logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`); const uploadResult = await agent.com.atproto.repo.uploadBlob( file.content, @@ -291,7 +247,7 @@ export const wispRoutes = (client: NodeOAuthClient) => returnedMimeType: returnedBlobRef.mimeType }; } catch (uploadError) { - logger.error('[Wisp] Upload failed for file', uploadError); + logger.error('Upload failed for file', uploadError); throw uploadError; } }); @@ -321,8 +277,7 @@ export const wispRoutes = (client: NodeOAuthClient) => record: manifest }); } catch (putRecordError: any) { - logger.error('[Wisp] Failed to create record on PDS'); - logger.error('[Wisp] Record creation error', putRecordError); + logger.error('Failed to create record on PDS', putRecordError); throw putRecordError; } @@ -342,11 +297,10 @@ export const wispRoutes = (client: NodeOAuthClient) => return result; } catch (error) { - logger.error('[Wisp] Upload error', error); - logger.errorWithContext('[Wisp] Upload error details', { + logger.error('Upload error', error, { message: error instanceof Error ? error.message : 'Unknown error', name: error instanceof Error ? error.name : undefined - }, error); + }); throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); } } -- 2.54.0