Compare commits
2 Commits
75008b623a
...
9ceab037ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ceab037ca | |||
| b014f9b44c |
@@ -13,6 +13,7 @@ End-to-end encryption library implementing the Signal Protocol (X3DH + Double Ra
|
|||||||
- **Constant-time comparisons** and **memory zeroization** for hardened operation
|
- **Constant-time comparisons** and **memory zeroization** for hardened operation
|
||||||
- **Binary wire format** that's significantly smaller than JSON
|
- **Binary wire format** that's significantly smaller than JSON
|
||||||
- **Crash-safe** — sessions survive container restarts, power outages, SIGKILL
|
- **Crash-safe** — sessions survive container restarts, power outages, SIGKILL
|
||||||
|
- **Live observability** — bundled dashboard SPA + embeddable React widgets to see what's happening between every step
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -68,13 +69,16 @@ const plaintext = await manager.decrypt('alice', incomingEnvelope);
|
|||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `@shade/core` | Protocol logic (X3DH, Double Ratchet, session manager, errors) |
|
| `@shade/core` | Protocol logic (X3DH, Double Ratchet, session manager, errors, events) |
|
||||||
| `@shade/crypto-web` | SubtleCrypto + @noble/curves provider, in-memory storage |
|
| `@shade/crypto-web` | SubtleCrypto + @noble/curves provider, in-memory storage |
|
||||||
| `@shade/storage-sqlite` | Persistent SQLite storage (zero-config, bun:sqlite) |
|
| `@shade/storage-sqlite` | Persistent SQLite storage (zero-config, bun:sqlite) |
|
||||||
| `@shade/storage-postgres` | PostgreSQL storage with Drizzle for shared databases |
|
| `@shade/storage-postgres` | PostgreSQL storage with Drizzle for shared databases |
|
||||||
| `@shade/server` | Prekey server (Hono routes, auth, rate limit, health, metrics) |
|
| `@shade/server` | Prekey server (Hono routes, auth, rate limit, health, metrics) |
|
||||||
| `@shade/transport` | HTTP + WebSocket transport wrappers with auto-encryption |
|
| `@shade/transport` | HTTP + WebSocket transport wrappers with auto-encryption |
|
||||||
| `@shade/proto` | Compact binary wire format (smaller than JSON) |
|
| `@shade/proto` | Compact binary wire format (smaller than JSON) |
|
||||||
|
| `@shade/observer` | Live debugger backend (snapshot, SSE, dashboard) — see [README](./packages/shade-observer/README.md) |
|
||||||
|
| `@shade/widgets` | Embeddable React widgets — see [README](./packages/shade-widgets/README.md) |
|
||||||
|
| `@shade/dashboard` | Standalone dashboard SPA bundled into the observer |
|
||||||
|
|
||||||
## Security properties
|
## Security properties
|
||||||
|
|
||||||
|
|||||||
282
bun.lock
282
bun.lock
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"hono": "^4.12.12",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.3.11",
|
"bun-types": "^1.3.11",
|
||||||
@@ -28,6 +29,33 @@
|
|||||||
"@shade/core": "workspace:*",
|
"@shade/core": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/shade-dashboard": {
|
||||||
|
"name": "@shade/dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@shade/widgets": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages/shade-observer": {
|
||||||
|
"name": "@shade/observer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@shade/core": "workspace:*",
|
||||||
|
"@shade/server": "workspace:*",
|
||||||
|
"hono": "^4.12.12",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shade/crypto-web": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/shade-proto": {
|
"packages/shade-proto": {
|
||||||
"name": "@shade/proto",
|
"name": "@shade/proto",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -78,16 +106,186 @@
|
|||||||
"@shade/server": "workspace:*",
|
"@shade/server": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/shade-widgets": {
|
||||||
|
"name": "@shade/widgets",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
"@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||||
|
|
||||||
"@shade/core": ["@shade/core@workspace:packages/shade-core"],
|
"@shade/core": ["@shade/core@workspace:packages/shade-core"],
|
||||||
|
|
||||||
"@shade/crypto-web": ["@shade/crypto-web@workspace:packages/shade-crypto-web"],
|
"@shade/crypto-web": ["@shade/crypto-web@workspace:packages/shade-crypto-web"],
|
||||||
|
|
||||||
|
"@shade/dashboard": ["@shade/dashboard@workspace:packages/shade-dashboard"],
|
||||||
|
|
||||||
|
"@shade/observer": ["@shade/observer@workspace:packages/shade-observer"],
|
||||||
|
|
||||||
"@shade/proto": ["@shade/proto@workspace:packages/shade-proto"],
|
"@shade/proto": ["@shade/proto@workspace:packages/shade-proto"],
|
||||||
|
|
||||||
"@shade/server": ["@shade/server@workspace:packages/shade-server"],
|
"@shade/server": ["@shade/server@workspace:packages/shade-server"],
|
||||||
@@ -98,16 +296,100 @@
|
|||||||
|
|
||||||
"@shade/transport": ["@shade/transport@workspace:packages/shade-transport"],
|
"@shade/transport": ["@shade/transport@workspace:packages/shade-transport"],
|
||||||
|
|
||||||
|
"@shade/widgets": ["@shade/widgets@workspace:packages/shade-widgets"],
|
||||||
|
|
||||||
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
|
|
||||||
|
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||||
|
|
||||||
|
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||||
|
|
||||||
|
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ services:
|
|||||||
- PORT=3900
|
- PORT=3900
|
||||||
- SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db
|
- SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db
|
||||||
- SHADE_LOG_LEVEL=info
|
- SHADE_LOG_LEVEL=info
|
||||||
|
# Optional: enable the live observer dashboard at /shade-observer/dashboard/
|
||||||
|
# Token must be at least 16 characters. Use a real secret in production.
|
||||||
|
# - SHADE_OBSERVER_TOKEN=change-me-must-be-at-least-16-chars
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:3900/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:3900/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
21
examples/06-observer-dashboard/README.md
Normal file
21
examples/06-observer-dashboard/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Example 06: Observer Dashboard
|
||||||
|
|
||||||
|
Spins up a Shade prekey server with the observer attached, runs Alice ↔ Bob conversations in a loop, and serves the dashboard at `http://localhost:3901/dashboard/`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/shade-dashboard && bun run build # build the SPA once
|
||||||
|
cd ../../examples/06-observer-dashboard
|
||||||
|
bun run main.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:3901/dashboard/` and enter the bearer token printed in the console.
|
||||||
|
|
||||||
|
## What you'll see
|
||||||
|
|
||||||
|
- Identity card with the demo's fingerprint
|
||||||
|
- Live session between Alice and Bob with message counters incrementing
|
||||||
|
- Recent activity feed showing every X3DH handshake, encryption, and ratchet step
|
||||||
|
- Prekey stock decreasing as Alice consumes them
|
||||||
|
- Server stats updating in real time
|
||||||
110
examples/06-observer-dashboard/main.ts
Normal file
110
examples/06-observer-dashboard/main.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import {
|
||||||
|
ShadeSessionManager,
|
||||||
|
ShadeEventEmitter,
|
||||||
|
} from '../../packages/shade-core/src/index.js';
|
||||||
|
import {
|
||||||
|
SubtleCryptoProvider,
|
||||||
|
MemoryStorage,
|
||||||
|
} from '../../packages/shade-crypto-web/src/index.js';
|
||||||
|
import {
|
||||||
|
createPrekeyServer,
|
||||||
|
MemoryPrekeyStore,
|
||||||
|
PrekeyServerEvents,
|
||||||
|
} from '../../packages/shade-server/src/index.js';
|
||||||
|
import { createObserver } from '../../packages/shade-observer/src/index.js';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
const TOKEN = 'demo-token-must-be-at-least-16-chars';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('━━━ Shade Observer Demo ━━━\n');
|
||||||
|
|
||||||
|
// ─── Wire up event emitters ──────────────────────────
|
||||||
|
const clientEvents = new ShadeEventEmitter();
|
||||||
|
const serverEvents = new PrekeyServerEvents();
|
||||||
|
|
||||||
|
// ─── Two demo session managers ───────────────────────
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events: clientEvents });
|
||||||
|
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events: clientEvents });
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
|
||||||
|
// ─── Prekey server with events ───────────────────────
|
||||||
|
const prekeyApp = createPrekeyServer({
|
||||||
|
crypto,
|
||||||
|
store: new MemoryPrekeyStore(),
|
||||||
|
disableRateLimit: true,
|
||||||
|
events: serverEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Observer ────────────────────────────────────────
|
||||||
|
const observer = createObserver({
|
||||||
|
token: TOKEN,
|
||||||
|
clientEvents,
|
||||||
|
serverEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mount everything in one Hono app ────────────────
|
||||||
|
const app = new Hono();
|
||||||
|
app.route('/prekey', prekeyApp);
|
||||||
|
app.route('/', observer);
|
||||||
|
|
||||||
|
const PORT = 3901;
|
||||||
|
Bun.serve({ port: PORT, fetch: app.fetch });
|
||||||
|
|
||||||
|
console.log(`✓ Server listening on http://localhost:${PORT}`);
|
||||||
|
console.log(`✓ Dashboard: http://localhost:${PORT}/dashboard/`);
|
||||||
|
console.log(`✓ Token: ${TOKEN}\n`);
|
||||||
|
console.log('Open the dashboard, paste the token, and watch the live activity below…\n');
|
||||||
|
|
||||||
|
// ─── Establish Alice ↔ Bob session ───────────────────
|
||||||
|
await bob.generateOneTimePreKeys(20);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
// Inline a one-time prekey since we're not going through the prekey server
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(1);
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
|
||||||
|
// Initial exchange to establish bidirectional ratchet
|
||||||
|
const initEnv = await alice.encrypt('bob', 'init');
|
||||||
|
await bob.decrypt('alice', initEnv);
|
||||||
|
const initReply = await bob.encrypt('alice', 'init reply');
|
||||||
|
await alice.decrypt('bob', initReply);
|
||||||
|
|
||||||
|
// ─── Run a loop of encrypted messages ────────────────
|
||||||
|
const messages = [
|
||||||
|
"Hey Bob, can you see this?",
|
||||||
|
"Yes Alice, loud and clear.",
|
||||||
|
"Cool — every message has a fresh key.",
|
||||||
|
"And the dashboard shows it live.",
|
||||||
|
"Ratchet steps every time we switch direction.",
|
||||||
|
"Forward secrecy in action.",
|
||||||
|
];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const fromAlice = i % 2 === 0;
|
||||||
|
const sender = fromAlice ? alice : bob;
|
||||||
|
const receiver = fromAlice ? bob : alice;
|
||||||
|
const senderAddr = fromAlice ? 'bob' : 'alice';
|
||||||
|
const receiverAddr = fromAlice ? 'alice' : 'bob';
|
||||||
|
const text = messages[i % messages.length];
|
||||||
|
|
||||||
|
const env = await sender.encrypt(senderAddr, text);
|
||||||
|
await receiver.decrypt(receiverAddr, env);
|
||||||
|
console.log(` [${i + 1}] ${fromAlice ? 'Alice' : 'Bob '}: "${text}"`);
|
||||||
|
i++;
|
||||||
|
|
||||||
|
// Periodically replenish prekeys to show that activity in the dashboard
|
||||||
|
if (i % 8 === 0) {
|
||||||
|
await bob.ensurePreKeyStock(5, 20);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Loop error:', err);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
"@noble/hashes": "^2.0.1"
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"hono": "^4.12.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
packages/shade-core/src/events.ts
Normal file
130
packages/shade-core/src/events.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import type { CryptoProvider } from './crypto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shade event bus.
|
||||||
|
*
|
||||||
|
* Emits structural events for observability — NEVER plaintext, private keys,
|
||||||
|
* nonces, or other secret material. Identity references are SHA-256 truncated
|
||||||
|
* to 8 bytes (16 hex chars) for display only.
|
||||||
|
*
|
||||||
|
* Optional: pass a ShadeEventEmitter to ShadeSessionManager to enable.
|
||||||
|
* If not passed, all emits are no-ops with zero overhead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Event payload types ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ShadeEventBase {
|
||||||
|
/** Monotonic sequence number assigned at emit time */
|
||||||
|
seq: number;
|
||||||
|
/** Wall-clock timestamp in milliseconds */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of event names to their payload shape (without seq/timestamp) */
|
||||||
|
export interface ShadeEventMap {
|
||||||
|
'identity.initialized': { fingerprint: string; registrationId: number };
|
||||||
|
'identity.rotated': { newFingerprint: string };
|
||||||
|
'session.created': { address: string; remoteIdentityKeyHash: string };
|
||||||
|
'session.removed': { address: string };
|
||||||
|
'message.encrypted': { address: string; counter: number; ciphertextSize: number };
|
||||||
|
'message.decrypted': { address: string; counter: number; plaintextSize: number };
|
||||||
|
'ratchet.dh_step': { address: string };
|
||||||
|
'prekey.generated': { count: number; totalAfter: number };
|
||||||
|
'prekey.consumed': { keyId: number };
|
||||||
|
'signed_prekey.rotated': { oldKeyId: number; newKeyId: number };
|
||||||
|
'trust.pinned': { address: string; identityKeyHash: string };
|
||||||
|
'trust.changed': { address: string; oldKeyHash: string; newKeyHash: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShadeEventName = keyof ShadeEventMap;
|
||||||
|
|
||||||
|
export type ShadeEvent = {
|
||||||
|
[K in ShadeEventName]: ShadeEventBase & { name: K; data: ShadeEventMap[K] };
|
||||||
|
}[ShadeEventName];
|
||||||
|
|
||||||
|
export type ShadeEventListener = (event: ShadeEvent) => void;
|
||||||
|
|
||||||
|
// ─── EventEmitter implementation ─────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal typed event emitter for Shade observability.
|
||||||
|
*
|
||||||
|
* Supports subscribe (`on`), unsubscribe (`off`), and replay buffer
|
||||||
|
* for late subscribers.
|
||||||
|
*/
|
||||||
|
export class ShadeEventEmitter {
|
||||||
|
private listeners = new Set<ShadeEventListener>();
|
||||||
|
private nextSeq = 1;
|
||||||
|
private buffer: ShadeEvent[] = [];
|
||||||
|
private readonly maxBuffer: number;
|
||||||
|
|
||||||
|
constructor(options: { bufferSize?: number } = {}) {
|
||||||
|
this.maxBuffer = options.bufferSize ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to all events. Returns an unsubscribe function. */
|
||||||
|
on(listener: ShadeEventListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(listener: ShadeEventListener): void {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit a typed event. Adds seq + timestamp automatically. */
|
||||||
|
emit<K extends ShadeEventName>(name: K, data: ShadeEventMap[K]): void {
|
||||||
|
const event = {
|
||||||
|
seq: this.nextSeq++,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
} as ShadeEvent;
|
||||||
|
|
||||||
|
// Add to ring buffer
|
||||||
|
this.buffer.push(event);
|
||||||
|
if (this.buffer.length > this.maxBuffer) {
|
||||||
|
this.buffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners (catching throws so one bad listener doesn't break others)
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shade] Event listener threw:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all buffered events with seq > since (for SSE replay/reconnect) */
|
||||||
|
getBufferedSince(since: number): ShadeEvent[] {
|
||||||
|
return this.buffer.filter((e) => e.seq > since);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the most recent N events */
|
||||||
|
getRecent(n: number): ShadeEvent[] {
|
||||||
|
return this.buffer.slice(-n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current sequence number (next event will use this + 1) */
|
||||||
|
get currentSeq(): number {
|
||||||
|
return this.nextSeq - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hash helper for safe display ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a short, display-safe hash of a public key.
|
||||||
|
* Uses HKDF-SHA256 (since CryptoProvider has it) to produce 8 bytes,
|
||||||
|
* then formats as 16 hex characters.
|
||||||
|
*
|
||||||
|
* NEVER use this for security decisions — it's lossy and only for UI display.
|
||||||
|
*/
|
||||||
|
export async function shortHash(crypto: CryptoProvider, key: Uint8Array): Promise<string> {
|
||||||
|
const salt = new Uint8Array(32);
|
||||||
|
const info = new TextEncoder().encode('ShadeShortHash');
|
||||||
|
const hash = await crypto.hkdf(key, salt, info, 8);
|
||||||
|
return Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export * from './ratchet.js';
|
|||||||
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
|
export { ShadeSessionManager, GRACE_PERIOD_MS } from './session.js';
|
||||||
export * from './serialization.js';
|
export * from './serialization.js';
|
||||||
export * from './fingerprint.js';
|
export * from './fingerprint.js';
|
||||||
|
export * from './events.js';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { NoSessionError, UntrustedIdentityError } from './errors.js';
|
import { NoSessionError, UntrustedIdentityError } from './errors.js';
|
||||||
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
|
import { computeFingerprint, shortFingerprint } from './fingerprint.js';
|
||||||
import { constantTimeEqual } from './crypto.js';
|
import { constantTimeEqual } from './crypto.js';
|
||||||
|
import { ShadeEventEmitter, shortHash } from './events.js';
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
@@ -59,11 +60,20 @@ export class ShadeSessionManager {
|
|||||||
private identity: IdentityKeyPair | null = null;
|
private identity: IdentityKeyPair | null = null;
|
||||||
private registrationId: number = 0;
|
private registrationId: number = 0;
|
||||||
private currentSignedPreKeyId: number = 0;
|
private currentSignedPreKeyId: number = 0;
|
||||||
|
private readonly events?: ShadeEventEmitter;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly crypto: CryptoProvider,
|
private readonly crypto: CryptoProvider,
|
||||||
private readonly storage: StorageProvider,
|
private readonly storage: StorageProvider,
|
||||||
) {}
|
options: { events?: ShadeEventEmitter } = {},
|
||||||
|
) {
|
||||||
|
this.events = options.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the event emitter (if observability is enabled) */
|
||||||
|
getEvents(): ShadeEventEmitter | undefined {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Initialization ────────────────────────────────────────
|
// ─── Initialization ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -95,6 +105,15 @@ export class ShadeSessionManager {
|
|||||||
} else {
|
} else {
|
||||||
this.currentSignedPreKeyId = spk.keyId;
|
this.currentSignedPreKeyId = spk.keyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit identity initialization event
|
||||||
|
if (this.events) {
|
||||||
|
const fingerprint = await this.getIdentityFingerprint();
|
||||||
|
this.events.emit('identity.initialized', {
|
||||||
|
fingerprint,
|
||||||
|
registrationId: this.registrationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get our identity's DH public key (for addressing) */
|
/** Get our identity's DH public key (for addressing) */
|
||||||
@@ -168,6 +187,7 @@ export class ShadeSessionManager {
|
|||||||
*/
|
*/
|
||||||
async resetSession(address: string): Promise<void> {
|
async resetSession(address: string): Promise<void> {
|
||||||
await this.storage.removeSession(address);
|
await this.storage.removeSession(address);
|
||||||
|
this.events?.emit('session.removed', { address });
|
||||||
// Note: we keep the trusted identity; new session will verify against it.
|
// Note: we keep the trusted identity; new session will verify against it.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +197,16 @@ export class ShadeSessionManager {
|
|||||||
* After this, any pinned trust for this address is replaced.
|
* After this, any pinned trust for this address is replaced.
|
||||||
*/
|
*/
|
||||||
async acceptIdentityChange(address: string, newIdentityKey: Uint8Array): Promise<void> {
|
async acceptIdentityChange(address: string, newIdentityKey: Uint8Array): Promise<void> {
|
||||||
|
// Capture old hash for the trust.changed event (TOFU semantics make this messy
|
||||||
|
// because isTrustedIdentity() compares not retrieves; we just emit the new hash)
|
||||||
await this.storage.saveTrustedIdentity(address, newIdentityKey);
|
await this.storage.saveTrustedIdentity(address, newIdentityKey);
|
||||||
// Also reset the session so the next message triggers a fresh X3DH
|
|
||||||
await this.storage.removeSession(address);
|
await this.storage.removeSession(address);
|
||||||
|
|
||||||
|
if (this.events) {
|
||||||
|
const newHash = await shortHash(this.crypto, newIdentityKey);
|
||||||
|
this.events.emit('trust.changed', { address, oldKeyHash: '?', newKeyHash: newHash });
|
||||||
|
this.events.emit('session.removed', { address });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,17 +238,23 @@ export class ShadeSessionManager {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
await this.storage.saveOneTimePreKey(key);
|
await this.storage.saveOneTimePreKey(key);
|
||||||
}
|
}
|
||||||
|
this.events?.emit('prekey.generated', {
|
||||||
|
count,
|
||||||
|
totalAfter: existingCount + count,
|
||||||
|
});
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rotate the signed prekey (recommended: every 1-7 days) */
|
/** Rotate the signed prekey (recommended: every 1-7 days) */
|
||||||
async rotateSignedPreKey(): Promise<SignedPreKey> {
|
async rotateSignedPreKey(): Promise<SignedPreKey> {
|
||||||
if (!this.identity) throw new Error('Not initialized');
|
if (!this.identity) throw new Error('Not initialized');
|
||||||
const newId = this.currentSignedPreKeyId + 1;
|
const oldId = this.currentSignedPreKeyId;
|
||||||
|
const newId = oldId + 1;
|
||||||
const spk = await generateSignedPreKey(this.crypto, this.identity, newId);
|
const spk = await generateSignedPreKey(this.crypto, this.identity, newId);
|
||||||
await this.storage.saveSignedPreKey(spk);
|
await this.storage.saveSignedPreKey(spk);
|
||||||
// Keep old one for a grace period (sessions may still reference it)
|
// Keep old one for a grace period (sessions may still reference it)
|
||||||
this.currentSignedPreKeyId = newId;
|
this.currentSignedPreKeyId = newId;
|
||||||
|
this.events?.emit('signed_prekey.rotated', { oldKeyId: oldId, newKeyId: newId });
|
||||||
return spk;
|
return spk;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +294,11 @@ export class ShadeSessionManager {
|
|||||||
await this.storage.saveSignedPreKey(spk);
|
await this.storage.saveSignedPreKey(spk);
|
||||||
this.currentSignedPreKeyId = newSpkId;
|
this.currentSignedPreKeyId = newSpkId;
|
||||||
|
|
||||||
|
if (this.events) {
|
||||||
|
const newFingerprint = await this.getIdentityFingerprint();
|
||||||
|
this.events.emit('identity.rotated', { newFingerprint });
|
||||||
|
}
|
||||||
|
|
||||||
// Return a fresh bundle for re-publication
|
// Return a fresh bundle for re-publication
|
||||||
return createPreKeyBundle(this.registrationId, this.identity, spk);
|
return createPreKeyBundle(this.registrationId, this.identity, spk);
|
||||||
}
|
}
|
||||||
@@ -313,6 +351,12 @@ export class ShadeSessionManager {
|
|||||||
registrationId: this.registrationId,
|
registrationId: this.registrationId,
|
||||||
};
|
};
|
||||||
await this.storage.saveSession(address, session);
|
await this.storage.saveSession(address, session);
|
||||||
|
|
||||||
|
if (this.events) {
|
||||||
|
const remoteHash = await shortHash(this.crypto, x3dhResult.remoteIdentityKey);
|
||||||
|
this.events.emit('session.created', { address, remoteIdentityKeyHash: remoteHash });
|
||||||
|
this.events.emit('trust.pinned', { address, identityKeyHash: remoteHash });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Encrypt / Decrypt ─────────────────────────────────────
|
// ─── Encrypt / Decrypt ─────────────────────────────────────
|
||||||
@@ -329,6 +373,12 @@ export class ShadeSessionManager {
|
|||||||
|
|
||||||
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
|
const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext));
|
||||||
|
|
||||||
|
this.events?.emit('message.encrypted', {
|
||||||
|
address,
|
||||||
|
counter: ratchetMsg.counter,
|
||||||
|
ciphertextSize: ratchetMsg.ciphertext.length,
|
||||||
|
});
|
||||||
|
|
||||||
// Check if this is the first message (X3DH metadata attached)
|
// Check if this is the first message (X3DH metadata attached)
|
||||||
const x3dh = (session as any).__x3dh;
|
const x3dh = (session as any).__x3dh;
|
||||||
if (x3dh) {
|
if (x3dh) {
|
||||||
@@ -390,6 +440,20 @@ export class ShadeSessionManager {
|
|||||||
await this.storage.saveSession(address, session);
|
await this.storage.saveSession(address, session);
|
||||||
await this.storage.saveTrustedIdentity(address, x3dhResult.remoteIdentityKey);
|
await this.storage.saveTrustedIdentity(address, x3dhResult.remoteIdentityKey);
|
||||||
|
|
||||||
|
if (this.events) {
|
||||||
|
const remoteHash = await shortHash(this.crypto, x3dhResult.remoteIdentityKey);
|
||||||
|
this.events.emit('session.created', { address, remoteIdentityKeyHash: remoteHash });
|
||||||
|
this.events.emit('trust.pinned', { address, identityKeyHash: remoteHash });
|
||||||
|
if (message.preKeyId != null) {
|
||||||
|
this.events.emit('prekey.consumed', { keyId: message.preKeyId });
|
||||||
|
}
|
||||||
|
this.events.emit('message.decrypted', {
|
||||||
|
address,
|
||||||
|
counter: x3dhResult.initialMessage.counter,
|
||||||
|
plaintextSize: plaintext.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return dec.decode(plaintext);
|
return dec.decode(plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +461,32 @@ export class ShadeSessionManager {
|
|||||||
const session = await this.storage.getSession(address);
|
const session = await this.storage.getSession(address);
|
||||||
if (!session) throw new NoSessionError(address);
|
if (!session) throw new NoSessionError(address);
|
||||||
|
|
||||||
|
// Detect DH ratchet step (new remote DH key)
|
||||||
|
const willRatchet = !session.dhReceive ||
|
||||||
|
!arraysEqual(message.dhPublicKey, session.dhReceive);
|
||||||
|
|
||||||
const plaintext = await ratchetDecrypt(this.crypto, session, message);
|
const plaintext = await ratchetDecrypt(this.crypto, session, message);
|
||||||
await this.storage.saveSession(address, session);
|
await this.storage.saveSession(address, session);
|
||||||
|
|
||||||
|
if (this.events) {
|
||||||
|
if (willRatchet) {
|
||||||
|
this.events.emit('ratchet.dh_step', { address });
|
||||||
|
}
|
||||||
|
this.events.emit('message.decrypted', {
|
||||||
|
address,
|
||||||
|
counter: message.counter,
|
||||||
|
plaintextSize: plaintext.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return dec.decode(plaintext);
|
return dec.decode(plaintext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
190
packages/shade-core/tests/events.test.ts
Normal file
190
packages/shade-core/tests/events.test.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||||
|
import {
|
||||||
|
ShadeSessionManager,
|
||||||
|
ShadeEventEmitter,
|
||||||
|
shortHash,
|
||||||
|
} from '../src/index.js';
|
||||||
|
import type { ShadeEvent } from '../src/index.js';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
|
||||||
|
describe('ShadeEventEmitter', () => {
|
||||||
|
test('subscribes and emits events', () => {
|
||||||
|
const emitter = new ShadeEventEmitter();
|
||||||
|
const received: ShadeEvent[] = [];
|
||||||
|
emitter.on((e) => received.push(e));
|
||||||
|
|
||||||
|
emitter.emit('identity.initialized', { fingerprint: 'abc', registrationId: 1 });
|
||||||
|
|
||||||
|
expect(received.length).toBe(1);
|
||||||
|
expect(received[0]!.name).toBe('identity.initialized');
|
||||||
|
expect(received[0]!.seq).toBe(1);
|
||||||
|
expect(received[0]!.timestamp).toBeGreaterThan(0);
|
||||||
|
expect((received[0]!.data as any).fingerprint).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seq is monotonically increasing', () => {
|
||||||
|
const emitter = new ShadeEventEmitter();
|
||||||
|
const seqs: number[] = [];
|
||||||
|
emitter.on((e) => seqs.push(e.seq));
|
||||||
|
|
||||||
|
emitter.emit('prekey.generated', { count: 5, totalAfter: 5 });
|
||||||
|
emitter.emit('prekey.consumed', { keyId: 1 });
|
||||||
|
emitter.emit('prekey.consumed', { keyId: 2 });
|
||||||
|
|
||||||
|
expect(seqs).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsubscribe stops receiving events', () => {
|
||||||
|
const emitter = new ShadeEventEmitter();
|
||||||
|
let count = 0;
|
||||||
|
const unsub = emitter.on(() => count++);
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
|
||||||
|
unsub();
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listener throw does not break other listeners', () => {
|
||||||
|
const emitter = new ShadeEventEmitter();
|
||||||
|
let goodCount = 0;
|
||||||
|
emitter.on(() => { throw new Error('boom'); });
|
||||||
|
emitter.on(() => goodCount++);
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
|
||||||
|
expect(goodCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getBufferedSince returns events after seq', () => {
|
||||||
|
const emitter = new ShadeEventEmitter();
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 1 });
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 2 });
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: 3 });
|
||||||
|
const events = emitter.getBufferedSince(1);
|
||||||
|
expect(events.length).toBe(2);
|
||||||
|
expect(events[0]!.seq).toBe(2);
|
||||||
|
expect(events[1]!.seq).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ring buffer evicts oldest', () => {
|
||||||
|
const emitter = new ShadeEventEmitter({ bufferSize: 3 });
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
emitter.emit('prekey.generated', { count: 1, totalAfter: i });
|
||||||
|
}
|
||||||
|
const recent = emitter.getRecent(10);
|
||||||
|
expect(recent.length).toBe(3);
|
||||||
|
expect(recent[0]!.seq).toBe(3);
|
||||||
|
expect(recent[2]!.seq).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shortHash helper', () => {
|
||||||
|
test('produces 16-hex-char string', async () => {
|
||||||
|
const hash = await shortHash(crypto, crypto.randomBytes(32));
|
||||||
|
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deterministic for same input', async () => {
|
||||||
|
const key = new Uint8Array(32).fill(0xab);
|
||||||
|
const a = await shortHash(crypto, key);
|
||||||
|
const b = await shortHash(crypto, key);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different inputs produce different hashes', async () => {
|
||||||
|
const a = await shortHash(crypto, crypto.randomBytes(32));
|
||||||
|
const b = await shortHash(crypto, crypto.randomBytes(32));
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ShadeSessionManager event integration', () => {
|
||||||
|
test('initialize emits identity.initialized', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const received: ShadeEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await mgr.initialize();
|
||||||
|
|
||||||
|
const init = received.find((e) => e.name === 'identity.initialized');
|
||||||
|
expect(init).toBeDefined();
|
||||||
|
const data = init!.data as any;
|
||||||
|
expect(data.fingerprint).toMatch(/^\d{5}( \d{5}){11}$/);
|
||||||
|
expect(data.registrationId).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full conversation emits expected event sequence', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const received: ShadeEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(5);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
|
||||||
|
const env1 = await alice.encrypt('bob', 'hello');
|
||||||
|
await bob.decrypt('alice', env1);
|
||||||
|
const env2 = await bob.encrypt('alice', 'hi');
|
||||||
|
await alice.decrypt('bob', env2);
|
||||||
|
|
||||||
|
const names = received.map((e) => e.name);
|
||||||
|
expect(names).toContain('identity.initialized');
|
||||||
|
expect(names).toContain('prekey.generated');
|
||||||
|
expect(names).toContain('session.created');
|
||||||
|
expect(names).toContain('trust.pinned');
|
||||||
|
expect(names).toContain('message.encrypted');
|
||||||
|
expect(names).toContain('message.decrypted');
|
||||||
|
expect(names).toContain('ratchet.dh_step'); // Bob's reply triggers a DH step
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no events emitted when emitter not provided', async () => {
|
||||||
|
const mgr = new ShadeSessionManager(crypto, new MemoryStorage());
|
||||||
|
await mgr.initialize();
|
||||||
|
// No assertion needed — should not throw or error
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SECURITY: no key material in event payloads', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const received: ShadeEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(5);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
const env = await alice.encrypt('bob', 'secret message');
|
||||||
|
await bob.decrypt('alice', env);
|
||||||
|
await alice.rotateSignedPreKey();
|
||||||
|
|
||||||
|
// Serialize all events and check for any 32-byte base64 patterns
|
||||||
|
// (which would indicate raw key material)
|
||||||
|
const json = JSON.stringify(received);
|
||||||
|
|
||||||
|
// 32-byte base64 = 44 chars (with padding) or 43 (without)
|
||||||
|
// We allow short 16-hex-char hashes, but no 44-char base64 or 64-char hex
|
||||||
|
const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g;
|
||||||
|
const longHex = /[0-9a-f]{32,}/gi;
|
||||||
|
|
||||||
|
const base64Matches = json.match(longBase64) ?? [];
|
||||||
|
const hexMatches = json.match(longHex) ?? [];
|
||||||
|
|
||||||
|
// Filter out any matches that are inside hash fields (which are 16 hex chars,
|
||||||
|
// so the regex above wouldn't match anyway, but be explicit)
|
||||||
|
expect(base64Matches.length).toBe(0);
|
||||||
|
expect(hexMatches.length).toBe(0);
|
||||||
|
|
||||||
|
// Also no plaintext leakage
|
||||||
|
expect(json).not.toContain('secret message');
|
||||||
|
});
|
||||||
|
});
|
||||||
23
packages/shade-dashboard/index.html
Normal file
23
packages/shade-dashboard/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shade Observer</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#root { min-height: 100vh; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
packages/shade-dashboard/package.json
Normal file
20
packages/shade-dashboard/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@shade/dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && bun run scripts/copy-to-observer.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shade/widgets": "workspace:*",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/shade-dashboard/scripts/copy-to-observer.ts
Normal file
26
packages/shade-dashboard/scripts/copy-to-observer.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* After Vite builds the dashboard, copy the dist/ output into
|
||||||
|
* @shade/observer's dist/ directory so the observer endpoint can
|
||||||
|
* serve it from /dashboard/.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, cpSync, rmSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const dashboardDist = join(here, '..', 'dist');
|
||||||
|
const observerDist = join(here, '..', '..', 'shade-observer', 'dist');
|
||||||
|
|
||||||
|
if (!existsSync(dashboardDist)) {
|
||||||
|
console.error(`Dashboard dist not found at ${dashboardDist}. Run \`vite build\` first.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and recreate observer dist
|
||||||
|
if (existsSync(observerDist)) {
|
||||||
|
rmSync(observerDist, { recursive: true });
|
||||||
|
}
|
||||||
|
mkdirSync(observerDist, { recursive: true });
|
||||||
|
|
||||||
|
cpSync(dashboardDist, observerDist, { recursive: true });
|
||||||
|
console.log(`✓ Copied dashboard build to ${observerDist}`);
|
||||||
149
packages/shade-dashboard/src/App.tsx
Normal file
149
packages/shade-dashboard/src/App.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ShadeProvider,
|
||||||
|
IdentityCard,
|
||||||
|
SessionList,
|
||||||
|
PrekeyStock,
|
||||||
|
RecentActivity,
|
||||||
|
ServerStatus,
|
||||||
|
FingerprintCompare,
|
||||||
|
} from '@shade/widgets';
|
||||||
|
import { Login } from './Login.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'shade-dashboard-config';
|
||||||
|
|
||||||
|
interface DashboardConfig {
|
||||||
|
observerUrl: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App(): React.ReactElement {
|
||||||
|
const [config, setConfig] = useState<DashboardConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine the default observer URL: assume dashboard is served from
|
||||||
|
// the same origin as the observer, so the API is at the parent path
|
||||||
|
const defaultUrl = window.location.origin + window.location.pathname.replace(/\/dashboard\/?$/, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as DashboardConfig;
|
||||||
|
if (parsed.observerUrl && parsed.token) {
|
||||||
|
setConfig(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Show login screen
|
||||||
|
setConfig({ observerUrl: defaultUrl, token: '' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleLogin(observerUrl: string, token: string): void {
|
||||||
|
const cfg = { observerUrl, token };
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||||
|
setConfig(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout(): void {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setConfig({ observerUrl: window.location.origin, token: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <div style={{ padding: 40, textAlign: 'center' }}>Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.token) {
|
||||||
|
return <Login defaultUrl={config.observerUrl} onLogin={handleLogin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShadeProvider observerUrl={config.observerUrl} token={config.token}>
|
||||||
|
<DashboardLayout onLogout={handleLogout} />
|
||||||
|
</ShadeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardLayout({ onLogout }: { onLogout: () => void }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 1400,
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: 24,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottom: '1px solid #262626',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#f7c948',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Shade Observer
|
||||||
|
</h1>
|
||||||
|
<div style={{ fontSize: 12, color: '#a3a3a3', marginTop: 2 }}>
|
||||||
|
Live debugger for your Signal Protocol deployment
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<IdentityCard />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(280px, 1fr) minmax(280px, 1fr) minmax(280px, 1fr)',
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PrekeyStock />
|
||||||
|
<ServerStatus />
|
||||||
|
<FingerprintCompare />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionList />
|
||||||
|
<RecentActivity limit={100} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
packages/shade-dashboard/src/Login.tsx
Normal file
127
packages/shade-dashboard/src/Login.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
defaultUrl: string;
|
||||||
|
onLogin: (url: string, token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Login({ defaultUrl, onLogin }: LoginProps): React.ReactElement {
|
||||||
|
const [url, setUrl] = useState(defaultUrl);
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!url.trim() || !token.trim()) {
|
||||||
|
setError('Both fields are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url.replace(/\/$/, '')}/api/state`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(`Auth failed: HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLogin(url.trim(), token.trim());
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
background: '#161616',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 32,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ margin: 0, color: '#f7c948', fontSize: 22 }}>Shade Observer</h1>
|
||||||
|
<p style={{ margin: '4px 0 0', color: '#a3a3a3', fontSize: 13 }}>
|
||||||
|
Connect to your observer endpoint
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Observer URL
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://shade.example.com/shade-observer"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Bearer token
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder="SHADE_OBSERVER_TOKEN value"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: '#ef4444', fontSize: 12, padding: '8px 10px', background: 'rgba(239, 68, 68, 0.1)', borderRadius: 4 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
background: '#f7c948',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
border: 'none',
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
background: '#0a0a0a',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
outline: 'none',
|
||||||
|
};
|
||||||
12
packages/shade-dashboard/src/main.tsx
Normal file
12
packages/shade-dashboard/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App.js';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) throw new Error('Missing #root');
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
12
packages/shade-dashboard/tsconfig.json
Normal file
12
packages/shade-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
12
packages/shade-dashboard/vite.config.ts
Normal file
12
packages/shade-dashboard/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/dashboard/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
|
});
|
||||||
64
packages/shade-observer/README.md
Normal file
64
packages/shade-observer/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# @shade/observer
|
||||||
|
|
||||||
|
Live observability backend for Shade — exposes a snapshot endpoint, an SSE event stream, and serves the bundled dashboard SPA.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add @shade/observer @shade/server @shade/core
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createObserver } from '@shade/observer';
|
||||||
|
import { ShadeEventEmitter, ShadeSessionManager } from '@shade/core';
|
||||||
|
import { PrekeyServerEvents, createPrekeyServer } from '@shade/server';
|
||||||
|
|
||||||
|
// 1. Create event emitters
|
||||||
|
const clientEvents = new ShadeEventEmitter();
|
||||||
|
const serverEvents = new PrekeyServerEvents();
|
||||||
|
|
||||||
|
// 2. Wire them into your session manager and prekey server
|
||||||
|
const manager = new ShadeSessionManager(crypto, storage, { events: clientEvents });
|
||||||
|
const prekeyServer = createPrekeyServer({ crypto, events: serverEvents });
|
||||||
|
|
||||||
|
// 3. Create the observer
|
||||||
|
const observer = createObserver({
|
||||||
|
token: process.env.SHADE_OBSERVER_TOKEN!,
|
||||||
|
clientEvents,
|
||||||
|
serverEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Mount or serve standalone
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
const app = new Hono();
|
||||||
|
app.route('/shade-observer', observer);
|
||||||
|
|
||||||
|
Bun.serve({ port: 3900, fetch: app.fetch });
|
||||||
|
```
|
||||||
|
|
||||||
|
After this, visit `http://localhost:3900/shade-observer/dashboard/` and enter your bearer token to see the dashboard.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/api/state` | Bearer | Current snapshot (identity, sessions, prekeys, server stats) |
|
||||||
|
| GET | `/api/events` | Bearer (or `?token=`) | SSE stream of live events |
|
||||||
|
| GET | `/dashboard/` | None | Bundled web UI |
|
||||||
|
| GET | `/health` | None | Liveness check |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Required | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| `SHADE_OBSERVER_TOKEN` | Yes | Bearer token (min 16 chars). Refuses to start if shorter. |
|
||||||
|
|
||||||
|
The token is checked with constant-time comparison.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Event payloads contain NO key material, plaintext, or signatures — only structural facts (counters, addresses, short hashes for display).
|
||||||
|
- The observer is intended for internal/debugging use. Put it behind a reverse proxy and authenticate access.
|
||||||
|
- The dashboard stores the bearer token in `localStorage` for convenience. Don't load the dashboard on shared computers.
|
||||||
15
packages/shade-observer/package.json
Normal file
15
packages/shade-observer/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@shade/observer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"@shade/core": "workspace:*",
|
||||||
|
"@shade/server": "workspace:*",
|
||||||
|
"hono": "^4.12.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shade/crypto-web": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
packages/shade-observer/src/auth.ts
Normal file
47
packages/shade-observer/src/auth.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { UnauthorizedError, ConfigurationError } from '@shade/core';
|
||||||
|
import type { Context, Next } from 'hono';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer token middleware for the observer.
|
||||||
|
*
|
||||||
|
* Reads token from `Authorization: Bearer <token>` header.
|
||||||
|
* For SSE endpoints (where browsers can't set headers), also accepts
|
||||||
|
* `?token=<token>` query parameter.
|
||||||
|
*
|
||||||
|
* Throws ConfigurationError if SHADE_OBSERVER_TOKEN is empty (refuses to start).
|
||||||
|
*/
|
||||||
|
export function createAuthMiddleware(token: string) {
|
||||||
|
if (!token || token.length < 16) {
|
||||||
|
throw new ConfigurationError(
|
||||||
|
'SHADE_OBSERVER_TOKEN must be set and at least 16 characters. Refusing to start.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const header = c.req.header('Authorization');
|
||||||
|
let provided: string | null = null;
|
||||||
|
|
||||||
|
if (header && header.startsWith('Bearer ')) {
|
||||||
|
provided = header.slice(7);
|
||||||
|
} else {
|
||||||
|
// Allow query string for SSE (EventSource can't set headers)
|
||||||
|
provided = c.req.query('token') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provided || !constantTimeStringEqual(provided, token)) {
|
||||||
|
throw new UnauthorizedError('Invalid or missing observer token');
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time string comparison (avoids timing attacks on token check) */
|
||||||
|
function constantTimeStringEqual(a: string, b: string): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
48
packages/shade-observer/src/index.ts
Normal file
48
packages/shade-observer/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { createObserverRoutes, type ObserverOptions } from './routes.js';
|
||||||
|
import { createStaticRoutes } from './static.js';
|
||||||
|
|
||||||
|
export { createObserverRoutes } from './routes.js';
|
||||||
|
export { StateAggregator } from './state.js';
|
||||||
|
export { createAuthMiddleware } from './auth.js';
|
||||||
|
export { createStaticRoutes } from './static.js';
|
||||||
|
export type { ObserverOptions } from './routes.js';
|
||||||
|
export type { ObserverSnapshot } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a complete Shade Observer Hono app with API + dashboard.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import { createObserver } from '@shade/observer';
|
||||||
|
*
|
||||||
|
* const observer = createObserver({
|
||||||
|
* token: process.env.SHADE_OBSERVER_TOKEN!,
|
||||||
|
* clientEvents: sessionManager.getEvents(),
|
||||||
|
* serverEvents: prekeyServerEvents,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Mount in any Hono app
|
||||||
|
* app.route('/shade-observer', observer);
|
||||||
|
*
|
||||||
|
* // Or run standalone
|
||||||
|
* Bun.serve({ port: 3901, fetch: observer.fetch });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createObserver(
|
||||||
|
options: ObserverOptions & { distDir?: string },
|
||||||
|
): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
app.route('/', createObserverRoutes(options));
|
||||||
|
|
||||||
|
const distDir = options.distDir
|
||||||
|
?? join(dirname(fileURLToPath(import.meta.url)), '..', 'dist');
|
||||||
|
app.route('/', createStaticRoutes(distDir));
|
||||||
|
|
||||||
|
// Root → dashboard
|
||||||
|
app.get('/', (c) => c.redirect('/dashboard/'));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
105
packages/shade-observer/src/routes.ts
Normal file
105
packages/shade-observer/src/routes.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { streamSSE } from 'hono/streaming';
|
||||||
|
import type { ShadeEventEmitter, ShadeEvent } from '@shade/core';
|
||||||
|
import { errorToHttpStatus, ShadeError } from '@shade/core';
|
||||||
|
import type { PrekeyServerEvents, PrekeyServerEvent } from '@shade/server';
|
||||||
|
import { StateAggregator } from './state.js';
|
||||||
|
import { createAuthMiddleware } from './auth.js';
|
||||||
|
|
||||||
|
export interface ObserverOptions {
|
||||||
|
token: string;
|
||||||
|
clientEvents?: ShadeEventEmitter;
|
||||||
|
serverEvents?: PrekeyServerEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createObserverRoutes(options: ObserverOptions): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
const aggregator = new StateAggregator(options.clientEvents, options.serverEvents);
|
||||||
|
const auth = createAuthMiddleware(options.token);
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
if (err instanceof ShadeError) {
|
||||||
|
return c.json(err.toJSON(), errorToHttpStatus(err) as any);
|
||||||
|
}
|
||||||
|
console.error('[Shade Observer] Unhandled error:', err);
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Snapshot ──────────────────────────────────────────────
|
||||||
|
app.get('/api/state', auth, (c) => {
|
||||||
|
return c.json(aggregator.toJSON());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Live event stream ─────────────────────────────────────
|
||||||
|
app.get('/api/events', auth, async (c) => {
|
||||||
|
const sinceParam = c.req.query('since');
|
||||||
|
const since = sinceParam ? parseInt(sinceParam, 10) : 0;
|
||||||
|
|
||||||
|
return streamSSE(c, async (stream) => {
|
||||||
|
// Send buffered events from `since` onwards
|
||||||
|
if (options.clientEvents) {
|
||||||
|
for (const e of options.clientEvents.getBufferedSince(since)) {
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: 'shade',
|
||||||
|
id: String(e.seq),
|
||||||
|
data: JSON.stringify({ source: 'client', ...e }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.serverEvents) {
|
||||||
|
for (const e of options.serverEvents.getBufferedSince(since)) {
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: 'shade',
|
||||||
|
id: String(e.seq),
|
||||||
|
data: JSON.stringify({ source: 'server', ...e }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live events
|
||||||
|
let closed = false;
|
||||||
|
const queue: Array<{ source: 'client' | 'server'; event: ShadeEvent | PrekeyServerEvent }> = [];
|
||||||
|
|
||||||
|
const unsubClient = options.clientEvents?.on((e) => {
|
||||||
|
if (closed) return;
|
||||||
|
queue.push({ source: 'client', event: e });
|
||||||
|
});
|
||||||
|
const unsubServer = options.serverEvents?.on((e) => {
|
||||||
|
if (closed) return;
|
||||||
|
queue.push({ source: 'server', event: e });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain queue periodically (or on demand)
|
||||||
|
try {
|
||||||
|
while (!closed) {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
const { source, event } = queue.shift()!;
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: 'shade',
|
||||||
|
id: String(event.seq),
|
||||||
|
data: JSON.stringify({ source, ...event }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Heartbeat every 15s to keep connection alive
|
||||||
|
await stream.writeSSE({ event: 'heartbeat', data: 'ping' });
|
||||||
|
await stream.sleep(15000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream closed
|
||||||
|
} finally {
|
||||||
|
closed = true;
|
||||||
|
unsubClient?.();
|
||||||
|
unsubServer?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Health (no auth) ──────────────────────────────────────
|
||||||
|
app.get('/health', (c) => {
|
||||||
|
return c.json({ status: 'ok', service: 'shade-observer' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
212
packages/shade-observer/src/state.ts
Normal file
212
packages/shade-observer/src/state.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { ShadeEventEmitter, ShadeEvent, ShadeSessionManager } from '@shade/core';
|
||||||
|
import type { PrekeyServerEvents, PrekeyServerEvent, PrekeyStore } from '@shade/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated observer state, updated as events flow in.
|
||||||
|
*
|
||||||
|
* The observer maintains a rolling snapshot of:
|
||||||
|
* - Identity (fingerprint, registration ID)
|
||||||
|
* - Active sessions (per address: message counts, last activity)
|
||||||
|
* - Prekey stock
|
||||||
|
* - Server stats (registered identities, fetches, replenishes)
|
||||||
|
* - Recent events ring buffer
|
||||||
|
*/
|
||||||
|
export interface ObserverSnapshot {
|
||||||
|
identity: {
|
||||||
|
fingerprint: string | null;
|
||||||
|
registrationId: number | null;
|
||||||
|
lastInitialized: number | null;
|
||||||
|
lastRotated: number | null;
|
||||||
|
};
|
||||||
|
sessions: Array<{
|
||||||
|
address: string;
|
||||||
|
remoteIdentityKeyHash: string;
|
||||||
|
messageCountSent: number;
|
||||||
|
messageCountReceived: number;
|
||||||
|
lastActivity: number;
|
||||||
|
dhRatchetSteps: number;
|
||||||
|
}>;
|
||||||
|
prekeys: {
|
||||||
|
oneTimeRemaining: number;
|
||||||
|
lastGenerated: number | null;
|
||||||
|
lastConsumed: number | null;
|
||||||
|
signedPreKeyId: number | null;
|
||||||
|
signedPreKeyLastRotated: number | null;
|
||||||
|
};
|
||||||
|
retiredIdentities: number;
|
||||||
|
server: {
|
||||||
|
registeredIdentities: Set<string>;
|
||||||
|
totalBundleFetches: number;
|
||||||
|
totalReplenishes: number;
|
||||||
|
totalDeleted: number;
|
||||||
|
totalRateLimited: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStats {
|
||||||
|
remoteIdentityKeyHash: string;
|
||||||
|
messageCountSent: number;
|
||||||
|
messageCountReceived: number;
|
||||||
|
lastActivity: number;
|
||||||
|
dhRatchetSteps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StateAggregator {
|
||||||
|
private identity: ObserverSnapshot['identity'] = {
|
||||||
|
fingerprint: null,
|
||||||
|
registrationId: null,
|
||||||
|
lastInitialized: null,
|
||||||
|
lastRotated: null,
|
||||||
|
};
|
||||||
|
private sessions = new Map<string, SessionStats>();
|
||||||
|
private prekeys: ObserverSnapshot['prekeys'] = {
|
||||||
|
oneTimeRemaining: 0,
|
||||||
|
lastGenerated: null,
|
||||||
|
lastConsumed: null,
|
||||||
|
signedPreKeyId: null,
|
||||||
|
signedPreKeyLastRotated: null,
|
||||||
|
};
|
||||||
|
private retiredIdentities = 0;
|
||||||
|
private serverStats = {
|
||||||
|
registeredIdentities: new Set<string>(),
|
||||||
|
totalBundleFetches: 0,
|
||||||
|
totalReplenishes: 0,
|
||||||
|
totalDeleted: 0,
|
||||||
|
totalRateLimited: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly clientEvents?: ShadeEventEmitter,
|
||||||
|
private readonly serverEvents?: PrekeyServerEvents,
|
||||||
|
private readonly manager?: ShadeSessionManager,
|
||||||
|
private readonly store?: PrekeyStore,
|
||||||
|
) {
|
||||||
|
if (clientEvents) {
|
||||||
|
// Replay any events that fired before subscription, then subscribe
|
||||||
|
for (const e of clientEvents.getBufferedSince(0)) {
|
||||||
|
this.handleClientEvent(e);
|
||||||
|
}
|
||||||
|
clientEvents.on((e) => this.handleClientEvent(e));
|
||||||
|
}
|
||||||
|
if (serverEvents) {
|
||||||
|
for (const e of serverEvents.getBufferedSince(0)) {
|
||||||
|
this.handleServerEvent(e);
|
||||||
|
}
|
||||||
|
serverEvents.on((e) => this.handleServerEvent(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClientEvent(e: ShadeEvent): void {
|
||||||
|
switch (e.name) {
|
||||||
|
case 'identity.initialized':
|
||||||
|
this.identity.fingerprint = e.data.fingerprint;
|
||||||
|
this.identity.registrationId = e.data.registrationId;
|
||||||
|
this.identity.lastInitialized = e.timestamp;
|
||||||
|
break;
|
||||||
|
case 'identity.rotated':
|
||||||
|
this.identity.fingerprint = e.data.newFingerprint;
|
||||||
|
this.identity.lastRotated = e.timestamp;
|
||||||
|
this.retiredIdentities++;
|
||||||
|
break;
|
||||||
|
case 'session.created':
|
||||||
|
this.sessions.set(e.data.address, {
|
||||||
|
remoteIdentityKeyHash: e.data.remoteIdentityKeyHash,
|
||||||
|
messageCountSent: 0,
|
||||||
|
messageCountReceived: 0,
|
||||||
|
lastActivity: e.timestamp,
|
||||||
|
dhRatchetSteps: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'session.removed':
|
||||||
|
this.sessions.delete(e.data.address);
|
||||||
|
break;
|
||||||
|
case 'message.encrypted': {
|
||||||
|
const s = this.sessions.get(e.data.address);
|
||||||
|
if (s) {
|
||||||
|
s.messageCountSent++;
|
||||||
|
s.lastActivity = e.timestamp;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'message.decrypted': {
|
||||||
|
const s = this.sessions.get(e.data.address);
|
||||||
|
if (s) {
|
||||||
|
s.messageCountReceived++;
|
||||||
|
s.lastActivity = e.timestamp;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ratchet.dh_step': {
|
||||||
|
const s = this.sessions.get(e.data.address);
|
||||||
|
if (s) s.dhRatchetSteps++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'prekey.generated':
|
||||||
|
this.prekeys.oneTimeRemaining = e.data.totalAfter;
|
||||||
|
this.prekeys.lastGenerated = e.timestamp;
|
||||||
|
break;
|
||||||
|
case 'prekey.consumed':
|
||||||
|
if (this.prekeys.oneTimeRemaining > 0) this.prekeys.oneTimeRemaining--;
|
||||||
|
this.prekeys.lastConsumed = e.timestamp;
|
||||||
|
break;
|
||||||
|
case 'signed_prekey.rotated':
|
||||||
|
this.prekeys.signedPreKeyId = e.data.newKeyId;
|
||||||
|
this.prekeys.signedPreKeyLastRotated = e.timestamp;
|
||||||
|
break;
|
||||||
|
// trust.* don't directly affect snapshot but appear in event feed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerEvent(e: PrekeyServerEvent): void {
|
||||||
|
switch (e.name) {
|
||||||
|
case 'server.identity_registered':
|
||||||
|
this.serverStats.registeredIdentities.add(e.data.address);
|
||||||
|
break;
|
||||||
|
case 'server.bundle_fetched':
|
||||||
|
this.serverStats.totalBundleFetches++;
|
||||||
|
break;
|
||||||
|
case 'server.prekeys_replenished':
|
||||||
|
this.serverStats.totalReplenishes++;
|
||||||
|
break;
|
||||||
|
case 'server.identity_deleted':
|
||||||
|
this.serverStats.registeredIdentities.delete(e.data.address);
|
||||||
|
this.serverStats.totalDeleted++;
|
||||||
|
break;
|
||||||
|
case 'server.rate_limited':
|
||||||
|
this.serverStats.totalRateLimited++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current snapshot */
|
||||||
|
snapshot(): ObserverSnapshot {
|
||||||
|
return {
|
||||||
|
identity: { ...this.identity },
|
||||||
|
sessions: Array.from(this.sessions.entries()).map(([address, s]) => ({
|
||||||
|
address,
|
||||||
|
...s,
|
||||||
|
})),
|
||||||
|
prekeys: { ...this.prekeys },
|
||||||
|
retiredIdentities: this.retiredIdentities,
|
||||||
|
server: {
|
||||||
|
registeredIdentities: new Set(this.serverStats.registeredIdentities),
|
||||||
|
totalBundleFetches: this.serverStats.totalBundleFetches,
|
||||||
|
totalReplenishes: this.serverStats.totalReplenishes,
|
||||||
|
totalDeleted: this.serverStats.totalDeleted,
|
||||||
|
totalRateLimited: this.serverStats.totalRateLimited,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot with serializable JSON (Set → array) */
|
||||||
|
toJSON(): any {
|
||||||
|
const s = this.snapshot();
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
server: {
|
||||||
|
...s.server,
|
||||||
|
registeredIdentities: Array.from(s.server.registeredIdentities),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
90
packages/shade-observer/src/static.ts
Normal file
90
packages/shade-observer/src/static.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync, readFileSync, statSync } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the bundled dashboard SPA from /dashboard/.
|
||||||
|
*
|
||||||
|
* Looks for dist/ in the @shade/observer package directory.
|
||||||
|
* Falls back to a placeholder page if no build is present.
|
||||||
|
*/
|
||||||
|
export function createStaticRoutes(distDir: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/dashboard', (c) => c.redirect('/dashboard/'));
|
||||||
|
|
||||||
|
app.get('/dashboard/*', async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
let path = url.pathname.replace(/^\/dashboard\/?/, '') || 'index.html';
|
||||||
|
|
||||||
|
// Prevent path traversal
|
||||||
|
if (path.includes('..')) {
|
||||||
|
return c.text('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(distDir, path);
|
||||||
|
|
||||||
|
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
||||||
|
// Fall back to index.html for SPA routing
|
||||||
|
const indexPath = join(distDir, 'index.html');
|
||||||
|
if (!existsSync(indexPath)) {
|
||||||
|
return c.html(placeholderHtml());
|
||||||
|
}
|
||||||
|
const content = readFileSync(indexPath);
|
||||||
|
c.header('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
return c.body(content as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(fullPath);
|
||||||
|
const ct = contentTypeFor(path);
|
||||||
|
c.header('Content-Type', ct);
|
||||||
|
if (path.endsWith('.html')) {
|
||||||
|
c.header('Cache-Control', 'no-cache');
|
||||||
|
} else {
|
||||||
|
c.header('Cache-Control', 'public, max-age=3600');
|
||||||
|
}
|
||||||
|
return c.body(content as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFor(path: string): string {
|
||||||
|
if (path.endsWith('.html')) return 'text/html; charset=utf-8';
|
||||||
|
if (path.endsWith('.js')) return 'application/javascript; charset=utf-8';
|
||||||
|
if (path.endsWith('.css')) return 'text/css; charset=utf-8';
|
||||||
|
if (path.endsWith('.json')) return 'application/json; charset=utf-8';
|
||||||
|
if (path.endsWith('.svg')) return 'image/svg+xml';
|
||||||
|
if (path.endsWith('.png')) return 'image/png';
|
||||||
|
if (path.endsWith('.woff2')) return 'font/woff2';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Shade Observer</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui; max-width: 600px; margin: 80px auto; padding: 0 20px; color: #d4d4d4; background: #0a0a0a; }
|
||||||
|
h1 { color: #f7c948; }
|
||||||
|
code { background: #1a1a1a; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
a { color: #f7c948; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Shade Observer</h1>
|
||||||
|
<p>The dashboard SPA hasn't been built yet. The observer API is running, but there's no UI bundled.</p>
|
||||||
|
<p>To build the dashboard:</p>
|
||||||
|
<pre><code>cd packages/shade-dashboard && bun run build</code></pre>
|
||||||
|
<p>Then re-run the observer.</p>
|
||||||
|
<h2>API endpoints</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /api/state</code> — current snapshot (requires <code>Authorization: Bearer ...</code>)</li>
|
||||||
|
<li><code>GET /api/events</code> — SSE stream of live events</li>
|
||||||
|
<li><code>GET /health</code> — health check (no auth)</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
154
packages/shade-observer/tests/observer.test.ts
Normal file
154
packages/shade-observer/tests/observer.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { createObserver, StateAggregator } from '../src/index.js';
|
||||||
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||||||
|
import { ShadeSessionManager, ShadeEventEmitter } from '@shade/core';
|
||||||
|
import { PrekeyServerEvents } from '@shade/server';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
const TEST_TOKEN = 'test-token-must-be-at-least-16-chars';
|
||||||
|
|
||||||
|
describe('StateAggregator', () => {
|
||||||
|
test('aggregates client events into snapshot', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const agg = new StateAggregator(events);
|
||||||
|
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await alice.initialize();
|
||||||
|
await alice.generateOneTimePreKeys(10);
|
||||||
|
|
||||||
|
const snap = agg.snapshot();
|
||||||
|
expect(snap.identity.fingerprint).toBeTruthy();
|
||||||
|
expect(snap.identity.registrationId).toBeGreaterThan(0);
|
||||||
|
expect(snap.prekeys.oneTimeRemaining).toBe(10);
|
||||||
|
expect(snap.prekeys.lastGenerated).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks sessions across encrypt/decrypt', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const agg = new StateAggregator(events);
|
||||||
|
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(5);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
|
||||||
|
const env = await alice.encrypt('bob', 'hello');
|
||||||
|
await bob.decrypt('alice', env);
|
||||||
|
|
||||||
|
const snap = agg.snapshot();
|
||||||
|
const aliceToBob = snap.sessions.find((s) => s.address === 'bob');
|
||||||
|
expect(aliceToBob).toBeDefined();
|
||||||
|
expect(aliceToBob!.messageCountSent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks server events', () => {
|
||||||
|
const serverEvents = new PrekeyServerEvents();
|
||||||
|
const agg = new StateAggregator(undefined, serverEvents);
|
||||||
|
|
||||||
|
serverEvents.emit('server.identity_registered', { address: 'alice', identityKeyHash: 'abc' });
|
||||||
|
serverEvents.emit('server.identity_registered', { address: 'bob', identityKeyHash: 'def' });
|
||||||
|
serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: true });
|
||||||
|
serverEvents.emit('server.bundle_fetched', { address: 'alice', hadOneTimePreKey: false });
|
||||||
|
serverEvents.emit('server.identity_deleted', { address: 'alice' });
|
||||||
|
|
||||||
|
const snap = agg.snapshot();
|
||||||
|
expect(snap.server.registeredIdentities.has('bob')).toBe(true);
|
||||||
|
expect(snap.server.registeredIdentities.has('alice')).toBe(false);
|
||||||
|
expect(snap.server.totalBundleFetches).toBe(2);
|
||||||
|
expect(snap.server.totalDeleted).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Observer routes', () => {
|
||||||
|
test('refuses requests without token', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
|
||||||
|
|
||||||
|
const res = await observer.request('/api/state');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts requests with valid bearer token', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
|
||||||
|
|
||||||
|
const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await mgr.initialize();
|
||||||
|
|
||||||
|
const res = await observer.request('/api/state', {
|
||||||
|
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.identity.fingerprint).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses requests with wrong token', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
|
||||||
|
|
||||||
|
const res = await observer.request('/api/state', {
|
||||||
|
headers: { Authorization: 'Bearer wrong-token-also-long-enough' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts token via query string for SSE', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
|
||||||
|
|
||||||
|
// Just check that the auth middleware accepts the query token
|
||||||
|
const res = await observer.request(`/api/state?token=${TEST_TOKEN}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses startup with too-short token', () => {
|
||||||
|
expect(() => createObserver({ token: 'short' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health endpoint works without auth', async () => {
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN });
|
||||||
|
const res = await observer.request('/health');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot reflects state after operations', async () => {
|
||||||
|
const events = new ShadeEventEmitter();
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN, clientEvents: events });
|
||||||
|
|
||||||
|
const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events });
|
||||||
|
await alice.initialize();
|
||||||
|
await bob.initialize();
|
||||||
|
const otpks = await bob.generateOneTimePreKeys(3);
|
||||||
|
const bundle = await bob.createPreKeyBundle();
|
||||||
|
bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey };
|
||||||
|
await alice.initSessionFromBundle('bob', bundle);
|
||||||
|
|
||||||
|
const env = await alice.encrypt('bob', 'hi');
|
||||||
|
await bob.decrypt('alice', env);
|
||||||
|
|
||||||
|
const res = await observer.request('/api/state', {
|
||||||
|
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.sessions.length).toBeGreaterThan(0);
|
||||||
|
// Bob started with 3 OTPKs; Alice consumed one via X3DH PreKeyMessage decrypt
|
||||||
|
expect(body.prekeys.oneTimeRemaining).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placeholder dashboard renders when no dist', async () => {
|
||||||
|
const observer = createObserver({ token: TEST_TOKEN });
|
||||||
|
const res = await observer.request('/dashboard/');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const html = await res.text();
|
||||||
|
expect(html).toContain('Shade Observer');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
packages/shade-observer/tsconfig.json
Normal file
5
packages/shade-observer/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": { "outDir": "dist-build", "rootDir": "src" },
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
92
packages/shade-server/src/events.ts
Normal file
92
packages/shade-server/src/events.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Prekey server event emitter.
|
||||||
|
*
|
||||||
|
* Mirrors @shade/core's ShadeEventEmitter for the server side. Emits
|
||||||
|
* structural facts only — no key material, no signatures, no plaintext.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PrekeyServerEventBase {
|
||||||
|
seq: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrekeyServerEventMap {
|
||||||
|
'server.identity_registered': { address: string; identityKeyHash: string };
|
||||||
|
'server.bundle_fetched': { address: string; hadOneTimePreKey: boolean };
|
||||||
|
'server.prekeys_replenished': { address: string; count: number; totalAfter: number };
|
||||||
|
'server.identity_deleted': { address: string };
|
||||||
|
'server.rate_limited': { route: string; key: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrekeyServerEventName = keyof PrekeyServerEventMap;
|
||||||
|
|
||||||
|
export type PrekeyServerEvent = {
|
||||||
|
[K in PrekeyServerEventName]: PrekeyServerEventBase & { name: K; data: PrekeyServerEventMap[K] };
|
||||||
|
}[PrekeyServerEventName];
|
||||||
|
|
||||||
|
export type PrekeyServerEventListener = (event: PrekeyServerEvent) => void;
|
||||||
|
|
||||||
|
export class PrekeyServerEvents {
|
||||||
|
private listeners = new Set<PrekeyServerEventListener>();
|
||||||
|
private nextSeq = 1;
|
||||||
|
private buffer: PrekeyServerEvent[] = [];
|
||||||
|
private readonly maxBuffer: number;
|
||||||
|
|
||||||
|
constructor(options: { bufferSize?: number } = {}) {
|
||||||
|
this.maxBuffer = options.bufferSize ?? 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(listener: PrekeyServerEventListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(listener: PrekeyServerEventListener): void {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends PrekeyServerEventName>(name: K, data: PrekeyServerEventMap[K]): void {
|
||||||
|
const event = {
|
||||||
|
seq: this.nextSeq++,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
} as PrekeyServerEvent;
|
||||||
|
|
||||||
|
this.buffer.push(event);
|
||||||
|
if (this.buffer.length > this.maxBuffer) this.buffer.shift();
|
||||||
|
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shade] Server event listener threw:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getBufferedSince(since: number): PrekeyServerEvent[] {
|
||||||
|
return this.buffer.filter((e) => e.seq > since);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecent(n: number): PrekeyServerEvent[] {
|
||||||
|
return this.buffer.slice(-n);
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentSeq(): number {
|
||||||
|
return this.nextSeq - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a short display hash from a public key.
|
||||||
|
* Identical algorithm to @shade/core/shortHash but inlined here to
|
||||||
|
* avoid circular dependency on CryptoProvider.
|
||||||
|
*
|
||||||
|
* Uses SHA-256 via crypto.subtle directly.
|
||||||
|
*/
|
||||||
|
export async function shortHash(key: Uint8Array): Promise<string> {
|
||||||
|
const buf = await globalThis.crypto.subtle.digest('SHA-256', key);
|
||||||
|
const arr = new Uint8Array(buf).slice(0, 8);
|
||||||
|
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { CryptoProvider } from '@shade/core';
|
|||||||
import { createPrekeyRoutes } from './routes.js';
|
import { createPrekeyRoutes } from './routes.js';
|
||||||
import { MemoryPrekeyStore } from './memory-store.js';
|
import { MemoryPrekeyStore } from './memory-store.js';
|
||||||
import type { PrekeyStore } from './store.js';
|
import type { PrekeyStore } from './store.js';
|
||||||
|
import type { PrekeyServerEvents } from './events.js';
|
||||||
|
|
||||||
export { createPrekeyRoutes } from './routes.js';
|
export { createPrekeyRoutes } from './routes.js';
|
||||||
export { MemoryPrekeyStore } from './memory-store.js';
|
export { MemoryPrekeyStore } from './memory-store.js';
|
||||||
@@ -27,10 +28,16 @@ export function createPrekeyServer(options: {
|
|||||||
crypto: CryptoProvider;
|
crypto: CryptoProvider;
|
||||||
store?: PrekeyStore;
|
store?: PrekeyStore;
|
||||||
disableRateLimit?: boolean;
|
disableRateLimit?: boolean;
|
||||||
|
events?: PrekeyServerEvents;
|
||||||
}): Hono {
|
}): Hono {
|
||||||
const store = options.store ?? new MemoryPrekeyStore();
|
const store = options.store ?? new MemoryPrekeyStore();
|
||||||
return createPrekeyRoutes(store, options.crypto, { disableRateLimit: options.disableRateLimit });
|
return createPrekeyRoutes(store, options.crypto, {
|
||||||
|
disableRateLimit: options.disableRateLimit,
|
||||||
|
events: options.events,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
|
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
|
||||||
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
|
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
|
||||||
|
export { PrekeyServerEvents, shortHash as serverShortHash } from './events.js';
|
||||||
|
export type { PrekeyServerEvent, PrekeyServerEventName, PrekeyServerEventMap, PrekeyServerEventListener } from './events.js';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { CryptoProvider } from '@shade/core';
|
import type { CryptoProvider } from '@shade/core';
|
||||||
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError } from '@shade/core';
|
import { fromBase64, errorToHttpStatus, ShadeError, ValidationError, RateLimitError } from '@shade/core';
|
||||||
import type { PrekeyStore } from './store.js';
|
import type { PrekeyStore } from './store.js';
|
||||||
import { verifyPayload, validateAddress } from './auth.js';
|
import { verifyPayload, validateAddress } from './auth.js';
|
||||||
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
|
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
|
||||||
|
import { PrekeyServerEvents, shortHash } from './events.js';
|
||||||
|
|
||||||
/** Max POST body size in bytes (64KB) */
|
/** Max POST body size in bytes (64KB) */
|
||||||
const MAX_BODY_SIZE = 64 * 1024;
|
const MAX_BODY_SIZE = 64 * 1024;
|
||||||
@@ -23,6 +24,8 @@ const MAX_BODY_SIZE = 64 * 1024;
|
|||||||
export interface PrekeyRoutesOptions {
|
export interface PrekeyRoutesOptions {
|
||||||
/** Disable rate limiting (for tests). Default: enabled. */
|
/** Disable rate limiting (for tests). Default: enabled. */
|
||||||
disableRateLimit?: boolean;
|
disableRateLimit?: boolean;
|
||||||
|
/** Optional event emitter for observability. */
|
||||||
|
events?: PrekeyServerEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPrekeyRoutes(
|
export function createPrekeyRoutes(
|
||||||
@@ -31,6 +34,7 @@ export function createPrekeyRoutes(
|
|||||||
options: PrekeyRoutesOptions = {},
|
options: PrekeyRoutesOptions = {},
|
||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
const events = options.events;
|
||||||
|
|
||||||
// Rate limiters (one per route, per IP or per identity)
|
// Rate limiters (one per route, per IP or per identity)
|
||||||
const rlStore = new MemoryRateLimitStore();
|
const rlStore = new MemoryRateLimitStore();
|
||||||
@@ -51,6 +55,13 @@ export function createPrekeyRoutes(
|
|||||||
|
|
||||||
// Global error handler — maps ShadeError to HTTP status
|
// Global error handler — maps ShadeError to HTTP status
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
|
if (err instanceof RateLimitError) {
|
||||||
|
// Emit rate-limited event before responding
|
||||||
|
events?.emit('server.rate_limited', {
|
||||||
|
route: c.req.routePath ?? c.req.path,
|
||||||
|
key: getClientIp(c),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (err instanceof ShadeError) {
|
if (err instanceof ShadeError) {
|
||||||
const status = errorToHttpStatus(err);
|
const status = errorToHttpStatus(err);
|
||||||
const body: any = err.toJSON();
|
const body: any = err.toJSON();
|
||||||
@@ -101,6 +112,11 @@ export function createPrekeyRoutes(
|
|||||||
await store.saveOneTimePreKeys(addr, keys);
|
await store.saveOneTimePreKeys(addr, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (events) {
|
||||||
|
const hash = await shortHash(signingKey);
|
||||||
|
events.emit('server.identity_registered', { address: addr, identityKeyHash: hash });
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +154,11 @@ export function createPrekeyRoutes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
events?.emit('server.bundle_fetched', {
|
||||||
|
address,
|
||||||
|
hadOneTimePreKey: oneTimePreKey != null,
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(bundle);
|
return c.json(bundle);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,6 +191,11 @@ export function createPrekeyRoutes(
|
|||||||
await store.saveOneTimePreKeys(addr, keys);
|
await store.saveOneTimePreKeys(addr, keys);
|
||||||
|
|
||||||
const count = await store.getOneTimePreKeyCount(addr);
|
const count = await store.getOneTimePreKeyCount(addr);
|
||||||
|
events?.emit('server.prekeys_replenished', {
|
||||||
|
address: addr,
|
||||||
|
count: keys.length,
|
||||||
|
totalAfter: count,
|
||||||
|
});
|
||||||
return c.json({ ok: true, remaining: count });
|
return c.json({ ok: true, remaining: count });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +221,7 @@ export function createPrekeyRoutes(
|
|||||||
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
|
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
|
||||||
|
|
||||||
await store.deleteAll(address);
|
await store.deleteAll(address);
|
||||||
|
events?.emit('server.identity_deleted', { address });
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
142
packages/shade-server/tests/events.test.ts
Normal file
142
packages/shade-server/tests/events.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents, signPayload } from '../src/index.js';
|
||||||
|
import type { PrekeyServerEvent } from '../src/index.js';
|
||||||
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||||
|
import { generateIdentityKeyPair } from '@shade/core';
|
||||||
|
|
||||||
|
const crypto = new SubtleCryptoProvider();
|
||||||
|
|
||||||
|
function b64(bytes: Uint8Array): string {
|
||||||
|
return Buffer.from(bytes).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randBytes(n: number): Uint8Array {
|
||||||
|
const buf = new Uint8Array(n);
|
||||||
|
globalThis.crypto.getRandomValues(buf);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PrekeyServerEvents integration', () => {
|
||||||
|
test('emits events for register, fetch, replenish, delete', async () => {
|
||||||
|
const events = new PrekeyServerEvents();
|
||||||
|
const received: PrekeyServerEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
const store = new MemoryPrekeyStore();
|
||||||
|
const app = createPrekeyServer({ crypto, store, disableRateLimit: true, events });
|
||||||
|
|
||||||
|
const alice = await generateIdentityKeyPair(crypto);
|
||||||
|
|
||||||
|
// Register
|
||||||
|
const regBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||||
|
address: 'alice',
|
||||||
|
identitySigningKey: b64(alice.signingPublicKey),
|
||||||
|
identityDHKey: b64(alice.dhPublicKey),
|
||||||
|
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||||
|
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
|
||||||
|
});
|
||||||
|
await app.request('/v1/keys/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(regBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch bundle
|
||||||
|
await app.request('/v1/keys/bundle/alice');
|
||||||
|
|
||||||
|
// Replenish
|
||||||
|
const replenishBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||||
|
address: 'alice',
|
||||||
|
oneTimePreKeys: [
|
||||||
|
{ keyId: 200, publicKey: b64(randBytes(32)) },
|
||||||
|
{ keyId: 201, publicKey: b64(randBytes(32)) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await app.request('/v1/keys/replenish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(replenishBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const delBody = await signPayload(crypto, alice.signingPrivateKey, { address: 'alice' });
|
||||||
|
await app.request('/v1/keys/alice', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(delBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = received.map((e) => e.name);
|
||||||
|
expect(names).toContain('server.identity_registered');
|
||||||
|
expect(names).toContain('server.bundle_fetched');
|
||||||
|
expect(names).toContain('server.prekeys_replenished');
|
||||||
|
expect(names).toContain('server.identity_deleted');
|
||||||
|
|
||||||
|
// Verify hadOneTimePreKey is true on the fetch event
|
||||||
|
const fetchEvent = received.find((e) => e.name === 'server.bundle_fetched');
|
||||||
|
expect((fetchEvent!.data as any).hadOneTimePreKey).toBe(true);
|
||||||
|
|
||||||
|
// Verify replenish reports the right count
|
||||||
|
const replenishEvent = received.find((e) => e.name === 'server.prekeys_replenished');
|
||||||
|
expect((replenishEvent!.data as any).count).toBe(2);
|
||||||
|
expect((replenishEvent!.data as any).totalAfter).toBe(2); // 1 - 1 (consumed) + 2 = 2
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits server.rate_limited when limits trip', async () => {
|
||||||
|
const events = new PrekeyServerEvents();
|
||||||
|
const received: PrekeyServerEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
// Rate limit ENABLED for this test
|
||||||
|
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), events });
|
||||||
|
|
||||||
|
// Burn the register limit (5/hour)
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const id = await generateIdentityKeyPair(crypto);
|
||||||
|
const body = await signPayload(crypto, id.signingPrivateKey, {
|
||||||
|
address: `user${i}`,
|
||||||
|
identitySigningKey: b64(id.signingPublicKey),
|
||||||
|
identityDHKey: b64(id.dhPublicKey),
|
||||||
|
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||||
|
});
|
||||||
|
await app.request('/v1/keys/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.99' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitedEvents = received.filter((e) => e.name === 'server.rate_limited');
|
||||||
|
expect(rateLimitedEvents.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SECURITY: no key material in server event payloads', async () => {
|
||||||
|
const events = new PrekeyServerEvents();
|
||||||
|
const received: PrekeyServerEvent[] = [];
|
||||||
|
events.on((e) => received.push(e));
|
||||||
|
|
||||||
|
const app = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events });
|
||||||
|
const alice = await generateIdentityKeyPair(crypto);
|
||||||
|
|
||||||
|
const regBody = await signPayload(crypto, alice.signingPrivateKey, {
|
||||||
|
address: 'alice',
|
||||||
|
identitySigningKey: b64(alice.signingPublicKey),
|
||||||
|
identityDHKey: b64(alice.dhPublicKey),
|
||||||
|
signedPreKey: { keyId: 1, publicKey: b64(randBytes(32)), signature: b64(randBytes(64)) },
|
||||||
|
oneTimePreKeys: [{ keyId: 100, publicKey: b64(randBytes(32)) }],
|
||||||
|
});
|
||||||
|
await app.request('/v1/keys/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(regBody),
|
||||||
|
});
|
||||||
|
await app.request('/v1/keys/bundle/alice');
|
||||||
|
|
||||||
|
const json = JSON.stringify(received);
|
||||||
|
// Same regex as core: no 32+ byte base64 or 32+ char hex
|
||||||
|
const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g;
|
||||||
|
const longHex = /[0-9a-f]{32,}/gi;
|
||||||
|
expect(json.match(longBase64) ?? []).toEqual([]);
|
||||||
|
expect(json.match(longHex) ?? []).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
packages/shade-widgets/README.md
Normal file
89
packages/shade-widgets/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# @shade/widgets
|
||||||
|
|
||||||
|
Embeddable React widgets for live Shade observability. Drop them into any React dashboard (Nova, Orchestrator, your own apps) to show what's happening in your Shade deployment.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add @shade/widgets react react-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ShadeProvider, IdentityCard, SessionList, RecentActivity } from '@shade/widgets';
|
||||||
|
|
||||||
|
function MyDashboard() {
|
||||||
|
return (
|
||||||
|
<ShadeProvider
|
||||||
|
observerUrl="https://shade.example.com/shade-observer"
|
||||||
|
token={process.env.SHADE_TOKEN!}
|
||||||
|
>
|
||||||
|
<IdentityCard />
|
||||||
|
<SessionList />
|
||||||
|
<RecentActivity />
|
||||||
|
</ShadeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a running [`@shade/observer`](../shade-observer/README.md) endpoint for the widgets to talk to.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `<IdentityCard />` | Your fingerprint, registration ID, init/rotation timestamps |
|
||||||
|
| `<SessionList />` | Active Shade sessions with per-session message counts and DH ratchet steps |
|
||||||
|
| `<PrekeyStock lowThreshold={5} />` | Gauge of remaining one-time prekeys with low-stock warning |
|
||||||
|
| `<RecentActivity limit={50} />` | Live SSE feed of events flowing through the system |
|
||||||
|
| `<ServerStatus />` | Prekey server stats (registered identities, fetches, replenishes, rate limits) |
|
||||||
|
| `<FingerprintCompare />` | Paste a safety number to verify it matches your identity |
|
||||||
|
| `<WidgetCatalog />` | Meta-widget letting users pick which widgets to display |
|
||||||
|
|
||||||
|
## Letting users pick widgets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ShadeProvider observerUrl="..." token="...">
|
||||||
|
<WidgetCatalog
|
||||||
|
available={['identity', 'sessions', 'prekeys', 'activity', 'server']}
|
||||||
|
defaultLayout={['identity', 'sessions', 'activity']}
|
||||||
|
/>
|
||||||
|
</ShadeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
User selections persist to `localStorage`. Pass `onLayoutChange` to override with your own persistence.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ShadeProvider observerUrl="..." token="..." themeMode="auto">
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes: `dark` (default), `light`, `auto` (matches `prefers-color-scheme`).
|
||||||
|
|
||||||
|
Each widget renders self-contained CSS via inline styles — no Tailwind, no external CSS file, no conflicts with your host app.
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
For custom layouts, use the underlying hooks directly:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useShadeState, useShadeEvents } from '@shade/widgets';
|
||||||
|
|
||||||
|
function CustomWidget() {
|
||||||
|
const { state, loading } = useShadeState();
|
||||||
|
const { events, connected } = useShadeEvents();
|
||||||
|
// ... render whatever you want
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Polling interval
|
||||||
|
|
||||||
|
Default poll for `/api/state` is 5 seconds. Override:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ShadeProvider observerUrl="..." token="..." pollIntervalMs={2000}>
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSE event stream updates instantly regardless of poll interval.
|
||||||
17
packages/shade-widgets/package.json
Normal file
17
packages/shade-widgets/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@shade/widgets",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
packages/shade-widgets/src/ShadeProvider.tsx
Normal file
67
packages/shade-widgets/src/ShadeProvider.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
|
import { resolveTheme, type ShadeTheme, type ThemeMode } from './theme.js';
|
||||||
|
|
||||||
|
export interface ShadeContextValue {
|
||||||
|
observerUrl: string;
|
||||||
|
token: string;
|
||||||
|
theme: ShadeTheme;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShadeContext = createContext<ShadeContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface ShadeProviderProps {
|
||||||
|
/** Base URL of the Shade observer endpoint, e.g. "https://shade.example.com/shade-observer" */
|
||||||
|
observerUrl: string;
|
||||||
|
/** Bearer token for the observer (matches SHADE_OBSERVER_TOKEN on the server) */
|
||||||
|
token: string;
|
||||||
|
/** Theme mode: dark, light, or auto (matches system preference). Default: dark. */
|
||||||
|
themeMode?: ThemeMode;
|
||||||
|
/** How often to poll /api/state in milliseconds. Default: 5000. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShadeProvider — root context provider that all Shade widgets need.
|
||||||
|
*
|
||||||
|
* Wrap your dashboard or specific widget container with this and pass
|
||||||
|
* the observer URL + token. All child widgets will share the connection.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* <ShadeProvider observerUrl="https://x.com/shade-observer" token={process.env.TOKEN!}>
|
||||||
|
* <SessionList />
|
||||||
|
* <PrekeyStock />
|
||||||
|
* </ShadeProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function ShadeProvider({
|
||||||
|
observerUrl,
|
||||||
|
token,
|
||||||
|
themeMode = 'dark',
|
||||||
|
pollIntervalMs = 5000,
|
||||||
|
children,
|
||||||
|
}: ShadeProviderProps): React.ReactElement {
|
||||||
|
const value = useMemo<ShadeContextValue>(
|
||||||
|
() => ({
|
||||||
|
observerUrl: observerUrl.replace(/\/$/, ''),
|
||||||
|
token,
|
||||||
|
theme: resolveTheme(themeMode),
|
||||||
|
pollIntervalMs,
|
||||||
|
}),
|
||||||
|
[observerUrl, token, themeMode, pollIntervalMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ShadeContext.Provider value={value}>{children}</ShadeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal hook used by widgets to access the context. Throws if no provider. */
|
||||||
|
export function useShadeContext(): ShadeContextValue {
|
||||||
|
const ctx = useContext(ShadeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
'Shade widgets must be wrapped in <ShadeProvider observerUrl="..." token="..." />',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
60
packages/shade-widgets/src/components/FingerprintCompare.tsx
Normal file
60
packages/shade-widgets/src/components/FingerprintCompare.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useShadeState } from '../useShadeState.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FingerprintCompare — paste a fingerprint and check if it matches your own
|
||||||
|
* or any active session.
|
||||||
|
*/
|
||||||
|
export function FingerprintCompare(): React.ReactElement {
|
||||||
|
const { state } = useShadeState();
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const normalized = input.replace(/\s+/g, ' ').trim();
|
||||||
|
const yourFp = state?.identity.fingerprint?.replace(/\s+/g, ' ').trim();
|
||||||
|
const matches = normalized && yourFp && normalized === yourFp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell title="Verify fingerprint">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<textarea
|
||||||
|
placeholder="Paste a 60-digit safety number to compare with your own identity"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: theme.bg,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 10,
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.text,
|
||||||
|
resize: 'vertical',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{normalized && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1px solid ${matches ? theme.success : theme.danger}`,
|
||||||
|
background: matches ? 'rgba(34, 197, 94, 0.08)' : 'rgba(239, 68, 68, 0.08)',
|
||||||
|
color: matches ? theme.success : theme.danger,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matches ? '✓ Matches your identity' : '✗ Does not match your identity'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
packages/shade-widgets/src/components/IdentityCard.tsx
Normal file
69
packages/shade-widgets/src/components/IdentityCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeState } from '../useShadeState.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell, formatRelative } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IdentityCard — shows your own Shade identity: fingerprint, registration ID,
|
||||||
|
* when it was initialized and last rotated.
|
||||||
|
*/
|
||||||
|
export function IdentityCard(): React.ReactElement {
|
||||||
|
const { state, loading, error } = useShadeState();
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell title="Identity">
|
||||||
|
{loading && !state ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
|
||||||
|
) : !state?.identity.fingerprint ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13 }}>No identity initialized yet</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.text,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
padding: 12,
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.identity.fingerprint}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 11,
|
||||||
|
color: theme.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Reg ID</span>{' '}
|
||||||
|
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
|
||||||
|
{state.identity.registrationId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Initialized</span>{' '}
|
||||||
|
{formatRelative(state.identity.lastInitialized)}
|
||||||
|
</div>
|
||||||
|
{state.identity.lastRotated && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Rotated</span>{' '}
|
||||||
|
{formatRelative(state.identity.lastRotated)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
packages/shade-widgets/src/components/PrekeyStock.tsx
Normal file
64
packages/shade-widgets/src/components/PrekeyStock.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeState } from '../useShadeState.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell, formatRelative } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PrekeyStock — gauge showing one-time prekey count + signed prekey info.
|
||||||
|
*/
|
||||||
|
export function PrekeyStock({ lowThreshold = 5 }: { lowThreshold?: number }): React.ReactElement {
|
||||||
|
const { state } = useShadeState();
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
|
||||||
|
const remaining = state?.prekeys.oneTimeRemaining ?? 0;
|
||||||
|
const isLow = remaining < lowThreshold;
|
||||||
|
const color = isLow ? theme.warning : theme.success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell title="Prekey stock">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 36, fontWeight: 700, color, fontFamily: theme.fontMono, lineHeight: 1 }}>
|
||||||
|
{remaining}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: theme.textMuted }}>one-time prekeys</div>
|
||||||
|
</div>
|
||||||
|
{isLow && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: theme.warning,
|
||||||
|
background: 'rgba(234, 179, 8, 0.1)',
|
||||||
|
border: `1px solid ${theme.warning}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '6px 8px',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Low — replenish recommended (threshold: {lowThreshold})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: theme.textMuted }}>
|
||||||
|
{state?.prekeys.signedPreKeyId != null && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Signed prekey ID</span>{' '}
|
||||||
|
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
|
||||||
|
{state.prekeys.signedPreKeyId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state?.prekeys.lastGenerated && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Last generated</span>{' '}
|
||||||
|
{formatRelative(state.prekeys.lastGenerated)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state?.prekeys.lastConsumed && (
|
||||||
|
<div>
|
||||||
|
<span style={{ color: theme.textDim }}>Last consumed</span>{' '}
|
||||||
|
{formatRelative(state.prekeys.lastConsumed)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
packages/shade-widgets/src/components/RecentActivity.tsx
Normal file
132
packages/shade-widgets/src/components/RecentActivity.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeEvents, type ShadeEventEnvelope } from '../useShadeEvents.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell, ConnectionStatus, formatRelative } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecentActivity — live feed of all events flowing through the system.
|
||||||
|
*/
|
||||||
|
export function RecentActivity({ limit = 50 }: { limit?: number }): React.ReactElement {
|
||||||
|
const { events, connected } = useShadeEvents(limit);
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
|
||||||
|
// Show newest first
|
||||||
|
const sorted = [...events].reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell title="Recent activity" status={<ConnectionStatus connected={connected} />}>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
|
||||||
|
{connected ? 'Waiting for events…' : 'Not connected'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
maxHeight: 320,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sorted.map((e) => (
|
||||||
|
<EventRow key={`${e.source}-${e.seq}`} event={e} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventRow({ event }: { event: ShadeEventEnvelope }): React.ReactElement {
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
const color = colorForEvent(event.name, theme);
|
||||||
|
const summary = summaryForEvent(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '60px 1fr auto',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 8px',
|
||||||
|
background: theme.bg,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: theme.textDim, fontFamily: theme.fontMono, fontSize: 10 }}>
|
||||||
|
{formatRelative(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color, fontFamily: theme.fontMono, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
<strong>{event.name}</strong> <span style={{ color: theme.textMuted }}>{summary}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
color: theme.textDim,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
padding: '1px 4px',
|
||||||
|
background: theme.bgElevated,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForEvent(name: string, theme: any): string {
|
||||||
|
if (name.startsWith('message.encrypted')) return theme.accent;
|
||||||
|
if (name.startsWith('message.decrypted')) return theme.success;
|
||||||
|
if (name.startsWith('ratchet')) return theme.accent;
|
||||||
|
if (name.startsWith('identity')) return theme.warning;
|
||||||
|
if (name.startsWith('trust')) return theme.warning;
|
||||||
|
if (name.startsWith('server.rate_limited')) return theme.danger;
|
||||||
|
return theme.textMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryForEvent(e: ShadeEventEnvelope): string {
|
||||||
|
const d = e.data;
|
||||||
|
switch (e.name) {
|
||||||
|
case 'identity.initialized':
|
||||||
|
return `reg ${d.registrationId}`;
|
||||||
|
case 'identity.rotated':
|
||||||
|
return 'new identity';
|
||||||
|
case 'session.created':
|
||||||
|
return `${d.address} (${d.remoteIdentityKeyHash})`;
|
||||||
|
case 'session.removed':
|
||||||
|
return `${d.address}`;
|
||||||
|
case 'message.encrypted':
|
||||||
|
return `${d.address} #${d.counter} (${d.ciphertextSize}b)`;
|
||||||
|
case 'message.decrypted':
|
||||||
|
return `${d.address} #${d.counter} (${d.plaintextSize}b)`;
|
||||||
|
case 'ratchet.dh_step':
|
||||||
|
return `${d.address}`;
|
||||||
|
case 'prekey.generated':
|
||||||
|
return `+${d.count} (now ${d.totalAfter})`;
|
||||||
|
case 'prekey.consumed':
|
||||||
|
return `id ${d.keyId}`;
|
||||||
|
case 'signed_prekey.rotated':
|
||||||
|
return `${d.oldKeyId} → ${d.newKeyId}`;
|
||||||
|
case 'trust.pinned':
|
||||||
|
return `${d.address} (${d.identityKeyHash})`;
|
||||||
|
case 'trust.changed':
|
||||||
|
return `${d.address}`;
|
||||||
|
case 'server.identity_registered':
|
||||||
|
return `${d.address}`;
|
||||||
|
case 'server.bundle_fetched':
|
||||||
|
return `${d.address}${d.hadOneTimePreKey ? ' (with OTPK)' : ''}`;
|
||||||
|
case 'server.prekeys_replenished':
|
||||||
|
return `${d.address} +${d.count} (now ${d.totalAfter})`;
|
||||||
|
case 'server.identity_deleted':
|
||||||
|
return `${d.address}`;
|
||||||
|
case 'server.rate_limited':
|
||||||
|
return `${d.route}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
79
packages/shade-widgets/src/components/ServerStatus.tsx
Normal file
79
packages/shade-widgets/src/components/ServerStatus.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeState } from '../useShadeState.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerStatus — prekey server stats: registered identities, fetches, etc.
|
||||||
|
*/
|
||||||
|
export function ServerStatus(): React.ReactElement {
|
||||||
|
const { state } = useShadeState();
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
|
||||||
|
const stats = state?.server;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell title="Prekey server">
|
||||||
|
{!stats ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stat label="Registered" value={stats.registeredIdentities.length} theme={theme} />
|
||||||
|
<Stat label="Bundle fetches" value={stats.totalBundleFetches} theme={theme} />
|
||||||
|
<Stat label="Replenishes" value={stats.totalReplenishes} theme={theme} />
|
||||||
|
<Stat
|
||||||
|
label="Rate limited"
|
||||||
|
value={stats.totalRateLimited}
|
||||||
|
theme={theme}
|
||||||
|
color={stats.totalRateLimited > 0 ? theme.danger : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
theme: any;
|
||||||
|
color?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
background: theme.bg,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 10, color: theme.textDim, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: color ?? theme.text,
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
marginTop: 2,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
packages/shade-widgets/src/components/SessionList.tsx
Normal file
88
packages/shade-widgets/src/components/SessionList.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeState } from '../useShadeState.js';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { WidgetShell, formatRelative } from './shared.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionList — table of all active Shade sessions with per-session stats.
|
||||||
|
*/
|
||||||
|
export function SessionList(): React.ReactElement {
|
||||||
|
const { state, loading, error } = useShadeState();
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetShell
|
||||||
|
title="Sessions"
|
||||||
|
status={
|
||||||
|
<span style={{ fontSize: 11, color: theme.textMuted }}>
|
||||||
|
{state?.sessions.length ?? 0} active
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && !state ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
|
||||||
|
) : !state?.sessions.length ? (
|
||||||
|
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.sessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.address}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 10,
|
||||||
|
background: theme.bg,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.text,
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.address}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: theme.textDim,
|
||||||
|
fontFamily: theme.fontMono,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.remoteIdentityKeyHash}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: theme.textMuted, flexShrink: 0 }}>
|
||||||
|
<div title="Messages sent">
|
||||||
|
<span style={{ color: theme.accent }}>↑</span> {s.messageCountSent}
|
||||||
|
</div>
|
||||||
|
<div title="Messages received">
|
||||||
|
<span style={{ color: theme.accent }}>↓</span> {s.messageCountReceived}
|
||||||
|
</div>
|
||||||
|
<div title="DH ratchet steps">
|
||||||
|
<span style={{ color: theme.textDim }}>⟳</span> {s.dhRatchetSteps}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 60, textAlign: 'right' }}>{formatRelative(s.lastActivity)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
packages/shade-widgets/src/components/WidgetCatalog.tsx
Normal file
156
packages/shade-widgets/src/components/WidgetCatalog.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import { IdentityCard } from './IdentityCard.js';
|
||||||
|
import { SessionList } from './SessionList.js';
|
||||||
|
import { PrekeyStock } from './PrekeyStock.js';
|
||||||
|
import { RecentActivity } from './RecentActivity.js';
|
||||||
|
import { ServerStatus } from './ServerStatus.js';
|
||||||
|
import { FingerprintCompare } from './FingerprintCompare.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WidgetCatalog — meta-widget that lets the user pick which Shade widgets
|
||||||
|
* to display in their dashboard. Selection is persisted to localStorage
|
||||||
|
* (or via onLayoutChange callback).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WidgetKey = 'identity' | 'sessions' | 'prekeys' | 'activity' | 'server' | 'fingerprint';
|
||||||
|
|
||||||
|
const ALL_WIDGETS: Record<WidgetKey, { label: string; component: React.ComponentType }> = {
|
||||||
|
identity: { label: 'Identity', component: IdentityCard },
|
||||||
|
sessions: { label: 'Sessions', component: SessionList },
|
||||||
|
prekeys: { label: 'Prekey stock', component: PrekeyStock },
|
||||||
|
activity: { label: 'Recent activity', component: RecentActivity },
|
||||||
|
server: { label: 'Server status', component: ServerStatus },
|
||||||
|
fingerprint: { label: 'Verify fingerprint', component: FingerprintCompare },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WidgetCatalogProps {
|
||||||
|
/** Which widgets are available for selection. Default: all. */
|
||||||
|
available?: WidgetKey[];
|
||||||
|
/** Initial layout if nothing in localStorage. Default: identity, sessions, activity. */
|
||||||
|
defaultLayout?: WidgetKey[];
|
||||||
|
/** Optional callback when user changes the layout (for persistence) */
|
||||||
|
onLayoutChange?: (layout: WidgetKey[]) => void;
|
||||||
|
/** localStorage key to persist layout. Default: "shade-widget-layout" */
|
||||||
|
storageKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetCatalog({
|
||||||
|
available = Object.keys(ALL_WIDGETS) as WidgetKey[],
|
||||||
|
defaultLayout = ['identity', 'sessions', 'activity'],
|
||||||
|
onLayoutChange,
|
||||||
|
storageKey = 'shade-widget-layout',
|
||||||
|
}: WidgetCatalogProps): React.ReactElement {
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
const [layout, setLayout] = useState<WidgetKey[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return defaultLayout;
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(storageKey);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as WidgetKey[];
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter((k) => available.includes(k));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return defaultLayout;
|
||||||
|
});
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(layout));
|
||||||
|
} catch {}
|
||||||
|
onLayoutChange?.(layout);
|
||||||
|
}, [layout, onLayoutChange, storageKey]);
|
||||||
|
|
||||||
|
function toggle(key: WidgetKey): void {
|
||||||
|
setLayout((current) => {
|
||||||
|
if (current.includes(key)) {
|
||||||
|
return current.filter((k) => k !== key);
|
||||||
|
}
|
||||||
|
return [...current, key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shade-widget-catalog" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '4px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, color: theme.textMuted }}>
|
||||||
|
{layout.length} widget{layout.length !== 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(!editing)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
color: theme.text,
|
||||||
|
borderRadius: theme.radius,
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: theme.font,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editing ? 'Done' : 'Customize'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
background: theme.bgElevated,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: theme.radius,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{available.map((key) => {
|
||||||
|
const isActive = layout.includes(key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '6px 8px',
|
||||||
|
background: isActive ? theme.accentMuted : theme.bg,
|
||||||
|
border: `1px solid ${isActive ? theme.accent : theme.border}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: isActive ? theme.accent : theme.text,
|
||||||
|
fontFamily: theme.font,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={() => toggle(key)}
|
||||||
|
style={{ accentColor: theme.accent }}
|
||||||
|
/>
|
||||||
|
{ALL_WIDGETS[key].label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{layout.map((key) => {
|
||||||
|
const W = ALL_WIDGETS[key].component;
|
||||||
|
return <W key={key} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
packages/shade-widgets/src/components/shared.tsx
Normal file
97
packages/shade-widgets/src/components/shared.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useShadeContext } from '../ShadeProvider.js';
|
||||||
|
import type { ShadeTheme } from '../theme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common widget shell — provides consistent border, padding, header.
|
||||||
|
* All widgets render inside one of these for visual consistency.
|
||||||
|
*/
|
||||||
|
export interface WidgetShellProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
status?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetShell({ title, icon, status, children, className }: WidgetShellProps): React.ReactElement {
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`shade-widget ${className ?? ''}`}
|
||||||
|
style={{
|
||||||
|
background: theme.bgElevated,
|
||||||
|
border: `1px solid ${theme.border}`,
|
||||||
|
borderRadius: theme.radius,
|
||||||
|
padding: 16,
|
||||||
|
fontFamily: theme.font,
|
||||||
|
color: theme.text,
|
||||||
|
boxShadow: theme.shadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
{icon}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: theme.accent,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(ts: number | null): string {
|
||||||
|
if (!ts) return 'never';
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
if (diff < 1000) return 'just now';
|
||||||
|
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
||||||
|
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||||
|
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400_000)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusDot({ color }: { color: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionStatus({ connected }: { connected: boolean }): React.ReactElement {
|
||||||
|
const { theme } = useShadeContext();
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: theme.textMuted }}>
|
||||||
|
<StatusDot color={connected ? theme.success : theme.danger} />
|
||||||
|
{connected ? 'live' : 'offline'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/shade-widgets/src/index.ts
Normal file
18
packages/shade-widgets/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export { ShadeProvider, useShadeContext } from './ShadeProvider.js';
|
||||||
|
export { useShadeState } from './useShadeState.js';
|
||||||
|
export { useShadeEvents } from './useShadeEvents.js';
|
||||||
|
export { darkTheme, lightTheme, resolveTheme } from './theme.js';
|
||||||
|
|
||||||
|
export { IdentityCard } from './components/IdentityCard.js';
|
||||||
|
export { SessionList } from './components/SessionList.js';
|
||||||
|
export { PrekeyStock } from './components/PrekeyStock.js';
|
||||||
|
export { RecentActivity } from './components/RecentActivity.js';
|
||||||
|
export { ServerStatus } from './components/ServerStatus.js';
|
||||||
|
export { FingerprintCompare } from './components/FingerprintCompare.js';
|
||||||
|
export { WidgetCatalog } from './components/WidgetCatalog.js';
|
||||||
|
|
||||||
|
export type { ShadeProviderProps, ShadeContextValue } from './ShadeProvider.js';
|
||||||
|
export type { ShadeState, UseShadeStateResult } from './useShadeState.js';
|
||||||
|
export type { ShadeEventEnvelope, UseShadeEventsResult } from './useShadeEvents.js';
|
||||||
|
export type { ShadeTheme, ThemeMode } from './theme.js';
|
||||||
|
export type { WidgetCatalogProps, WidgetKey } from './components/WidgetCatalog.js';
|
||||||
72
packages/shade-widgets/src/theme.ts
Normal file
72
packages/shade-widgets/src/theme.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Theme tokens for Shade widgets.
|
||||||
|
*
|
||||||
|
* Self-contained — no external CSS required. Each widget reads tokens
|
||||||
|
* from the active theme via context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ShadeTheme {
|
||||||
|
bg: string;
|
||||||
|
bgElevated: string;
|
||||||
|
border: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
textDim: string;
|
||||||
|
accent: string;
|
||||||
|
accentMuted: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
danger: string;
|
||||||
|
font: string;
|
||||||
|
fontMono: string;
|
||||||
|
radius: string;
|
||||||
|
shadow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const darkTheme: ShadeTheme = {
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
bgElevated: '#161616',
|
||||||
|
border: '#262626',
|
||||||
|
text: '#e5e5e5',
|
||||||
|
textMuted: '#a3a3a3',
|
||||||
|
textDim: '#525252',
|
||||||
|
accent: '#f7c948',
|
||||||
|
accentMuted: 'rgba(247, 201, 72, 0.15)',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#eab308',
|
||||||
|
danger: '#ef4444',
|
||||||
|
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
||||||
|
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
radius: '8px',
|
||||||
|
shadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme: ShadeTheme = {
|
||||||
|
bg: '#ffffff',
|
||||||
|
bgElevated: '#f5f5f5',
|
||||||
|
border: '#e5e5e5',
|
||||||
|
text: '#0a0a0a',
|
||||||
|
textMuted: '#525252',
|
||||||
|
textDim: '#a3a3a3',
|
||||||
|
accent: '#ca8a04',
|
||||||
|
accentMuted: 'rgba(202, 138, 4, 0.1)',
|
||||||
|
success: '#16a34a',
|
||||||
|
warning: '#ca8a04',
|
||||||
|
danger: '#dc2626',
|
||||||
|
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
||||||
|
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
radius: '8px',
|
||||||
|
shadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light' | 'auto';
|
||||||
|
|
||||||
|
export function resolveTheme(mode: ThemeMode): ShadeTheme {
|
||||||
|
if (mode === 'dark') return darkTheme;
|
||||||
|
if (mode === 'light') return lightTheme;
|
||||||
|
// auto: use prefers-color-scheme
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: light)').matches) {
|
||||||
|
return lightTheme;
|
||||||
|
}
|
||||||
|
return darkTheme;
|
||||||
|
}
|
||||||
62
packages/shade-widgets/src/useShadeEvents.ts
Normal file
62
packages/shade-widgets/src/useShadeEvents.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useShadeContext } from './ShadeProvider.js';
|
||||||
|
|
||||||
|
export interface ShadeEventEnvelope {
|
||||||
|
source: 'client' | 'server';
|
||||||
|
seq: number;
|
||||||
|
timestamp: number;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseShadeEventsResult {
|
||||||
|
events: ShadeEventEnvelope[];
|
||||||
|
connected: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that subscribes to /api/events SSE stream and accumulates events.
|
||||||
|
*
|
||||||
|
* @param maxBuffer Max number of events to keep in memory (default: 200).
|
||||||
|
* Older events are dropped.
|
||||||
|
*/
|
||||||
|
export function useShadeEvents(maxBuffer = 200): UseShadeEventsResult {
|
||||||
|
const ctx = useShadeContext();
|
||||||
|
const [events, setEvents] = useState<ShadeEventEnvelope[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = `${ctx.observerUrl}/api/events?token=${encodeURIComponent(ctx.token)}`;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.addEventListener('open', () => setConnected(true));
|
||||||
|
es.addEventListener('error', () => {
|
||||||
|
setConnected(false);
|
||||||
|
setError(new Error('SSE connection error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('shade', (msg) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse((msg as MessageEvent).data) as ShadeEventEnvelope;
|
||||||
|
setEvents((prev) => {
|
||||||
|
const next = [...prev, event];
|
||||||
|
return next.length > maxBuffer ? next.slice(-maxBuffer) : next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shade] Failed to parse event:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
}, [ctx.observerUrl, ctx.token, maxBuffer]);
|
||||||
|
|
||||||
|
return { events, connected, error };
|
||||||
|
}
|
||||||
75
packages/shade-widgets/src/useShadeState.ts
Normal file
75
packages/shade-widgets/src/useShadeState.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useShadeContext } from './ShadeProvider.js';
|
||||||
|
|
||||||
|
export interface ShadeState {
|
||||||
|
identity: {
|
||||||
|
fingerprint: string | null;
|
||||||
|
registrationId: number | null;
|
||||||
|
lastInitialized: number | null;
|
||||||
|
lastRotated: number | null;
|
||||||
|
};
|
||||||
|
sessions: Array<{
|
||||||
|
address: string;
|
||||||
|
remoteIdentityKeyHash: string;
|
||||||
|
messageCountSent: number;
|
||||||
|
messageCountReceived: number;
|
||||||
|
lastActivity: number;
|
||||||
|
dhRatchetSteps: number;
|
||||||
|
}>;
|
||||||
|
prekeys: {
|
||||||
|
oneTimeRemaining: number;
|
||||||
|
lastGenerated: number | null;
|
||||||
|
lastConsumed: number | null;
|
||||||
|
signedPreKeyId: number | null;
|
||||||
|
signedPreKeyLastRotated: number | null;
|
||||||
|
};
|
||||||
|
retiredIdentities: number;
|
||||||
|
server: {
|
||||||
|
registeredIdentities: string[];
|
||||||
|
totalBundleFetches: number;
|
||||||
|
totalReplenishes: number;
|
||||||
|
totalDeleted: number;
|
||||||
|
totalRateLimited: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseShadeStateResult {
|
||||||
|
state: ShadeState | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that polls the observer's /api/state endpoint at the configured interval.
|
||||||
|
*/
|
||||||
|
export function useShadeState(): UseShadeStateResult {
|
||||||
|
const ctx = useShadeContext();
|
||||||
|
const [state, setState] = useState<ShadeState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ctx.observerUrl}/api/state`, {
|
||||||
|
headers: { Authorization: `Bearer ${ctx.token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const json = (await res.json()) as ShadeState;
|
||||||
|
setState(json);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [ctx.observerUrl, ctx.token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchState();
|
||||||
|
const interval = setInterval(fetchState, ctx.pollIntervalMs);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchState, ctx.pollIntervalMs]);
|
||||||
|
|
||||||
|
return { state, loading, error, refresh: fetchState };
|
||||||
|
}
|
||||||
10
packages/shade-widgets/tsconfig.json
Normal file
10
packages/shade-widgets/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user