mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: improve account appeal notifications (#2520)
* feat: improve account appeal notifications Adds Resend-backed account-action emails for bans/restores, public-safe ban reason summaries, scanner-triggered appeal scan guidance, and inline banned-account sign-in appeal messaging. Autoreview findings addressed: - [P2] Wire restored-account email into unban flow - [P2] Add the scan upload test to the CI selection * feat: quarantine malicious artifact uploads * fix: warn on repeated malicious rejections * fix: route banned sign-ins to appeal page * feat: add stored scan report downloads * chore: bump clawhub cli to 0.20.0 * test: cover malicious skill ban journey * test: align deletion cleanup expectations after merge * fix: hide deleted account packages before cleanup * fix: finalize appeals moderation flow
This commit is contained in:
@@ -24,3 +24,8 @@ DEV_AUTH_SECRET=
|
||||
|
||||
# Embeddings
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Transactional email
|
||||
RESEND_API_KEY=
|
||||
CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai
|
||||
CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.20.0 - 2026-06-06
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/API: replace local `clawhub scan` uploads with stored submitted-version scan report downloads, including owner-authorized `clawhub scan download <name> --version <version>` support for blocked skill and plugin submissions.
|
||||
|
||||
## 0.19.2 - 2026-06-05
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -43,6 +43,7 @@
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"resend": "6.12.4",
|
||||
"semver": "7.8.2",
|
||||
"shiki": "4.2.0",
|
||||
"sonner": "2.0.7",
|
||||
@@ -81,7 +82,7 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
@@ -160,39 +161,39 @@
|
||||
|
||||
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
|
||||
|
||||
"@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/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@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/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@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-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@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/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@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-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@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-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@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-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@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/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@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/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@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=="],
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
@@ -212,7 +213,7 @@
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="],
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
@@ -274,7 +275,7 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
|
||||
|
||||
@@ -540,35 +541,35 @@
|
||||
|
||||
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gCYzGOSkYY6Z034suzd20euvds7lPzMEEla62DJGE/ZAlR4OMBnNbvnBSsIGUCAr52gaWMsloGxP4tVGtN5aCA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQBD77MNgu+4Z6RAyg69acugdrhhVoWesr3l47zohYZ2YV2fwkWMArkN/2p4l6Ei+Sno7W5q+UsKdVWq5Ens0w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p/8cXUTK4Sob604e+xxPhVSbDFf29E6J0l/xESM9rdCfn3aDai3nEs6TnMHUsdD5aNlFz0+gDbiGlozLKGa2YA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KbtOSlVv6fElujiZWMcC3aQYhEwLVVf073RcwlSmpGQvIsKZFUqc0ef4sjUuurRwfbiI6JJXji9DQn+86hawmQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9fZ9i0o0/MQaw7om6Z6TsT7tfCk0jtbEFtC+aPqZL5RNsGWNcHvn6EHgL3dAprjq+AZzPTAQjg2JtpJaMt+6pg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tog7T66i+yFyIuuAnjL6xmW182W/qTBOUt6BtQ6lBIM1Eikh/fSMz4HGgvuCp5uU0zuIVWng7kDYthjCMOHcg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4b7yruLIIj/oZ3GpcLOvxcLCLDMraohn3IhQfN2hBP4w9UekG0DTIajWguJosRGfySf/+h/NwRUiMKoCpxCrqQ=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QRDOVZd0bhQ5jLsUsCC3dUxDWdTSVY9WMznowZgCGOrZfLLgctWpelhUASEiBwsXfat/JwYnVd1EaxMhqyT+UQ=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ypxT+Hq76NFG7woFbNbySnGEajFuYuIXeKz/jfCU+lXUoxfi3zLE6OG/ZQNeK3RpZSYJlAe2bokpsQ046CaieQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IdovCmfROFmpTLahdecTDFL74aLERVYN68F/mLZjfVh6LfoplPfI6deyHNMTcVujbokDV5k05XrFO22zfv+qjg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pcA8xlFp2tyk9T2R6Fi/rPe3bQ1MA+sSMDNUU5Ogu80GHOatkE4P8YCreGAvZErm5Ho2YRXnyvNrWiRncfVysQ=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4+fexHayrLCWpriPh4c6dNvL4an34DEZCG7zOM/FD5QNF6h8DT+bDXzyB/kfC8lDJbaFb7jKShtnjDQFXVQEjg=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+xTE6XC7wBgk0VKRXGG+QAnyW5S9b8vfsFpiMjf0waQTmSQSU8onsH/beyZ8X4aXVveJnotiy7VDjLOaW8bTrg=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ogji1TQNqH3ACLnYr+1Ns1nyrJ0CO2P585u9Hsh02pXvtFiFpgtgT2b3P4PnCOU86VVCvqtAeCN4OftMT8KU4w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
@@ -590,6 +591,8 @@
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
@@ -728,9 +731,7 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -744,23 +745,19 @@
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="],
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
@@ -776,7 +773,7 @@
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
@@ -836,13 +833,13 @@
|
||||
|
||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.354", "", {}, "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="],
|
||||
"env-runner": ["env-runner@0.1.9", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.15" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
|
||||
@@ -862,11 +859,13 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -874,8 +873,6 @@
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -884,8 +881,6 @@
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="],
|
||||
@@ -924,7 +919,7 @@
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"httpxy": ["httpxy@0.5.1", "", {}, "sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A=="],
|
||||
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
@@ -934,29 +929,21 @@
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="],
|
||||
"isbot": ["isbot@5.1.41", "", {}, "sha512-9WFV/Vhh0FEj6CQ7MoHweEL9/vLKPjeoD2I2htbAjX7kbW7VJs3OCpWOVyd+JraNTWVU6/DRx2MZy2KaUNXHcg=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
@@ -970,7 +957,7 @@
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
|
||||
@@ -980,7 +967,7 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
||||
|
||||
"launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
|
||||
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -1010,7 +997,7 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
|
||||
@@ -1020,7 +1007,7 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
@@ -1130,13 +1117,11 @@
|
||||
|
||||
"nitro": ["nitro@3.0.260429-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.17", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.2", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KweLVCUN5X9v9g+4yxAyRcz3FcOlnjmt9FyrAIWDxJETJmNT7I0JV0clgsONjo2nI0U5gwedXYA3RaNtF5XWzg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
|
||||
|
||||
"ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="],
|
||||
|
||||
@@ -1180,6 +1165,8 @@
|
||||
|
||||
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
|
||||
"postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
|
||||
|
||||
"postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
|
||||
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
@@ -1190,7 +1177,7 @@
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
@@ -1208,7 +1195,7 @@
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
@@ -1230,9 +1217,11 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resend": ["resend@6.12.4", "", { "dependencies": { "postal-mime": "2.7.4", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="],
|
||||
"rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1248,7 +1237,7 @@
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
|
||||
|
||||
@@ -1266,10 +1255,12 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
|
||||
"srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
@@ -1298,7 +1289,7 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
@@ -1306,11 +1297,9 @@
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
"tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
"tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
@@ -1480,27 +1469,17 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"jsdom/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"make-dir/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.134.0", "", {}, "sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
@@ -1526,8 +1505,6 @@
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
|
||||
Vendored
+4
@@ -19,6 +19,7 @@ import type * as devSeed from "../devSeed.js";
|
||||
import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloadMetrics from "../downloadMetrics.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as emailsNode from "../emailsNode.js";
|
||||
import type * as functions from "../functions.js";
|
||||
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
@@ -59,6 +60,7 @@ import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
|
||||
import type * as lib_devAuth from "../lib/devAuth.js";
|
||||
import type * as lib_devSeed from "../lib/devSeed.js";
|
||||
import type * as lib_emails from "../lib/emails.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
@@ -171,6 +173,7 @@ declare const fullApi: ApiFromModules<{
|
||||
devSeedExtra: typeof devSeedExtra;
|
||||
downloadMetrics: typeof downloadMetrics;
|
||||
downloads: typeof downloads;
|
||||
emailsNode: typeof emailsNode;
|
||||
functions: typeof functions;
|
||||
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
|
||||
githubBackups: typeof githubBackups;
|
||||
@@ -211,6 +214,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/depRegistryScan": typeof lib_depRegistryScan;
|
||||
"lib/devAuth": typeof lib_devAuth;
|
||||
"lib/devSeed": typeof lib_devSeed;
|
||||
"lib/emails": typeof lib_emails;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
|
||||
+2
-2
@@ -128,7 +128,7 @@ describe("handleDeletedUserSignIn", () => {
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes the moderator ban reason in the sign-in error", async () => {
|
||||
it("does not leak the moderator ban reason in the sign-in error", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123, banReason: "Chargeback fraud" },
|
||||
banRecords: [{ action: "user.ban" }],
|
||||
@@ -136,6 +136,6 @@ describe("handleDeletedUserSignIn", () => {
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(`${BANNED_REAUTH_MESSAGE} Reason: Chargeback fraud`);
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-9
@@ -9,19 +9,15 @@ import { isLocalDevAuthEnabled } from "./lib/devAuth";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.";
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
|
||||
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
|
||||
"This account has been permanently deleted and cannot be restored.";
|
||||
|
||||
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember", "abusePublisher"]);
|
||||
|
||||
function getBannedReauthMessage(reason: string | undefined) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason || normalizedReason.toLowerCase() === "malware auto-ban") {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
return `${BANNED_REAUTH_MESSAGE} Reason: ${normalizedReason}`;
|
||||
function getBannedReauthMessage(_reason: string | undefined) {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
|
||||
export async function handleDeletedUserSignIn(
|
||||
@@ -98,7 +94,7 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
const persona = typeof credentials.persona === "string" ? credentials.persona : "";
|
||||
if (!DEV_PERSONAS.has(persona)) throw new Error("Unknown dev persona");
|
||||
const userId: Id<"users"> = await ctx.runMutation(internal.users.upsertDevPersonaInternal, {
|
||||
persona: persona as "owner" | "user" | "admin" | "officialOrgMember",
|
||||
persona: persona as "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher",
|
||||
devAuthSecret,
|
||||
});
|
||||
return { userId };
|
||||
|
||||
@@ -3664,6 +3664,10 @@ export const getAccountDeletionFixtureState: ReturnType<typeof rawInternalMutati
|
||||
skillActive: Boolean(skill && !skill.softDeletedAt),
|
||||
skillSoftDeletedAt: skill?.softDeletedAt ?? null,
|
||||
packageExists: Boolean(pkg),
|
||||
skillPubliclyVisible: Boolean(
|
||||
skill && !skill.softDeletedAt && !skill.hiddenAt && skill.moderationStatus !== "removed",
|
||||
),
|
||||
packagePubliclyVisible: Boolean(pkg && !pkg.softDeletedAt),
|
||||
packageActive: Boolean(pkg && !pkg.softDeletedAt),
|
||||
packageSoftDeletedAt: pkg?.softDeletedAt ?? null,
|
||||
authAccountCount: authAccounts.length,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use node";
|
||||
|
||||
import { mkdir, appendFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { v } from "convex/values";
|
||||
import { Resend } from "resend";
|
||||
import { internalAction } from "./functions";
|
||||
import {
|
||||
buildBanNotificationEmail,
|
||||
buildMaliciousArtifactEmail,
|
||||
buildRestoredAccountEmail,
|
||||
type NotificationArtifact,
|
||||
} from "./lib/emails";
|
||||
|
||||
const DEFAULT_FROM = "ClawHub Security <noreply@notifications.openclaw.ai>";
|
||||
const DEFAULT_REPLY_TO = "security@notifications.openclaw.ai";
|
||||
|
||||
const notificationArtifactValidator = v.object({
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
name: v.string(),
|
||||
});
|
||||
|
||||
type SendEmailArgs = {
|
||||
idempotencyKey: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
function getEmailConfig() {
|
||||
return {
|
||||
apiKey: process.env.RESEND_API_KEY,
|
||||
from: process.env.CLAWHUB_SECURITY_EMAIL_FROM || DEFAULT_FROM,
|
||||
replyTo: process.env.CLAWHUB_SECURITY_EMAIL || DEFAULT_REPLY_TO,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendTransactionalEmail(args: SendEmailArgs) {
|
||||
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE?.trim();
|
||||
if (captureFile) {
|
||||
await mkdir(dirname(captureFile), { recursive: true });
|
||||
await appendFile(
|
||||
captureFile,
|
||||
`${JSON.stringify({ ...args, capturedAt: Date.now() })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { ok: true as const, id: "local-capture" };
|
||||
}
|
||||
|
||||
const config = getEmailConfig();
|
||||
if (!config.apiKey) {
|
||||
console.warn(`[emails] RESEND_API_KEY is not configured; skipped ${args.idempotencyKey}`);
|
||||
return { ok: false as const, reason: "missing_api_key" as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(config.apiKey);
|
||||
const result = await resend.emails.send(
|
||||
{
|
||||
from: config.from,
|
||||
to: args.to,
|
||||
replyTo: config.replyTo,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html,
|
||||
},
|
||||
{ idempotencyKey: args.idempotencyKey },
|
||||
);
|
||||
if (result.error) {
|
||||
console.error("[emails] Resend error", result.error);
|
||||
return { ok: false as const, reason: "resend_error" as const };
|
||||
}
|
||||
return { ok: true as const, id: result.data?.id ?? null };
|
||||
} catch (error) {
|
||||
console.error("[emails] Send failed", error);
|
||||
return { ok: false as const, reason: "send_error" as const };
|
||||
}
|
||||
}
|
||||
|
||||
export const sendBanNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
bannedAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
source: v.union(v.literal("manual"), v.literal("autoban")),
|
||||
reason: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
artifact: v.optional(notificationArtifactValidator),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: args.handle,
|
||||
source: args.source,
|
||||
reason: args.reason,
|
||||
trigger: args.trigger,
|
||||
artifact: args.artifact as NotificationArtifact | undefined,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `ban:${args.userId}:${args.bannedAt}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const sendRestoredAccountNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
restoredAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
restoredListings: v.optional(v.array(notificationArtifactValidator)),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildRestoredAccountEmail({
|
||||
handle: args.handle,
|
||||
restoredListings: args.restoredListings as NotificationArtifact[] | undefined,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `account-restored:${args.userId}:${args.restoredAt}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const sendMaliciousArtifactNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
findingAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
artifact: notificationArtifactValidator,
|
||||
version: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: args.handle,
|
||||
artifact: args.artifact as NotificationArtifact,
|
||||
version: args.version,
|
||||
trigger: args.trigger,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `malicious-artifact:${args.userId}:${args.findingAt}:${args.artifact.kind}:${args.artifact.name}:${args.version ?? ""}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -257,6 +257,76 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("httpApiV1 handlers", () => {
|
||||
it("rejects local scan upload submissions with scan-download guidance", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
|
||||
});
|
||||
const response = await __handlers.skillScanSubmitV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/-/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: { kind: "upload" } }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
expect(await response.text()).toContain("clawhub scan download <slug> --version <version>");
|
||||
});
|
||||
|
||||
it("downloads stored scan reports for submitted skill versions", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
expect(args).toMatchObject({
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "demo-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
scanId: "skill:demo-skill:1.2.3",
|
||||
status: "succeeded",
|
||||
sourceKind: "published",
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "demo-skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
report: {
|
||||
clawscan: { status: "malicious", checkedAt: 1 },
|
||||
skillspector: null,
|
||||
staticAnalysis: null,
|
||||
virustotal: null,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
completedAt: 1,
|
||||
};
|
||||
});
|
||||
const response = await __handlers.skillScanGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/skills/-/scan/download/demo-skill?version=1.2.3"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/zip");
|
||||
expect(response.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="clawhub-scan-demo-skill-1.2.3.zip"',
|
||||
);
|
||||
expect((await response.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("search returns empty results for blank query", async () => {
|
||||
const runAction = vi.fn();
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
@@ -110,6 +110,10 @@ export const __handlers = {
|
||||
skillsGetRouterV1Handler,
|
||||
publishSkillV1Handler,
|
||||
skillSecurityVerdictsV1Handler,
|
||||
skillScanSubmitV1Handler,
|
||||
skillScanGetRouterV1Handler,
|
||||
skillScanBatchSubmitV1Handler,
|
||||
skillScanBatchStatusV1Handler,
|
||||
skillsPostRouterV1Handler,
|
||||
skillsDeleteRouterV1Handler,
|
||||
exportSkillsV1Handler,
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
getPathSegments,
|
||||
json,
|
||||
parseJsonPayload,
|
||||
parseMultipartSkillScan,
|
||||
parseMultipartPublish,
|
||||
parsePublishBody,
|
||||
publicApiOrigin,
|
||||
@@ -294,9 +293,9 @@ const internalRefs = internal as unknown as {
|
||||
getByIdInternal: unknown;
|
||||
};
|
||||
securityScan: {
|
||||
createUploadedSkillScanRequestInternal: unknown;
|
||||
createPublishedSkillScanRequestInternal: unknown;
|
||||
enqueueBulkSkillRescanBatchForAdminInternal: unknown;
|
||||
getStoredScanReportForUserInternal: unknown;
|
||||
getSkillScanRequestForUserInternal: unknown;
|
||||
getBulkSkillRescanBatchStatusForAdminInternal: unknown;
|
||||
requestSkillRescanForUserInternal: unknown;
|
||||
@@ -334,10 +333,6 @@ function isMultipartRequest(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteStoredScanFiles(ctx: ActionCtx, files: Array<{ storageId: Id<"_storage"> }>) {
|
||||
await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId)));
|
||||
}
|
||||
|
||||
function encodeJsonEntry(value: unknown) {
|
||||
return new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
@@ -396,6 +391,16 @@ function buildSkillScanReportZip(status: Record<string, unknown>) {
|
||||
]);
|
||||
}
|
||||
|
||||
function safeScanReportFilenamePart(value: string) {
|
||||
return (
|
||||
value
|
||||
.replace(/^@/, "")
|
||||
.replaceAll("/", "-")
|
||||
.replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "artifact"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSkillScanBatchSubmit(ctx: ActionCtx, request: Request, headers: HeadersInit) {
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
@@ -1123,35 +1128,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
|
||||
try {
|
||||
if (isMultipartRequest(request)) {
|
||||
const multipart = await parseMultipartSkillScan(ctx, request, (payload) => {
|
||||
const parsed = parseArk(
|
||||
ApiV1SkillScanSubmitRequestSchema,
|
||||
payload,
|
||||
"Skill scan payload",
|
||||
) as {
|
||||
source: { kind: "upload" } | { kind: "published"; slug: string; version?: string };
|
||||
update?: boolean;
|
||||
};
|
||||
if (parsed.source.kind !== "upload") {
|
||||
throw new Error("multipart scan payload must use source.kind=upload");
|
||||
}
|
||||
if (parsed.update === true) {
|
||||
throw new Error("update is not valid for uploaded scans");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.createUploadedSkillScanRequestInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
files: multipart.files,
|
||||
},
|
||||
).catch(async (error) => {
|
||||
await deleteStoredScanFiles(ctx, multipart.files);
|
||||
throw error;
|
||||
});
|
||||
return json(result, 202, rate.headers);
|
||||
return text(
|
||||
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
|
||||
410,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
const body = parseArk(
|
||||
@@ -1163,7 +1144,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
update?: boolean;
|
||||
};
|
||||
if (body.source.kind === "upload") {
|
||||
return text("uploaded scans must use multipart/form-data", 400, rate.headers);
|
||||
return text(
|
||||
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
|
||||
410,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
@@ -1197,6 +1182,32 @@ export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
if (!scanId) return text("scanId required", 400, rate.headers);
|
||||
|
||||
try {
|
||||
if (segments.length === 2 && scanId === "download") {
|
||||
const name = (segments[1] ?? "").trim();
|
||||
const url = new URL(request.url);
|
||||
const version = url.searchParams.get("version")?.trim() ?? "";
|
||||
const kind = url.searchParams.get("kind")?.trim() === "plugin" ? "plugin" : "skill";
|
||||
if (!name) return text("name required", 400, rate.headers);
|
||||
if (!version) return text("version required", 400, rate.headers);
|
||||
|
||||
const status = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.getStoredScanReportForUserInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
kind,
|
||||
name,
|
||||
version,
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
const zip = buildSkillScanReportZip(status);
|
||||
const headers = mergeHeaders(rate.headers, {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="clawhub-scan-${safeScanReportFilenamePart(name)}-${safeScanReportFilenamePart(version)}.zip"`,
|
||||
});
|
||||
return new Response(zip, { status: 200, headers });
|
||||
}
|
||||
|
||||
const status = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.getSkillScanRequestForUserInternal,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APPEALS_URL,
|
||||
buildMaliciousArtifactEmail,
|
||||
buildBanNotificationEmail,
|
||||
buildRestoredAccountEmail,
|
||||
} from "./emails";
|
||||
|
||||
describe("moderation notification email copy", () => {
|
||||
it("builds public-safe malicious skill context with appeal but no local scan guidance", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "gingiris",
|
||||
source: "autoban",
|
||||
reason: "malicious.llm_malicious",
|
||||
artifact: { kind: "skill", name: "gingiris-launch" },
|
||||
trigger: "scanner.llm.malicious",
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("Your ClawHub account was disabled");
|
||||
expect(email.context).toMatchObject({
|
||||
appealUrl: APPEALS_URL,
|
||||
artifact: { kind: "skill", name: "gingiris-launch" },
|
||||
scannerLabel: "ClawScan",
|
||||
findingSummary: "ClawScan classified the uploaded skill as malicious.",
|
||||
});
|
||||
expect(email.text).toContain("Skill: gingiris-launch");
|
||||
expect(email.text).not.toContain("Scanner:");
|
||||
expect(email.html).not.toContain("<strong>Scanner:</strong>");
|
||||
expect(email.text).not.toContain("republishing");
|
||||
expect(email.html).not.toContain("republishing");
|
||||
expect(email.text).not.toContain("To support your appeal, include scan results");
|
||||
expect(email.html).not.toContain("Include scan results with your appeal");
|
||||
expect(email.text).toContain("Appeal: https://appeals.openclaw.ai/");
|
||||
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
|
||||
expect(email.text).not.toContain("https://docs.openclaw.ai/clawhub/cli#scan-path");
|
||||
});
|
||||
|
||||
it("does not leak raw manual moderator notes into outbound email", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "target",
|
||||
source: "manual",
|
||||
reason: "internal reviewer note: reporter=user_123 secret finding id=abc",
|
||||
});
|
||||
|
||||
expect(email.context.findingSummary).toBe(
|
||||
"ClawHub staff disabled the account after a security review.",
|
||||
);
|
||||
expect(email.text).not.toContain("internal reviewer note");
|
||||
expect(email.text).not.toContain("reporter=user_123");
|
||||
expect(email.html).not.toContain("secret finding id");
|
||||
});
|
||||
|
||||
it("uses rate-limit copy without scan remediation guidance", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "publish-loop",
|
||||
source: "manual",
|
||||
reason: "rate limit triggered by automated CLI publishing",
|
||||
});
|
||||
|
||||
expect(email.context).toMatchObject({
|
||||
scannerLabel: null,
|
||||
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
|
||||
});
|
||||
expect(email.text).toContain("Publishing automation");
|
||||
expect(email.text).not.toContain("clawhub scan");
|
||||
expect(email.text).not.toContain("Include scan results");
|
||||
expect(email.html).not.toContain("Include scan results");
|
||||
expect(email.html).not.toContain("fixed local copy");
|
||||
});
|
||||
|
||||
it("builds restored-account copy that explains tokens stay revoked", () => {
|
||||
const email = buildRestoredAccountEmail({
|
||||
handle: "restored",
|
||||
restoredListings: [
|
||||
{ kind: "skill", name: "safe-one" },
|
||||
{ kind: "plugin", name: "@scope/demo" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("Your ClawHub account was restored");
|
||||
expect(email.text).toContain("Your ClawHub account can sign in again.");
|
||||
expect(email.text).toContain("Skill: safe-one");
|
||||
expect(email.text).toContain("Plugin: @scope/demo");
|
||||
expect(email.text).toContain("Previously revoked API tokens stay revoked.");
|
||||
});
|
||||
|
||||
it("builds malicious artifact copy without account appeal language", () => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: "publisher",
|
||||
artifact: { kind: "skill", name: "demo-skill" },
|
||||
version: "1.2.3",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("ClawHub blocked a skill version");
|
||||
expect(email.text).toContain("Skill: demo-skill");
|
||||
expect(email.text).toContain("Version: 1.2.3");
|
||||
expect(email.text).toContain("clawhub scan download demo-skill --version 1.2.3");
|
||||
expect(email.text).toContain("Increment the version number before uploading the fixed skill.");
|
||||
expect(email.text).toContain("https://docs.openclaw.ai/clawhub/moderation");
|
||||
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
|
||||
expect(email.text).not.toContain("fixed local copy");
|
||||
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.html).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.text).not.toContain(APPEALS_URL);
|
||||
expect(email.html).not.toContain(APPEALS_URL);
|
||||
expect(email.html).not.toContain("appeal this decision");
|
||||
});
|
||||
|
||||
it("builds plugin scan download copy with an explicit artifact kind", () => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: "publisher",
|
||||
artifact: { kind: "plugin", name: "@scope/demo" },
|
||||
version: "2.0.0",
|
||||
trigger: "malicious.static",
|
||||
});
|
||||
|
||||
expect(email.text).toContain("Plugin: @scope/demo");
|
||||
expect(email.text).toContain("clawhub scan download @scope/demo --version 2.0.0 --kind plugin");
|
||||
expect(email.text).toContain("Increment the version number before uploading the fixed plugin.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
export const APPEALS_URL = "https://appeals.openclaw.ai/";
|
||||
export const MODERATION_GUIDELINES_URL = "https://docs.openclaw.ai/clawhub/moderation";
|
||||
export const MALICIOUS_REJECTION_ACCOUNT_WARNING =
|
||||
"Repeated malicious rejections may lead to account disablement.";
|
||||
|
||||
export type NotificationArtifact = {
|
||||
kind: "skill" | "plugin";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type BanNotificationSource = "manual" | "autoban";
|
||||
|
||||
export type BanNotificationEmailArgs = {
|
||||
handle?: string;
|
||||
source: BanNotificationSource;
|
||||
reason?: string;
|
||||
trigger?: string;
|
||||
artifact?: NotificationArtifact;
|
||||
};
|
||||
|
||||
export type BanNotificationEmailContext = {
|
||||
appealUrl: typeof APPEALS_URL;
|
||||
artifact: NotificationArtifact | null;
|
||||
scannerLabel: string | null;
|
||||
findingSummary: string;
|
||||
};
|
||||
|
||||
export type TransactionalEmail = {
|
||||
subject: string;
|
||||
context: BanNotificationEmailContext;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type RestoredAccountEmailArgs = {
|
||||
handle?: string;
|
||||
restoredListings?: NotificationArtifact[];
|
||||
};
|
||||
|
||||
export type MaliciousArtifactEmailArgs = {
|
||||
handle?: string;
|
||||
artifact: NotificationArtifact;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
};
|
||||
|
||||
type BanReasonSummary = {
|
||||
scannerLabel: string | null;
|
||||
findingSummary: string;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeReasonInput(args: Pick<BanNotificationEmailArgs, "reason" | "trigger">) {
|
||||
return `${args.reason ?? ""} ${args.trigger ?? ""}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function summarizeBanReason(args: BanNotificationEmailArgs): BanReasonSummary {
|
||||
const normalized = normalizeReasonInput(args);
|
||||
|
||||
if (args.source === "autoban") {
|
||||
if (normalized.includes("virustotal") || normalized.includes("virus_total")) {
|
||||
return {
|
||||
scannerLabel: "VirusTotal",
|
||||
findingSummary: "VirusTotal telemetry contributed to a malicious upload finding.",
|
||||
};
|
||||
}
|
||||
if (normalized.includes("static")) {
|
||||
return {
|
||||
scannerLabel: "Static analysis",
|
||||
findingSummary: "Static analysis flagged malicious upload patterns.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes("clawscan") ||
|
||||
normalized.includes("llm") ||
|
||||
normalized.includes("malicious")
|
||||
) {
|
||||
return {
|
||||
scannerLabel: "ClawScan",
|
||||
findingSummary: "ClawScan classified the uploaded skill as malicious.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
scannerLabel: "ClawHub security checks",
|
||||
findingSummary: "ClawHub security checks classified the uploaded skill as malicious.",
|
||||
};
|
||||
}
|
||||
|
||||
if (/rate[-\s]?limit|publishing automation|automated(?: cli)? publishing/.test(normalized)) {
|
||||
return {
|
||||
scannerLabel: null,
|
||||
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scannerLabel: null,
|
||||
findingSummary: "ClawHub staff disabled the account after a security review.",
|
||||
};
|
||||
}
|
||||
|
||||
function artifactLabel(artifact: NotificationArtifact) {
|
||||
return `${artifact.kind === "skill" ? "Skill" : "Plugin"}: ${artifact.name}`;
|
||||
}
|
||||
|
||||
function greeting(handle: string | undefined) {
|
||||
return handle?.trim() ? `Hi ${handle.trim()},` : "Hi,";
|
||||
}
|
||||
|
||||
function emailShell(args: { preheader: string; title: string; body: string }) {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>${escapeHtml(args.title)}</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#ffffff;color:#1f2328;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(
|
||||
args.preheader,
|
||||
)}</span>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#ffffff;margin:0;padding:24px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;">
|
||||
<tr>
|
||||
<td style="padding:0;font-size:15px;line-height:22px;color:#1f2328;">
|
||||
${args.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function textLink(href: string, label: string) {
|
||||
return `<a href="${escapeHtml(href)}" style="color:#0969da;text-decoration:underline;">${escapeHtml(label)}</a>`;
|
||||
}
|
||||
|
||||
function detailLine(label: string, value: string) {
|
||||
return `<p style="margin:0 0 6px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
|
||||
}
|
||||
|
||||
function sectionHeading(label: string) {
|
||||
return `<p style="margin:18px 0 8px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}</strong></p>`;
|
||||
}
|
||||
|
||||
function paragraph(value: string) {
|
||||
return `<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">${escapeHtml(value)}</p>`;
|
||||
}
|
||||
|
||||
function bulletList(items: string[]) {
|
||||
return `<ul style="margin:0 0 14px;padding-left:22px;font-size:15px;line-height:22px;color:#1f2328;">${items
|
||||
.map((item) => `<li style="margin:0 0 6px;">${escapeHtml(item)}</li>`)
|
||||
.join("")}</ul>`;
|
||||
}
|
||||
|
||||
function commandBlock(command: string) {
|
||||
return `<pre style="margin:8px 0 14px;padding:10px 12px;background:#f6f8fa;border:1px solid #d8dee4;border-radius:6px;white-space:pre-wrap;color:#1f2328;font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',monospace;font-size:13px;line-height:20px;"><code>${escapeHtml(command)}</code></pre>`;
|
||||
}
|
||||
|
||||
function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) {
|
||||
const version = args.version?.trim() || "<version>";
|
||||
const kindFlag = args.artifact.kind === "plugin" ? " --kind plugin" : "";
|
||||
return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`;
|
||||
}
|
||||
|
||||
export function buildBanNotificationEmail(args: BanNotificationEmailArgs): TransactionalEmail {
|
||||
const summary = summarizeBanReason(args);
|
||||
const artifact = args.artifact ?? null;
|
||||
const context: BanNotificationEmailContext = {
|
||||
appealUrl: APPEALS_URL,
|
||||
artifact,
|
||||
scannerLabel: summary.scannerLabel,
|
||||
findingSummary: summary.findingSummary,
|
||||
};
|
||||
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
"Your ClawHub account was disabled.",
|
||||
`Reason: ${context.findingSummary}`,
|
||||
];
|
||||
if (artifact) lines.push(artifactLabel(artifact));
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"What changed:",
|
||||
"- Your ClawHub account cannot sign in.",
|
||||
"- Existing API tokens for the account have been revoked.",
|
||||
"- Published listings owned by the account may be hidden from public view.",
|
||||
"",
|
||||
`Appeal: ${APPEALS_URL}`,
|
||||
);
|
||||
|
||||
lines.push("", "ClawHub Security");
|
||||
|
||||
const impactItems = [
|
||||
"Your ClawHub account cannot sign in.",
|
||||
"Existing API tokens for the account have been revoked.",
|
||||
"Published listings owned by the account may be hidden from public view.",
|
||||
];
|
||||
const detailLines = [
|
||||
detailLine("Reason", context.findingSummary),
|
||||
...(artifact
|
||||
? [detailLine(artifact.kind === "skill" ? "Skill" : "Plugin", artifact.name)]
|
||||
: []),
|
||||
].join("");
|
||||
|
||||
const html = emailShell({
|
||||
title: "Your ClawHub account was disabled",
|
||||
preheader: context.findingSummary,
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph("We disabled your ClawHub account after an account-safety review."),
|
||||
detailLines,
|
||||
sectionHeading("What changed"),
|
||||
bulletList(impactItems),
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">You can ${textLink(APPEALS_URL, "appeal this decision")} if you believe this was a mistake.</p>`,
|
||||
`<p style="margin:18px 0 0;color:#6a737d;font-size:13px;line-height:20px;">If you already appealed, you do not need to send a separate support email.</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject: "Your ClawHub account was disabled",
|
||||
context,
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestoredAccountEmail(args: RestoredAccountEmailArgs) {
|
||||
const restoredListings = args.restoredListings ?? [];
|
||||
const listingLines = restoredListings.map(artifactLabel);
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
"Your ClawHub account can sign in again.",
|
||||
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
|
||||
];
|
||||
if (listingLines.length > 0) {
|
||||
lines.push("", "Restored listings:", ...listingLines);
|
||||
}
|
||||
lines.push("", "ClawHub Security");
|
||||
|
||||
const html = emailShell({
|
||||
title: "Your ClawHub account was restored",
|
||||
preheader: "Your ClawHub account can sign in again.",
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph("Your ClawHub account can sign in again."),
|
||||
paragraph(
|
||||
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
|
||||
),
|
||||
listingLines.length > 0
|
||||
? `${sectionHeading("Restored listings")}${bulletList(listingLines)}`
|
||||
: "",
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Settings: ${textLink("https://clawhub.ai/settings", "open ClawHub settings")}</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject: "Your ClawHub account was restored",
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailArgs) {
|
||||
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
|
||||
const artifactLabelText = artifactLabel(args.artifact);
|
||||
const scanDownloadCommand = buildScanDownloadCommand(args);
|
||||
const findingSummary =
|
||||
args.trigger?.includes("static") === true
|
||||
? "Static analysis flagged malicious upload patterns."
|
||||
: args.trigger?.includes("virustotal") === true || args.trigger?.includes("vt_") === true
|
||||
? "VirusTotal telemetry contributed to a malicious upload finding."
|
||||
: "ClawScan classified the uploaded artifact as malicious.";
|
||||
const subject = `ClawHub blocked a ${artifactKind} version`;
|
||||
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
`ClawHub blocked a ${artifactKind} version after a security scan.`,
|
||||
`Reason: ${findingSummary}`,
|
||||
artifactLabelText,
|
||||
];
|
||||
if (args.version?.trim()) lines.push(`Version: ${args.version.trim()}`);
|
||||
lines.push(
|
||||
"",
|
||||
"What changed:",
|
||||
"- This version was not made public.",
|
||||
"- Your account can still sign in.",
|
||||
`- You can upload a fixed version of this ${artifactKind}.`,
|
||||
`- ${MALICIOUS_REJECTION_ACCOUNT_WARNING}`,
|
||||
"",
|
||||
"Download the scan results for the blocked submitted version:",
|
||||
scanDownloadCommand,
|
||||
`Docs: ${MODERATION_GUIDELINES_URL}`,
|
||||
`Increment the version number before uploading the fixed ${artifactKind}.`,
|
||||
"",
|
||||
"ClawHub Security",
|
||||
);
|
||||
|
||||
const detailLines = [
|
||||
detailLine("Reason", findingSummary),
|
||||
detailLine(args.artifact.kind === "skill" ? "Skill" : "Plugin", args.artifact.name),
|
||||
...(args.version?.trim() ? [detailLine("Version", args.version.trim())] : []),
|
||||
].join("");
|
||||
|
||||
const html = emailShell({
|
||||
title: subject,
|
||||
preheader: `${artifactLabelText} was blocked by ClawHub security scans.`,
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph(`ClawHub blocked a ${artifactKind} version after a security scan.`),
|
||||
detailLines,
|
||||
sectionHeading("What changed"),
|
||||
bulletList([
|
||||
"This version was not made public.",
|
||||
"Your account can still sign in.",
|
||||
`You can upload a fixed version of this ${artifactKind}.`,
|
||||
MALICIOUS_REJECTION_ACCOUNT_WARNING,
|
||||
]),
|
||||
sectionHeading("Review the blocked-version scan results"),
|
||||
paragraph("Download the scan results for the blocked submitted version."),
|
||||
commandBlock(scanDownloadCommand),
|
||||
paragraph(`Increment the version number before uploading the fixed ${artifactKind}.`),
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Docs: ${textLink(MODERATION_GUIDELINES_URL, "moderation and account safety")}</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject,
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
@@ -73,14 +73,21 @@ describe("assertValidSkillSlug", () => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
|
||||
});
|
||||
|
||||
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages", "publishers"])(
|
||||
"rejects reserved slug %s",
|
||||
(slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"account-banned",
|
||||
"admin",
|
||||
"settings",
|
||||
"api",
|
||||
"openclaw",
|
||||
"clawhub",
|
||||
"souls",
|
||||
"packages",
|
||||
"publishers",
|
||||
])("rejects reserved slug %s", (slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
});
|
||||
|
||||
it.each(["openclaw", "publishers"])(
|
||||
"emits the reserved-specific error for long reserved slug %s",
|
||||
|
||||
@@ -25,6 +25,7 @@ const MAX_SLUG_LENGTH = 96;
|
||||
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Current top-level route segments under src/routes/.
|
||||
"about",
|
||||
"account-banned",
|
||||
"admin",
|
||||
"cli",
|
||||
"dashboard",
|
||||
|
||||
+417
-20
@@ -30,6 +30,7 @@ import {
|
||||
listPublicPage,
|
||||
listPageForViewerInternal,
|
||||
listVersions,
|
||||
updateReleaseLlmAnalysisInternal,
|
||||
updateReleaseStaticScanInternal,
|
||||
applyAccountDeletionToOwnedPackagesBatchInternal,
|
||||
applyPublisherDeletionToOwnedPackagesBatchInternal,
|
||||
@@ -559,6 +560,23 @@ const updateReleaseStaticScanInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const updateReleaseLlmAnalysisInternalHandler = (
|
||||
updateReleaseLlmAnalysisInternal as unknown as WrappedHandler<
|
||||
{
|
||||
releaseId: string;
|
||||
llmAnalysis: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
confidence?: string;
|
||||
summary?: string;
|
||||
guidance?: string;
|
||||
findings?: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
},
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const softDeletePackageInternalHandler = (
|
||||
softDeletePackageInternal as unknown as WrappedHandler<
|
||||
{ userId: string; name: string },
|
||||
@@ -728,6 +746,8 @@ function makeReleaseDoc(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
softDeletedAt: undefined,
|
||||
createdBy: "users:owner",
|
||||
publishActor: { kind: "user", userId: "users:owner" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -4977,7 +4997,7 @@ describe("packages public queries", () => {
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("Version 1.0.0 already exists");
|
||||
).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again.");
|
||||
});
|
||||
|
||||
it("treats matching workflow duplicate package releases as idempotent", async () => {
|
||||
@@ -8550,6 +8570,383 @@ describe("package scan backfill", () => {
|
||||
expect.objectContaining({ scanStatus: "pending" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious latest plugin release and restores the previous clean latest", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const previousRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
distTags: [],
|
||||
verification: { scanStatus: "clean" },
|
||||
createdAt: 1_600_000_000_000,
|
||||
});
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-2",
|
||||
packageId: "packages:demo",
|
||||
version: "2.0.0",
|
||||
runtimeId: "demo.plugin",
|
||||
sourceRepo: "openclaw/demo-malicious",
|
||||
distTags: ["latest"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdBy: "users:member",
|
||||
publishActor: { kind: "user", userId: "users:member" },
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
runtimeId: "malicious.plugin",
|
||||
sourceRepo: "openclaw/demo-malicious",
|
||||
latestReleaseId: "packageReleases:demo-2",
|
||||
tags: { latest: "packageReleases:demo-2" },
|
||||
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "pending" } },
|
||||
verification: { scanStatus: "pending" },
|
||||
scanStatus: "pending",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-2") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([previousRelease, candidateRelease]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "pending",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-2",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-2",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-1",
|
||||
expect.objectContaining({ distTags: ["latest"] }),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
runtimeId: undefined,
|
||||
sourceRepo: undefined,
|
||||
scanStatus: "clean",
|
||||
tags: { latest: "packageReleases:demo-1" },
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "demo-plugin",
|
||||
version: "2.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious non-latest plugin release without changing the clean latest", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-beta",
|
||||
packageId: "packages:demo",
|
||||
version: "1.5.0",
|
||||
distTags: ["beta"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdBy: "users:member",
|
||||
publishActor: { kind: "user", userId: "users:member" },
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
createdAt: 1_650_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
latestReleaseId: "packageReleases:demo-latest",
|
||||
tags: {
|
||||
latest: "packageReleases:demo-latest",
|
||||
beta: "packageReleases:demo-beta",
|
||||
},
|
||||
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "clean" } },
|
||||
verification: { scanStatus: "clean" },
|
||||
scanStatus: "clean",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-beta") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "clean",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-beta",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-beta",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-beta",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
tags: { latest: "packageReleases:demo-latest" },
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({ latestReleaseId: "packageReleases:demo-beta" }),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "demo-plugin",
|
||||
version: "1.5.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a first malicious plugin release out of public package lists", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
distTags: ["latest"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
tags: { latest: "packageReleases:demo-1" },
|
||||
latestVersionSummary: { version: "1.0.0", verification: { scanStatus: "pending" } },
|
||||
verification: { scanStatus: "pending" },
|
||||
scanStatus: "pending",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-1") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([candidateRelease]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "pending",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-1",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
scanStatus: "malicious",
|
||||
tags: {},
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageSearchDigest:demo",
|
||||
expect.objectContaining({
|
||||
latestVersion: undefined,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -9019,12 +9416,6 @@ describe("owned package sanction batches", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:org-plugin",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "publisher.delete",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:org-plugin",
|
||||
expect.objectContaining({
|
||||
@@ -9034,6 +9425,12 @@ describe("owned package sanction batches", () => {
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:org-plugin",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "publisher.delete",
|
||||
});
|
||||
expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:org-plugin", expect.anything());
|
||||
});
|
||||
|
||||
@@ -9525,7 +9922,7 @@ describe("owned package sanction batches", () => {
|
||||
expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything());
|
||||
});
|
||||
|
||||
it("schedules hard deletes for account-deleted packages", async () => {
|
||||
it("hides and schedules hard deletes for account-deleted packages", async () => {
|
||||
const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx();
|
||||
|
||||
const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, {
|
||||
@@ -9534,12 +9931,6 @@ describe("owned package sanction batches", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:demo",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
@@ -9549,6 +9940,12 @@ describe("owned package sanction batches", () => {
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:demo",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules hard deletes for account-deleted packages owned through the user's personal publisher", async () => {
|
||||
@@ -9588,12 +9985,6 @@ describe("owned package sanction batches", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:personal-publisher",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:personal-publisher",
|
||||
expect.objectContaining({
|
||||
@@ -9603,6 +9994,12 @@ describe("owned package sanction batches", () => {
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:personal-publisher",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not delete org-owned packages when deleting a member account", async () => {
|
||||
|
||||
+211
-5
@@ -3279,6 +3279,28 @@ function rebuildPackageTagsFromActiveReleases(releases: Doc<"packageReleases">[]
|
||||
return tags;
|
||||
}
|
||||
|
||||
function packageLatestSummaryFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release
|
||||
? {
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
changelog: release.changelog,
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
artifact: packageArtifactSummary(release),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function packageRuntimeIdFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release?.runtimeId ?? release?.capabilities?.runtimeId;
|
||||
}
|
||||
|
||||
function packageSourceRepoFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release?.sourceRepo ?? release?.verification?.sourceRepo;
|
||||
}
|
||||
|
||||
async function restorePackageDoc(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
@@ -3378,7 +3400,7 @@ async function restorePackageDoc(
|
||||
compatibility: nextLatest?.compatibility,
|
||||
capabilities: nextLatest?.capabilities,
|
||||
verification: nextLatest?.verification,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : undefined,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : pkg.scanStatus,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
@@ -6533,7 +6555,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
releaseId: releaseExists._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
throw new ConvexError(
|
||||
`Version ${nextVersionLabel} already exists. Increment the version number and try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const priorReleases = existing
|
||||
@@ -6571,6 +6595,8 @@ export const insertReleaseInternal = internalMutation({
|
||||
normalizedBundleManifest: args.normalizedBundleManifest,
|
||||
compatibility: args.compatibility,
|
||||
capabilities: nextCapabilities,
|
||||
runtimeId: args.runtimeId,
|
||||
sourceRepo: args.sourceRepo,
|
||||
verification: args.verification,
|
||||
staticScan: args.staticScan,
|
||||
source: args.source,
|
||||
@@ -6642,10 +6668,186 @@ function isReleaseActive(
|
||||
return Boolean(release && !release.softDeletedAt);
|
||||
}
|
||||
|
||||
async function syncLatestPackageVerification(ctx: MutationCtx, release: Doc<"packageReleases">) {
|
||||
async function recordMaliciousPluginReleaseFinding(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
|
||||
ownerUserId: release.createdBy,
|
||||
artifactKind: "plugin",
|
||||
artifactName: pkg.normalizedName,
|
||||
version: release.version,
|
||||
trigger,
|
||||
...(release.sha256hash ? { sha256hash: release.sha256hash } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function quarantineMaliciousNonLatestPackageRelease(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const maliciousVerification = release.verification
|
||||
? { ...release.verification, scanStatus: "malicious" as const }
|
||||
: release.verification;
|
||||
await ctx.db.patch(release._id, {
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
});
|
||||
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(pkg.tags ?? {}).filter(([, releaseId]) => releaseId !== release._id),
|
||||
) as Doc<"packages">["tags"];
|
||||
if (Object.keys(nextTags).length !== Object.keys(pkg.tags ?? {}).length) {
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
tags: nextTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
await ctx.db.patch(pkg._id, packagePatch);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...extractPackageDigestFields(nextPackage),
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.scheduler) {
|
||||
await recordMaliciousPluginReleaseFinding(
|
||||
ctx as Pick<MutationCtx, "scheduler">,
|
||||
pkg,
|
||||
release,
|
||||
trigger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantineMaliciousLatestPackageRelease(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const maliciousVerification = release.verification
|
||||
? { ...release.verification, scanStatus: "malicious" as const }
|
||||
: release.verification;
|
||||
const quarantinedRelease = {
|
||||
...release,
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
} as Doc<"packageReleases">;
|
||||
|
||||
await ctx.db.patch(release._id, {
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
});
|
||||
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
const activeNonMaliciousReleases = releases
|
||||
.map((candidate) => (candidate._id === release._id ? quarantinedRelease : candidate))
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!candidate.softDeletedAt && resolvePackageReleaseScanStatus(candidate) !== "malicious",
|
||||
);
|
||||
const nextLatest = getPreferredRestoredPackageRelease(pkg.family, activeNonMaliciousReleases);
|
||||
const nextTags = rebuildPackageTagsFromActiveReleases(activeNonMaliciousReleases);
|
||||
if (nextLatest) {
|
||||
nextTags.latest = nextLatest._id;
|
||||
if (!(nextLatest.distTags ?? []).includes("latest")) {
|
||||
await ctx.db.patch(nextLatest._id, {
|
||||
distTags: [...(nextLatest.distTags ?? []), "latest"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const restoredRuntimeId = packageRuntimeIdFromRelease(nextLatest);
|
||||
const restoredSourceRepo = packageSourceRepoFromRelease(nextLatest);
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
tags: nextTags,
|
||||
latestReleaseId: nextLatest?._id,
|
||||
latestVersionSummary: packageLatestSummaryFromRelease(nextLatest),
|
||||
summary: nextLatest?.summary,
|
||||
sourceRepo: restoredSourceRepo,
|
||||
runtimeId: restoredRuntimeId,
|
||||
capabilityTags: nextLatest?.capabilities?.capabilityTags,
|
||||
executesCode:
|
||||
typeof nextLatest?.capabilities?.executesCode === "boolean"
|
||||
? nextLatest.capabilities.executesCode
|
||||
: undefined,
|
||||
compatibility: nextLatest?.compatibility,
|
||||
capabilities: nextLatest?.capabilities,
|
||||
verification: nextLatest?.verification,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : "malicious",
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
await ctx.db.patch(pkg._id, packagePatch);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...extractPackageDigestFields(nextPackage),
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
|
||||
if (ctx.scheduler) {
|
||||
await recordMaliciousPluginReleaseFinding(
|
||||
ctx as Pick<MutationCtx, "scheduler">,
|
||||
pkg,
|
||||
release,
|
||||
trigger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type SyncLatestPackageVerificationOptions = {
|
||||
quarantineMaliciousLatest?: boolean;
|
||||
maliciousTrigger?: string;
|
||||
};
|
||||
|
||||
async function syncLatestPackageVerification(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
release: Doc<"packageReleases">,
|
||||
options: SyncLatestPackageVerificationOptions = {},
|
||||
) {
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
if (!pkg || pkg.latestReleaseId !== release._id) return;
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
if (!pkg) return;
|
||||
|
||||
if (scanStatus === "malicious" && options.quarantineMaliciousLatest) {
|
||||
if (pkg.latestReleaseId !== release._id) {
|
||||
await quarantineMaliciousNonLatestPackageRelease(
|
||||
ctx,
|
||||
pkg,
|
||||
release,
|
||||
options.maliciousTrigger ?? "malicious.llm_malicious",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await quarantineMaliciousLatestPackageRelease(
|
||||
ctx,
|
||||
pkg,
|
||||
release,
|
||||
options.maliciousTrigger ?? "malicious.llm_malicious",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pkg.latestReleaseId !== release._id) return;
|
||||
|
||||
const nextVerification = pkg.verification
|
||||
? {
|
||||
@@ -6762,7 +6964,11 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
|
||||
...release,
|
||||
llmAnalysis: args.llmAnalysis,
|
||||
} as Doc<"packageReleases">;
|
||||
await syncLatestPackageVerification(ctx, updatedRelease);
|
||||
const llmVerdict = (args.llmAnalysis.verdict ?? args.llmAnalysis.status).trim().toLowerCase();
|
||||
await syncLatestPackageVerification(ctx, updatedRelease, {
|
||||
quarantineMaliciousLatest: llmVerdict === "malicious",
|
||||
maliciousTrigger: "malicious.llm_malicious",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -818,6 +818,7 @@ const skillVersions = defineTable({
|
||||
),
|
||||
changelog: v.string(),
|
||||
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
|
||||
icon: v.optional(v.string()),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -1242,6 +1243,8 @@ const packageReleases = defineTable({
|
||||
normalizedBundleManifest: v.optional(v.any()),
|
||||
compatibility: packageCompatibilityValidator,
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
runtimeId: v.optional(v.string()),
|
||||
sourceRepo: v.optional(v.string()),
|
||||
verification: packageVerificationValidator,
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
failCodexScanJob,
|
||||
getBulkSkillRescanBatchStatusForAdminInternal,
|
||||
getSkillScanRequestForUserInternal,
|
||||
getStoredScanReportForUserInternal,
|
||||
pruneExpiredSkillScanRequestsInternal,
|
||||
requestPackageRescanForUserInternal,
|
||||
requestPackageRescan,
|
||||
@@ -221,6 +222,28 @@ const getSkillScanRequestForUserInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStoredScanReportForUserInternalHandler = (
|
||||
getStoredScanReportForUserInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
kind: "skill" | "plugin";
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
{
|
||||
ok: true;
|
||||
status: string;
|
||||
artifact: Record<string, unknown>;
|
||||
report: {
|
||||
clawscan: Record<string, unknown> | null;
|
||||
skillspector: Record<string, unknown> | null;
|
||||
staticAnalysis: Record<string, unknown> | null;
|
||||
virustotal: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const claimedJob = {
|
||||
_id: "securityScanJobs:1",
|
||||
_creationTime: 1,
|
||||
@@ -685,6 +708,77 @@ function makeSkillScanStatusCtx(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function makeStoredScanReportCtx(options: {
|
||||
actor: Record<string, unknown>;
|
||||
docs: Record<string, Record<string, unknown>>;
|
||||
membership?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const docs = new Map<string, Record<string, unknown>>([
|
||||
[String(options.actor._id), options.actor],
|
||||
...Object.entries(options.docs),
|
||||
]);
|
||||
const get = vi.fn(async (id: string) => docs.get(id) ?? null);
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((_indexName: string, buildRange: (q: { eq: typeof eq }) => unknown) => {
|
||||
const equals = new Map<string, unknown>();
|
||||
function eq(field: string, value: unknown) {
|
||||
equals.set(field, value);
|
||||
return { eq };
|
||||
}
|
||||
buildRange({ eq });
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (table === "publisherMembers") return options.membership ?? null;
|
||||
if (table === "skills") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) => String(doc._id).startsWith("skills:") && doc.slug === equals.get("slug"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("skillVersions:") &&
|
||||
doc.skillId === equals.get("skillId") &&
|
||||
doc.version === equals.get("version"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "packages") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("packages:") &&
|
||||
doc.normalizedName === equals.get("normalizedName"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "packageReleases") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("packageReleases:") &&
|
||||
doc.packageId === equals.get("packageId") &&
|
||||
doc.version === equals.get("version"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
db: {
|
||||
get,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("securityScan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -743,6 +837,269 @@ describe("securityScan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden skill versions to the owner", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
docs: {
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["network.exfiltration"],
|
||||
findings: [],
|
||||
summary: "Credential exfiltration pattern.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
status: "succeeded",
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
report: {
|
||||
clawscan: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
},
|
||||
staticAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Credential exfiltration pattern.",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden org skill versions to publisher-role uploaders", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
membership: {
|
||||
_id: "publisherMembers:member",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:member",
|
||||
role: "publisher",
|
||||
},
|
||||
docs: {
|
||||
"publishers:org": {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
},
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:member",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("denies stored scan reports to non-owners", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:intruder", role: "user" },
|
||||
docs: {
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1,
|
||||
files: [],
|
||||
llmAnalysis: { status: "malicious", checkedAt: 1 },
|
||||
createdAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:intruder",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden plugin releases to platform moderators", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:moderator", role: "moderator" },
|
||||
docs: {
|
||||
"packages:plugin": {
|
||||
_id: "packages:plugin",
|
||||
name: "@scope/demo",
|
||||
normalizedName: "@scope/demo",
|
||||
displayName: "Demo Plugin",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"packageReleases:hidden": {
|
||||
_id: "packageReleases:hidden",
|
||||
packageId: "packages:plugin",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
integritySha256: "def456",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:moderator",
|
||||
kind: "plugin",
|
||||
name: "@scope/demo",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
status: "succeeded",
|
||||
artifact: {
|
||||
kind: "plugin",
|
||||
name: "@scope/demo",
|
||||
displayName: "Demo Plugin",
|
||||
version: "2.0.0",
|
||||
},
|
||||
report: {
|
||||
clawscan: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden org plugin releases to publisher-role uploaders", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
membership: {
|
||||
_id: "publisherMembers:member",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:member",
|
||||
role: "publisher",
|
||||
},
|
||||
docs: {
|
||||
"publishers:org": {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
},
|
||||
"packages:plugin": {
|
||||
_id: "packages:plugin",
|
||||
name: "@org/demo",
|
||||
normalizedName: "@org/demo",
|
||||
displayName: "Org Plugin",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
},
|
||||
"packageReleases:hidden": {
|
||||
_id: "packageReleases:hidden",
|
||||
packageId: "packages:plugin",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
integritySha256: "def456",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:member",
|
||||
kind: "plugin",
|
||||
name: "@org/demo",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
artifact: {
|
||||
kind: "plugin",
|
||||
name: "@org/demo",
|
||||
displayName: "Org Plugin",
|
||||
version: "2.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets skill owners request skill rescans through the API helper", async () => {
|
||||
const { ctx, inserts } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
|
||||
@@ -88,6 +88,8 @@ type SkillSpectorAnalysisForStorage = {
|
||||
checkedAt: number;
|
||||
};
|
||||
|
||||
type StoredScanArtifactKind = "skill" | "plugin";
|
||||
|
||||
const jobSourceValidator = v.union(
|
||||
v.literal("publish"),
|
||||
v.literal("clawscan-note"),
|
||||
@@ -793,6 +795,54 @@ function skillScanReportFromRequest(request: Doc<"skillScanRequests">) {
|
||||
};
|
||||
}
|
||||
|
||||
function storedScanReportFromArtifact(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
clawscan: artifact.llmAnalysis ?? null,
|
||||
skillspector: artifact.skillSpectorAnalysis ?? null,
|
||||
staticAnalysis: artifact.staticScan ?? null,
|
||||
virustotal: artifact.vtAnalysis
|
||||
? {
|
||||
...artifact.vtAnalysis,
|
||||
...artifact.vtAnalysis.engineStats,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasStoredScanReport(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
artifact.llmAnalysis ||
|
||||
artifact.skillSpectorAnalysis ||
|
||||
artifact.staticScan ||
|
||||
artifact.vtAnalysis,
|
||||
);
|
||||
}
|
||||
|
||||
function completedAtFromStoredScanReport(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
const checkedAtValues = [
|
||||
artifact.llmAnalysis?.checkedAt,
|
||||
artifact.skillSpectorAnalysis?.checkedAt,
|
||||
artifact.staticScan?.checkedAt,
|
||||
artifact.vtAnalysis?.checkedAt,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
return checkedAtValues.length > 0 ? Math.max(...checkedAtValues) : undefined;
|
||||
}
|
||||
|
||||
function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) {
|
||||
return {
|
||||
...(request.slug ? { slug: request.slug } : {}),
|
||||
@@ -1111,6 +1161,146 @@ export const getSkillScanRequestForUserInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getStoredScanReportForUserInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
name: v.string(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const name = args.name.trim();
|
||||
const versionLabel = args.version.trim();
|
||||
if (!name) throw new ConvexError("Name required");
|
||||
if (!versionLabel) throw new ConvexError("Version required");
|
||||
|
||||
return args.kind === "plugin"
|
||||
? await getStoredPackageScanReportForUser(ctx, {
|
||||
actor,
|
||||
kind: args.kind,
|
||||
name,
|
||||
version: versionLabel,
|
||||
})
|
||||
: await getStoredSkillScanReportForUser(ctx, {
|
||||
actor,
|
||||
kind: args.kind,
|
||||
name,
|
||||
version: versionLabel,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function getStoredSkillScanReportForUser(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
actor: Doc<"users">;
|
||||
kind: StoredScanArtifactKind;
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
) {
|
||||
const slug = args.name.toLowerCase();
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: args.actor,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
allowedPublisherRoles: ["publisher"],
|
||||
allowPlatformModerator: true,
|
||||
});
|
||||
|
||||
const version = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (!version) throw new ConvexError("Skill version not found");
|
||||
if (!hasStoredScanReport(version)) throw new ConvexError("Scan results not found");
|
||||
|
||||
const completedAt = completedAtFromStoredScanReport(version);
|
||||
return {
|
||||
ok: true as const,
|
||||
scanId: `skill:${skill.slug}:${version.version}`,
|
||||
status: "succeeded" as const,
|
||||
sourceKind: "published" as const,
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: args.kind,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
version: version.version,
|
||||
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
|
||||
fileCount: version.files.length,
|
||||
},
|
||||
report: storedScanReportFromArtifact(version),
|
||||
createdAt: version.createdAt,
|
||||
updatedAt: Math.max(version.createdAt, completedAt ?? version.createdAt),
|
||||
completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStoredPackageScanReportForUser(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
actor: Doc<"users">;
|
||||
kind: StoredScanArtifactKind;
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
) {
|
||||
const normalizedName = normalizePackageName(args.name);
|
||||
const pkg = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizedName))
|
||||
.unique();
|
||||
if (!pkg || pkg.family === "skill") throw new ConvexError("Plugin not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: args.actor,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
allowedPublisherRoles: ["publisher"],
|
||||
allowPlatformModerator: true,
|
||||
});
|
||||
|
||||
const release = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (!release) throw new ConvexError("Plugin version not found");
|
||||
if (!hasStoredScanReport(release)) throw new ConvexError("Scan results not found");
|
||||
|
||||
const completedAt = completedAtFromStoredScanReport(release);
|
||||
return {
|
||||
ok: true as const,
|
||||
scanId: `plugin:${pkg.normalizedName}:${release.version}`,
|
||||
status: "succeeded" as const,
|
||||
sourceKind: "published" as const,
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: args.kind,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
version: release.version,
|
||||
...(release.integritySha256 ? { sha256hash: release.integritySha256 } : {}),
|
||||
fileCount: release.files.length,
|
||||
},
|
||||
report: storedScanReportFromArtifact(release),
|
||||
createdAt: release.createdAt,
|
||||
updatedAt: Math.max(release.createdAt, completedAt ?? release.createdAt),
|
||||
completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const recordSkillScanRequestSucceededInternal = internalMutation({
|
||||
args: {
|
||||
scanId: v.id("skillScanRequests"),
|
||||
|
||||
@@ -152,7 +152,11 @@ type Captured = {
|
||||
allPatches: Array<{ id: string; value: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
function buildDb(
|
||||
skill: SkillDoc,
|
||||
captured: Captured,
|
||||
existingVersion?: Record<string, unknown> | null,
|
||||
) {
|
||||
// Trigger-driven code (syncSkillSearchDigestForSkill -> getOwnerPublisher)
|
||||
// will ask for publishers via `db.get(ownerPublisherId)`. Return null so
|
||||
// getOwnerPublisher falls back to resolving the publisher from the owner user.
|
||||
@@ -229,7 +233,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
if (name !== "by_skill_version") {
|
||||
throw new Error(`unexpected skillVersions index ${name}`);
|
||||
}
|
||||
return { unique: async () => null };
|
||||
return { unique: async () => existingVersion ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -377,7 +381,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
return db;
|
||||
}
|
||||
|
||||
function buildCtx(skill: SkillDoc) {
|
||||
function buildCtx(skill: SkillDoc, existingVersion?: Record<string, unknown> | null) {
|
||||
const captured: Captured = {
|
||||
skillPatches: [],
|
||||
embeddingInserts: [],
|
||||
@@ -385,7 +389,7 @@ function buildCtx(skill: SkillDoc) {
|
||||
versionInserted: null,
|
||||
allPatches: [],
|
||||
};
|
||||
const db = buildDb(skill, captured);
|
||||
const db = buildDb(skill, captured, existingVersion);
|
||||
const ctx = {
|
||||
db,
|
||||
scheduler: { runAfter: vi.fn() },
|
||||
@@ -394,6 +398,20 @@ function buildCtx(skill: SkillDoc) {
|
||||
}
|
||||
|
||||
describe("skills.insertVersion latest-tag protection", () => {
|
||||
it("tells authors to increment the version when publishing a duplicate skill version", async () => {
|
||||
const skill = buildExistingSkill();
|
||||
const { ctx, captured } = buildCtx(skill, {
|
||||
_id: "skillVersions:existing",
|
||||
skillId: SKILL_ID,
|
||||
version: "1.0.1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never),
|
||||
).rejects.toThrow("Version 1.0.1 already exists. Increment the version number and try again.");
|
||||
expect(captured.versionInserted).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale clawScanNote values when inserting skill versions", async () => {
|
||||
const skill = buildExistingSkill();
|
||||
const { ctx, captured } = buildCtx(skill);
|
||||
|
||||
@@ -62,6 +62,13 @@ function makeCtx({
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
+534
-17
@@ -198,6 +198,7 @@ describe("skills anti-spam guards", () => {
|
||||
_id: "skills:1",
|
||||
slug: "taken-skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
@@ -278,6 +279,7 @@ describe("skills anti-spam guards", () => {
|
||||
_id: "skills:1",
|
||||
slug: "taken-skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
@@ -1491,11 +1493,44 @@ describe("skills anti-spam guards", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("schedules owner autoban when the latest version ClawScan verdict becomes malicious", async () => {
|
||||
const version = {
|
||||
it("quarantines a malicious latest skill version and restores the previous clean latest", async () => {
|
||||
const previousVersion = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now() - 10_000,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Clean version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["automation"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
llmAnalysis: { status: "clean", checkedAt: Date.now() },
|
||||
};
|
||||
const version = {
|
||||
_id: "skillVersions:2",
|
||||
skillId: "skills:1",
|
||||
version: "2.0.0",
|
||||
createdBy: "users:member",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Bad release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Bad Skill", description: "Bad version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
@@ -1509,13 +1544,37 @@ describe("skills anti-spam guards", () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "spam-skill",
|
||||
displayName: "Bad Skill",
|
||||
summary: "Bad version",
|
||||
icon: "lucide:Sparkles",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:2",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Bad release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: {
|
||||
latest: "skillVersions:2",
|
||||
beta: "skillVersions:2",
|
||||
stable: "skillVersions:1",
|
||||
},
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
@@ -1523,6 +1582,348 @@ describe("skills anti-spam guards", () => {
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skillVersions:1") return previousVersion;
|
||||
if (id === "skillVersions:2") return version;
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name === "by_owner") {
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected skills index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version, previousVersion],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return {
|
||||
collect: async () => [
|
||||
{
|
||||
_id: "skillEmbeddings:old",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
isApproved: true,
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
_id: "skillEmbeddings:new",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:2",
|
||||
isApproved: true,
|
||||
isLatest: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await updateVersionLlmAnalysisHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
versionId: "skillVersions:2",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Do not install.",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:2",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:2",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
displayName: "spam-skill",
|
||||
summary: "Clean version",
|
||||
icon: "lucide:Sparkles",
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {
|
||||
latest: "skillVersions:1",
|
||||
stable: "skillVersions:1",
|
||||
},
|
||||
latestVersionSummary: expect.objectContaining({
|
||||
version: "1.0.0",
|
||||
changelog: "Initial release",
|
||||
}),
|
||||
capabilityTags: ["automation"],
|
||||
moderationStatus: "active",
|
||||
moderationReason: "scanner.llm.clean",
|
||||
moderationVerdict: "clean",
|
||||
moderationFlags: undefined,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillSearchDigest",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:1",
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSkillId: "skills:1",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "skill",
|
||||
artifactName: "spam-skill",
|
||||
version: "2.0.0",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious non-latest skill version without changing the clean latest", async () => {
|
||||
const latestVersion = {
|
||||
_id: "skillVersions:latest",
|
||||
skillId: "skills:1",
|
||||
version: "2.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Latest release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Clean Latest", description: "Clean latest version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["automation"],
|
||||
llmAnalysis: { status: "clean", checkedAt: Date.now() },
|
||||
};
|
||||
const backportVersion = {
|
||||
_id: "skillVersions:backport",
|
||||
skillId: "skills:1",
|
||||
version: "1.5.0",
|
||||
createdBy: "users:member",
|
||||
createdAt: Date.now() - 1_000,
|
||||
changelog: "Backport release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Backport", description: "Backport version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
sha256hash: "b".repeat(64),
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "spam-skill",
|
||||
displayName: "Clean Latest",
|
||||
summary: "Clean latest version",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:latest",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: latestVersion.createdAt,
|
||||
changelog: "Latest release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: {
|
||||
latest: "skillVersions:latest",
|
||||
beta: "skillVersions:backport",
|
||||
},
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skillVersions:latest") return latestVersion;
|
||||
if (id === "skillVersions:backport") return backportVersion;
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await updateVersionLlmAnalysisHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
versionId: "skillVersions:backport",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Do not install.",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:backport",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:backport",
|
||||
expect.objectContaining({ softDeletedAt: expect.any(Number) }),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
tags: { latest: "skillVersions:latest" },
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({ latestVersionId: "skillVersions:backport" }),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "skill",
|
||||
artifactName: "spam-skill",
|
||||
version: "1.5.0",
|
||||
sha256hash: "b".repeat(64),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious first skill version without publishing a latest version", async () => {
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Bad first version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
sha256hash: "h".repeat(64),
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "new-spam-skill",
|
||||
displayName: "New Spam Skill",
|
||||
summary: "Bad first version",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: { latest: "skillVersions:1" },
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
@@ -1548,10 +1949,40 @@ describe("skills anti-spam guards", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return {
|
||||
collect: async () => [
|
||||
{
|
||||
_id: "skillEmbeddings:1",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
isApproved: true,
|
||||
isLatest: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert: vi.fn(),
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -1570,32 +2001,63 @@ describe("skills anti-spam guards", () => {
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"globalStats:1",
|
||||
expect.objectContaining({
|
||||
activeSkillsCount: 99,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillSearchDigest",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:1",
|
||||
latestVersionId: undefined,
|
||||
latestVersionSkillId: undefined,
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.autobanMalwareAuthorInternal,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:owner",
|
||||
slug: "spam-skill",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
artifactKind: "skill",
|
||||
artifactName: "new-spam-skill",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists ClawScan malware lock when malicious lands on an existing moderation hold", async () => {
|
||||
it("quarantines ClawScan malware while preserving an existing moderation hold", async () => {
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Held for review" },
|
||||
metadata: {},
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
@@ -1609,14 +2071,36 @@ describe("skills anti-spam guards", () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "quality-held-spam",
|
||||
displayName: "Quality Held Spam",
|
||||
summary: "Held for review",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
clawdis: undefined,
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: { latest: "skillVersions:1" },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "user.moderation",
|
||||
moderationFlags: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
@@ -1635,6 +2119,26 @@ describe("skills anti-spam guards", () => {
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return { collect: async () => [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
@@ -1659,21 +2163,34 @@ describe("skills anti-spam guards", () => {
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("skillVersions:1", expect.any(Object));
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReasonCodes: ["malicious.llm_malicious"],
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
patch.mock.calls.some(
|
||||
([id, value]) =>
|
||||
id === "skills:1" &&
|
||||
(value as Record<string, unknown>).moderationReason === "scanner.llm.malicious",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.autobanMalwareAuthorInternal,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:owner",
|
||||
slug: "quality-held-spam",
|
||||
artifactKind: "skill",
|
||||
artifactName: "quality-held-spam",
|
||||
version: "1.0.0",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
}),
|
||||
|
||||
+240
-13
@@ -487,7 +487,7 @@ async function patchStructuredModerationFromVersion(
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<
|
||||
Doc<"skillVersions">,
|
||||
"_id" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
|
||||
"_id" | "version" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
|
||||
>,
|
||||
) {
|
||||
const now = Date.now();
|
||||
@@ -507,11 +507,17 @@ async function patchStructuredModerationFromVersion(
|
||||
const shouldPersistClawScanMalwareBlock =
|
||||
patch.moderationVerdict === "malicious" && isClawScanMaliciousAnalysis(version.llmAnalysis);
|
||||
|
||||
if (shouldPersistClawScanMalwareBlock) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
await quarantineMaliciousLatestSkillVersion(ctx, skill, version, owner, now, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
// A ClawScan-malicious result is itself a security lock. Persist it even
|
||||
// when the skill was already hidden by a user or quality hold so a later
|
||||
// hold lift cannot restore a latest-version malware verdict.
|
||||
if (shouldPreserveExistingModerationLock(skill) && !shouldPersistClawScanMalwareBlock) {
|
||||
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
|
||||
if (shouldPreserveExistingModerationLock(skill)) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,13 +525,206 @@ async function patchStructuredModerationFromVersion(
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
}
|
||||
|
||||
async function scheduleClawScanAutobanForMalware(
|
||||
function latestVersionSummaryFromSkillVersion(
|
||||
version: Pick<
|
||||
Doc<"skillVersions">,
|
||||
"version" | "createdAt" | "changelog" | "changelogSource" | "parsed" | "apiKeyRequired"
|
||||
>,
|
||||
): NonNullable<Doc<"skills">["latestVersionSummary"]> {
|
||||
return {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource,
|
||||
clawdis: version.parsed?.clawdis,
|
||||
apiKeyRequired: version.apiKeyRequired,
|
||||
};
|
||||
}
|
||||
|
||||
function skillSummaryFromSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
|
||||
) {
|
||||
return version?.parsed?.frontmatter
|
||||
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function skillDisplayNameFromSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
|
||||
) {
|
||||
return version?.parsed?.frontmatter
|
||||
? getFrontmatterValue(version.parsed.frontmatter, "name")?.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function skillIconFromSkillVersion(version: Pick<Doc<"skillVersions">, "icon"> | null | undefined) {
|
||||
return version && "icon" in version ? version.icon : undefined;
|
||||
}
|
||||
|
||||
function isKnownMaliciousSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "_id" | "staticScan" | "vtAnalysis" | "llmAnalysis">,
|
||||
) {
|
||||
const patch = buildStructuredModerationPatch({
|
||||
staticScan: version.staticScan,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
llmAnalysis: version.llmAnalysis,
|
||||
vtStatus: version.vtAnalysis?.status,
|
||||
llmStatus: version.llmAnalysis?.status,
|
||||
sourceVersionId: version._id,
|
||||
});
|
||||
return patch.moderationVerdict === "malicious";
|
||||
}
|
||||
|
||||
function compareSkillVersionsForRestore(
|
||||
left: Pick<Doc<"skillVersions">, "version" | "createdAt">,
|
||||
right: Pick<Doc<"skillVersions">, "version" | "createdAt">,
|
||||
) {
|
||||
const leftValid = semver.valid(left.version);
|
||||
const rightValid = semver.valid(right.version);
|
||||
if (leftValid && rightValid) return semver.rcompare(leftValid, rightValid);
|
||||
if (leftValid) return -1;
|
||||
if (rightValid) return 1;
|
||||
return right.createdAt - left.createdAt;
|
||||
}
|
||||
|
||||
async function findReplacementLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
quarantinedVersionId: Id<"skillVersions">,
|
||||
) {
|
||||
const versions = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
|
||||
.collect();
|
||||
return (
|
||||
versions
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate._id !== quarantinedVersionId &&
|
||||
!candidate.softDeletedAt &&
|
||||
!isKnownMaliciousSkillVersion(candidate),
|
||||
)
|
||||
.sort(compareSkillVersionsForRestore)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function clearSkillEmbeddingsLatestVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
now: number,
|
||||
) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId);
|
||||
for (const embedding of embeddings) {
|
||||
if (
|
||||
!embedding.isLatest &&
|
||||
embedding.visibility === embeddingVisibilityFor(false, embedding.isApproved)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest: false,
|
||||
visibility: embeddingVisibilityFor(false, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantineMaliciousLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash">,
|
||||
version: Pick<Doc<"skillVersions">, "_id">,
|
||||
owner: Doc<"users"> | null | undefined,
|
||||
now: number,
|
||||
maliciousPatch: SkillModerationPatch,
|
||||
) {
|
||||
await ctx.db.patch(version._id, { softDeletedAt: now });
|
||||
|
||||
const replacement = await findReplacementLatestSkillVersion(ctx, skill._id, version._id);
|
||||
const nextTags: Record<string, Id<"skillVersions">> = {};
|
||||
for (const [tag, versionId] of Object.entries(skill.tags ?? {})) {
|
||||
if (versionId === version._id || tag === "latest") continue;
|
||||
nextTags[tag] = versionId;
|
||||
}
|
||||
if (replacement) {
|
||||
nextTags.latest = replacement._id;
|
||||
}
|
||||
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
displayName: replacement
|
||||
? (skillDisplayNameFromSkillVersion(replacement) ?? skill.slug)
|
||||
: skill.displayName,
|
||||
summary: replacement ? skillSummaryFromSkillVersion(replacement) : skill.summary,
|
||||
icon: replacement ? (skillIconFromSkillVersion(replacement) ?? skill.icon) : skill.icon,
|
||||
latestVersionId: replacement?._id,
|
||||
latestVersionSummary: replacement
|
||||
? latestVersionSummaryFromSkillVersion(replacement)
|
||||
: undefined,
|
||||
tags: nextTags,
|
||||
capabilityTags: replacement?.capabilityTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (!shouldPreserveExistingModerationLock(skill)) {
|
||||
const basePatch = replacement
|
||||
? buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: replacement,
|
||||
now,
|
||||
})
|
||||
: maliciousPatch;
|
||||
Object.assign(
|
||||
patch,
|
||||
applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch,
|
||||
now,
|
||||
stripUpdatedAt: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (replacement) {
|
||||
await setSkillEmbeddingsLatestVersion(ctx, skill._id, replacement._id, now);
|
||||
} else {
|
||||
await clearSkillEmbeddingsLatestVersion(ctx, skill._id, now);
|
||||
}
|
||||
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
|
||||
}
|
||||
|
||||
async function quarantineMaliciousNonLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
versionId: Id<"skillVersions">,
|
||||
now: number,
|
||||
) {
|
||||
await ctx.db.patch(versionId, { softDeletedAt: now });
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(skill.tags ?? {}).filter(([, taggedVersionId]) => taggedVersionId !== versionId),
|
||||
) as Record<string, Id<"skillVersions">>;
|
||||
if (Object.keys(nextTags).length === Object.keys(skill.tags ?? {}).length) return;
|
||||
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
tags: nextTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
|
||||
}
|
||||
|
||||
async function scheduleClawScanMaliciousArtifactFinding(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash" | "version"> &
|
||||
Partial<Pick<Doc<"skillVersions">, "createdBy">>,
|
||||
patch: SkillModerationPatch,
|
||||
) {
|
||||
if (
|
||||
@@ -533,9 +732,11 @@ async function scheduleClawScanAutobanForMalware(
|
||||
skill.ownerUserId &&
|
||||
isClawScanMaliciousAnalysis(version.llmAnalysis)
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.autobanMalwareAuthorInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
|
||||
ownerUserId: version.createdBy ?? skill.ownerUserId,
|
||||
artifactKind: "skill",
|
||||
artifactName: skill.slug,
|
||||
version: version.version,
|
||||
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
|
||||
trigger:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.llm_")) ??
|
||||
@@ -8042,7 +8243,30 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
|
||||
if (args.moderationMode === "preserve") return;
|
||||
|
||||
const skill = await ctx.db.get(version.skillId);
|
||||
if (!skill || skill.latestVersionId !== version._id) return;
|
||||
if (!skill) return;
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: nextVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch,
|
||||
now,
|
||||
stripUpdatedAt: true,
|
||||
});
|
||||
if (
|
||||
patch.moderationVerdict === "malicious" &&
|
||||
isClawScanMaliciousAnalysis(args.llmAnalysis)
|
||||
) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, nextVersion, patch);
|
||||
await quarantineMaliciousNonLatestSkillVersion(ctx, skill, version._id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await patchStructuredModerationFromVersion(ctx, skill, nextVersion);
|
||||
},
|
||||
});
|
||||
@@ -10683,13 +10907,16 @@ export const insertVersion = internalMutation({
|
||||
}
|
||||
|
||||
if (!skill) throw new Error("Skill creation failed");
|
||||
const versionIcon = args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
|
||||
|
||||
const existingVersion = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (existingVersion) {
|
||||
throw new ConvexError("Version already exists");
|
||||
throw new ConvexError(
|
||||
`Version ${args.version} already exists. Increment the version number and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
@@ -10699,6 +10926,7 @@ export const insertVersion = internalMutation({
|
||||
sourceProvenance: args.sourceProvenance,
|
||||
changelog: args.changelog,
|
||||
changelogSource: args.changelogSource,
|
||||
icon: versionIcon,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
capabilityTags: args.capabilityTags,
|
||||
@@ -10757,8 +10985,7 @@ export const insertVersion = internalMutation({
|
||||
// displayName / summary so backport publishes can't surprise the card.
|
||||
// Only update when the publisher explicitly picked one this time —
|
||||
// omitting `args.icon` keeps the previously stored value.
|
||||
const nextIcon =
|
||||
isNewLatest && args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
|
||||
const nextIcon = isNewLatest ? versionIcon : skill.icon;
|
||||
const derivedFlags = deriveModerationFlags({
|
||||
skill: {
|
||||
slug: skill.slug,
|
||||
|
||||
+451
-6
@@ -23,6 +23,8 @@ const {
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
autobanMalwareAuthorInternal,
|
||||
recordMaliciousArtifactFindingInternal,
|
||||
unbanUserForBanAppealServiceInternal,
|
||||
reclassifyBanInternal,
|
||||
me,
|
||||
@@ -57,7 +59,7 @@ const purgeSelfDeletedAccountRecoveryBatchInternalHandler = (
|
||||
)._handler;
|
||||
const upsertDevPersonaInternalHandler = (
|
||||
upsertDevPersonaInternal as unknown as WrappedHandler<
|
||||
{ persona: "owner" | "user" | "admin" | "officialOrgMember" },
|
||||
{ persona: "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher" },
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
@@ -150,6 +152,7 @@ function makeDevPersonaCtx() {
|
||||
const publishers = new Map<string, Record<string, unknown>>();
|
||||
const publisherMembers: Array<Record<string, unknown>> = [];
|
||||
const officialPublishers: Array<Record<string, unknown>> = [];
|
||||
const auditLogs: Array<Record<string, unknown>> = [];
|
||||
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
||||
const patches: Array<{ id: string; value: Record<string, unknown> }> = [];
|
||||
|
||||
@@ -290,6 +293,30 @@ function makeDevPersonaCtx() {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "auditLogs") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
||||
if (name !== "by_target") throw new Error(`Unexpected auditLogs index ${name}`);
|
||||
let targetType = "";
|
||||
let targetId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "targetType") targetType = value;
|
||||
if (field === "targetId") targetId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
collect: vi.fn(async () =>
|
||||
auditLogs.filter(
|
||||
(entry) => entry.targetType === targetType && entry.targetId === targetId,
|
||||
),
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string) => {
|
||||
@@ -310,8 +337,10 @@ function makeDevPersonaCtx() {
|
||||
|
||||
return {
|
||||
ctx: { db: { patch, get, insert, query, normalizeId: vi.fn() } } as never,
|
||||
auditLogs,
|
||||
inserts,
|
||||
patches,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -384,10 +413,14 @@ function makeListCtx(
|
||||
|
||||
function makeBanCtx(options: { auditLogs?: Array<Record<string, unknown>> } = {}) {
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const auditLogs = options.auditLogs ?? [];
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "auditLogs") auditLogs.unshift(value);
|
||||
return `${table}:inserted`;
|
||||
});
|
||||
const get = vi.fn();
|
||||
const runMutation = vi.fn();
|
||||
const auditLogs = options.auditLogs ?? [];
|
||||
const runAfter = vi.fn();
|
||||
const apiTokens = [{ _id: "apiTokens:1", revokedAt: undefined }];
|
||||
const userComments = [
|
||||
{
|
||||
@@ -424,8 +457,12 @@ function makeBanCtx(options: { auditLogs?: Array<Record<string, unknown>> } = {}
|
||||
},
|
||||
}));
|
||||
|
||||
const ctx = { db: { patch, insert, get, query, normalizeId: vi.fn() }, runMutation } as never;
|
||||
return { ctx, patch, insert, get, runMutation };
|
||||
const ctx = {
|
||||
db: { patch, insert, get, query, normalizeId: vi.fn() },
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
} as never;
|
||||
return { ctx, patch, insert, get, runMutation, runAfter };
|
||||
}
|
||||
|
||||
function makeBanAppealContextCtx(options: {
|
||||
@@ -1019,6 +1056,32 @@ describe("ensureHandler", () => {
|
||||
});
|
||||
|
||||
describe("users.upsertDevPersonaInternal", () => {
|
||||
it("rejects a dev persona sign-in when the persona is banned", async () => {
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
||||
process.env.CONVEX_SITE_URL = "http://localhost:3210";
|
||||
const { auditLogs, ctx, patches, users } = makeDevPersonaCtx();
|
||||
users.set("users:banned", {
|
||||
_id: "users:banned",
|
||||
_creationTime: 1,
|
||||
handle: "local-abuse",
|
||||
displayName: "Local Abuse Test Publisher",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
});
|
||||
auditLogs.push({
|
||||
action: "user.autoban.malware",
|
||||
targetType: "user",
|
||||
targetId: "users:banned",
|
||||
});
|
||||
|
||||
await expect(
|
||||
upsertDevPersonaInternalHandler(ctx, {
|
||||
persona: "abusePublisher",
|
||||
}),
|
||||
).rejects.toThrow(/account has been banned/i);
|
||||
expect(patches).toEqual([]);
|
||||
});
|
||||
|
||||
it("seeds a non-platform-admin user who manages an official org", async () => {
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
||||
@@ -2683,6 +2746,54 @@ describe("users.banUserInternal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a public-safe ban notification email when the target has email", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, runMutation, runAfter } = makeBanCtx();
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:actor") return { _id: "users:actor", role: "moderator" };
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
if (id === "souls:1") return { _id: "souls:1", stats: { comments: 3 } };
|
||||
return null;
|
||||
});
|
||||
|
||||
runMutation
|
||||
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
||||
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const handler = (
|
||||
banUserInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: { actorUserId: string; targetUserId: string; reason?: string },
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
actorUserId: "users:actor",
|
||||
targetUserId: "users:target",
|
||||
reason: "rate limit triggered by automated CLI publishing",
|
||||
});
|
||||
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
userId: "users:target",
|
||||
bannedAt: 1_700_000_000_000,
|
||||
to: "target@example.com",
|
||||
handle: "target-user",
|
||||
source: "manual",
|
||||
reason: "rate limit triggered by automated CLI publishing",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-ban of already banned user still cleans lingering comments", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, patch, runMutation } = makeBanCtx();
|
||||
@@ -2737,6 +2848,331 @@ describe("users.banUserInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.autobanMalwareAuthorInternal", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("schedules a malicious skill notification with trigger context", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, runMutation, runAfter } = makeBanCtx();
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
if (id === "souls:1") return { _id: "souls:1", stats: { comments: 3 } };
|
||||
return null;
|
||||
});
|
||||
runMutation
|
||||
.mockResolvedValueOnce({ hiddenCount: 1, scheduled: false })
|
||||
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const handler = (
|
||||
autobanMalwareAuthorInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
ownerUserId: string;
|
||||
sha256hash?: string;
|
||||
slug: string;
|
||||
trigger?: string;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
ownerUserId: "users:target",
|
||||
sha256hash: "abc123",
|
||||
slug: "gingiris-launch",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
userId: "users:target",
|
||||
bannedAt: 1_700_000_000_000,
|
||||
to: "target@example.com",
|
||||
handle: "target-user",
|
||||
source: "autoban",
|
||||
reason: "malicious.llm_malicious",
|
||||
trigger: "malicious.llm_malicious",
|
||||
artifact: { kind: "skill", name: "gingiris-launch" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.recordMaliciousArtifactFindingInternal", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emails artifact-level remediation without banning on the first malicious finding", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, insert, runMutation, runAfter } = makeBanCtx();
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handler = (
|
||||
recordMaliciousArtifactFindingInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
ownerUserId: string;
|
||||
artifactKind: "skill" | "plugin";
|
||||
artifactName: string;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
sha256hash?: string;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const result = await handler(ctx, {
|
||||
ownerUserId: "users:target",
|
||||
artifactKind: "skill",
|
||||
artifactName: "demo-skill",
|
||||
version: "1.0.0",
|
||||
trigger: "malicious.llm_malicious",
|
||||
sha256hash: "abc123",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, escalated: false });
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"auditLogs",
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:target",
|
||||
action: "user.malicious_artifact.finding",
|
||||
targetType: "user",
|
||||
targetId: "users:target",
|
||||
metadata: expect.objectContaining({
|
||||
artifactKind: "skill",
|
||||
artifactName: "demo-skill",
|
||||
version: "1.0.0",
|
||||
trigger: "malicious.llm_malicious",
|
||||
sha256hash: "abc123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
userId: "users:target",
|
||||
findingAt: 1_700_000_000_000,
|
||||
to: "target@example.com",
|
||||
handle: "target-user",
|
||||
artifact: { kind: "skill", name: "demo-skill" },
|
||||
version: "1.0.0",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escalates to account ban on the third malicious attempt for one artifact", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, runMutation, runAfter } = makeBanCtx({
|
||||
auditLogs: [
|
||||
{
|
||||
action: "user.malicious_artifact.finding",
|
||||
targetType: "user",
|
||||
targetId: "users:target",
|
||||
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
||||
createdAt: 1_699_999_000_000,
|
||||
},
|
||||
{
|
||||
action: "user.malicious_artifact.finding",
|
||||
targetType: "user",
|
||||
targetId: "users:target",
|
||||
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
||||
createdAt: 1_699_998_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
|
||||
|
||||
const handler = (
|
||||
recordMaliciousArtifactFindingInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
ownerUserId: string;
|
||||
artifactKind: "skill" | "plugin";
|
||||
artifactName: string;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
sha256hash?: string;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const result = await handler(ctx, {
|
||||
ownerUserId: "users:target",
|
||||
artifactKind: "skill",
|
||||
artifactName: "demo-skill",
|
||||
version: "1.0.2",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, escalated: true, reason: "attempt_threshold" });
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
ownerUserId: "users:target",
|
||||
slug: "demo-skill",
|
||||
trigger: "malicious.llm_malicious",
|
||||
artifactKind: "skill",
|
||||
artifactName: "demo-skill",
|
||||
});
|
||||
expect(runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not escalate on the second malicious attempt for one artifact", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, runMutation } = makeBanCtx({
|
||||
auditLogs: [
|
||||
{
|
||||
action: "user.malicious_artifact.finding",
|
||||
targetType: "user",
|
||||
targetId: "users:target",
|
||||
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
||||
createdAt: 1_699_999_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const handler = (
|
||||
recordMaliciousArtifactFindingInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
ownerUserId: string;
|
||||
artifactKind: "skill" | "plugin";
|
||||
artifactName: string;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
sha256hash?: string;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const result = await handler(ctx, {
|
||||
ownerUserId: "users:target",
|
||||
artifactKind: "skill",
|
||||
artifactName: "demo-skill",
|
||||
version: "1.0.1",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, escalated: false });
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escalates to account ban on the second distinct malicious artifact", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { ctx, get, runMutation, runAfter } = makeBanCtx({
|
||||
auditLogs: [
|
||||
{
|
||||
action: "user.malicious_artifact.finding",
|
||||
targetType: "user",
|
||||
targetId: "users:target",
|
||||
metadata: { artifactKind: "skill", artifactName: "first-skill" },
|
||||
createdAt: 1_699_999_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:target") {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
|
||||
|
||||
const handler = (
|
||||
recordMaliciousArtifactFindingInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
ownerUserId: string;
|
||||
artifactKind: "skill" | "plugin";
|
||||
artifactName: string;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
sha256hash?: string;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const result = await handler(ctx, {
|
||||
ownerUserId: "users:target",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "@scope/second-plugin",
|
||||
version: "1.0.0",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
escalated: true,
|
||||
reason: "distinct_artifact_threshold",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
ownerUserId: "users:target",
|
||||
slug: "@scope/second-plugin",
|
||||
trigger: "malicious.llm_malicious",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "@scope/second-plugin",
|
||||
});
|
||||
expect(runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.unbanUserForBanAppealServiceInternal", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -2744,7 +3180,7 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
|
||||
|
||||
it("restores ban-hidden skills and packages for accepted appeals", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
||||
const { ctx, get, patch, insert, runMutation } = makeBanCtx({
|
||||
const { ctx, get, patch, insert, runMutation, runAfter } = makeBanCtx({
|
||||
auditLogs: [
|
||||
{
|
||||
_id: "auditLogs:ban",
|
||||
@@ -2760,6 +3196,8 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
|
||||
return {
|
||||
_id: "users:target",
|
||||
role: "user",
|
||||
handle: "target-user",
|
||||
email: "target@example.com",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
deactivatedAt: undefined,
|
||||
banReason: "malware auto-ban",
|
||||
@@ -2818,6 +3256,13 @@ describe("users.unbanUserForBanAppealServiceInternal", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
userId: "users:target",
|
||||
restoredAt: 1_700_000_100_000,
|
||||
to: "target@example.com",
|
||||
handle: "target-user",
|
||||
restoredListings: undefined,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
alreadyUnbanned: false,
|
||||
|
||||
+264
-1
@@ -1,4 +1,4 @@
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
@@ -45,6 +45,12 @@ const AUTOBAN_AUDIT_MATCH_WINDOW_MS = 5_000;
|
||||
const BAN_AUDIT_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
const BAN_APPEAL_AUTH_ACCOUNT_MATCH_LIMIT = 20;
|
||||
const AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE = 100;
|
||||
const MALICIOUS_ARTIFACT_FINDING_ACTION = "user.malicious_artifact.finding";
|
||||
const MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD = 2;
|
||||
const MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD = 3;
|
||||
const MALICIOUS_ARTIFACT_AUDIT_LOOKBACK = 100;
|
||||
const DEV_PERSONA_BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
|
||||
const ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT = 25;
|
||||
const ACCOUNT_RECOVERY_PURGE_LIMIT_MAX = 100;
|
||||
const accountRecoveryPurgeModeValidator = v.optional(
|
||||
@@ -92,6 +98,83 @@ type AccountRecoveryPurgeCandidate = {
|
||||
selfDeleteAuditCreatedAt: number | null;
|
||||
};
|
||||
|
||||
type BanEmailTarget = Pick<Doc<"users">, "_id" | "email" | "handle">;
|
||||
type MaliciousArtifactKind = "skill" | "plugin";
|
||||
type MaliciousArtifactFinding = {
|
||||
artifactKind: MaliciousArtifactKind;
|
||||
artifactName: string;
|
||||
};
|
||||
|
||||
async function scheduleBanNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
bannedAt: number;
|
||||
source: "manual" | "autoban";
|
||||
reason?: string;
|
||||
trigger?: string;
|
||||
artifact?: { kind: "skill" | "plugin"; name: string };
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendBanNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
bannedAt: args.bannedAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
source: args.source,
|
||||
reason: args.reason,
|
||||
trigger: args.trigger,
|
||||
artifact: args.artifact,
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleRestoredAccountNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
restoredAt: number;
|
||||
restoredListings?: Array<{ kind: "skill" | "plugin"; name: string }>;
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendRestoredAccountNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
restoredAt: args.restoredAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
restoredListings: args.restoredListings,
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleMaliciousArtifactNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
findingAt: number;
|
||||
artifact: { kind: MaliciousArtifactKind; name: string };
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendMaliciousArtifactNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
findingAt: args.findingAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
artifact: args.artifact,
|
||||
version: args.version,
|
||||
trigger: args.trigger,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAutobanPersonalPublisherId(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
owner: Pick<Doc<"users">, "_id" | "personalPublisherId"> | null | undefined,
|
||||
@@ -293,6 +376,12 @@ const DEV_PERSONAS = {
|
||||
displayName: "Local Official Org Member",
|
||||
role: "user",
|
||||
},
|
||||
abusePublisher: {
|
||||
handle: "local-abuse",
|
||||
displayName: "Local Abuse Test Publisher",
|
||||
email: "local-abuse@example.test",
|
||||
role: "user",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const DEV_OFFICIAL_ORG = {
|
||||
@@ -303,6 +392,14 @@ const DEV_OFFICIAL_ORG = {
|
||||
|
||||
type DevPersona = keyof typeof DEV_PERSONAS;
|
||||
|
||||
async function hasBlockingBanAudit(ctx: Pick<MutationCtx, "db">, userId: Id<"users">) {
|
||||
const banRecords = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", userId.toString()))
|
||||
.collect();
|
||||
return banRecords.some((record) => BAN_AUDIT_ACTIONS.has(record.action));
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)),
|
||||
@@ -320,6 +417,7 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
v.literal("user"),
|
||||
v.literal("admin"),
|
||||
v.literal("officialOrgMember"),
|
||||
v.literal("abusePublisher"),
|
||||
),
|
||||
devAuthSecret: v.optional(v.string()),
|
||||
},
|
||||
@@ -335,6 +433,7 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
handle: persona.handle,
|
||||
displayName: persona.displayName,
|
||||
name: persona.displayName,
|
||||
email: "email" in persona ? persona.email : undefined,
|
||||
role: persona.role,
|
||||
githubCreatedAt: DEV_PERSONA_GITHUB_CREATED_AT,
|
||||
deletedAt: undefined,
|
||||
@@ -343,6 +442,13 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
banReason: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (
|
||||
existing &&
|
||||
(existing.deletedAt || existing.deactivatedAt) &&
|
||||
(await hasBlockingBanAudit(ctx, existing._id))
|
||||
) {
|
||||
throw new ConvexError(DEV_PERSONA_BANNED_REAUTH_MESSAGE);
|
||||
}
|
||||
const userId =
|
||||
existing?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
@@ -2257,6 +2363,13 @@ async function banUserWithActor(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleBanNotificationEmail(ctx, {
|
||||
target,
|
||||
bannedAt: now,
|
||||
source: "manual",
|
||||
reason,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyBanned: false,
|
||||
@@ -2335,6 +2448,11 @@ async function unbanUserForBanAppealService(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleRestoredAccountNotificationEmail(ctx, {
|
||||
target,
|
||||
restoredAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyUnbanned: false,
|
||||
@@ -2414,6 +2532,11 @@ async function unbanUserWithActor(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleRestoredAccountNotificationEmail(ctx, {
|
||||
target,
|
||||
restoredAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyUnbanned: false,
|
||||
@@ -2705,6 +2828,132 @@ export const ensurePublisherHandleInternal = internalMutation({
|
||||
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
function normalizeMaliciousArtifactName(name: string) {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function readMaliciousArtifactFindingFromAudit(
|
||||
log: Doc<"auditLogs">,
|
||||
): MaliciousArtifactFinding | null {
|
||||
if (log.action !== MALICIOUS_ARTIFACT_FINDING_ACTION) return null;
|
||||
const metadata = log.metadata as
|
||||
| {
|
||||
artifactKind?: unknown;
|
||||
artifactName?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const artifactKind = metadata?.artifactKind;
|
||||
const artifactName = typeof metadata?.artifactName === "string" ? metadata.artifactName : "";
|
||||
if ((artifactKind !== "skill" && artifactKind !== "plugin") || !artifactName.trim()) {
|
||||
return null;
|
||||
}
|
||||
return { artifactKind, artifactName };
|
||||
}
|
||||
|
||||
function getMaliciousArtifactEscalationReason(findings: MaliciousArtifactFinding[]) {
|
||||
const distinctArtifacts = new Set<string>();
|
||||
const attemptsByArtifact = new Map<string, number>();
|
||||
|
||||
for (const finding of findings) {
|
||||
const artifactKey = `${finding.artifactKind}:${normalizeMaliciousArtifactName(
|
||||
finding.artifactName,
|
||||
)}`;
|
||||
distinctArtifacts.add(artifactKey);
|
||||
attemptsByArtifact.set(artifactKey, (attemptsByArtifact.get(artifactKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (distinctArtifacts.size >= MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD) {
|
||||
return "distinct_artifact_threshold" as const;
|
||||
}
|
||||
for (const attempts of attemptsByArtifact.values()) {
|
||||
if (attempts >= MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD) {
|
||||
return "attempt_threshold" as const;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const recordMaliciousArtifactFindingInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
artifactKind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
artifactName: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
sha256hash: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const target = await ctx.db.get(args.ownerUserId);
|
||||
if (!target) return { ok: false as const, reason: "user_not_found" as const };
|
||||
if (target.deletedAt || target.deactivatedAt) return { ok: true as const, alreadyBanned: true };
|
||||
|
||||
const artifactName = args.artifactName.trim();
|
||||
if (!artifactName) {
|
||||
return { ok: false as const, reason: "missing_artifact" as const };
|
||||
}
|
||||
const now = Date.now();
|
||||
const trigger = args.trigger?.trim() || "scanner.malicious";
|
||||
const version = args.version?.trim() || undefined;
|
||||
const sha256hash = args.sha256hash?.trim() || undefined;
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.ownerUserId,
|
||||
action: MALICIOUS_ARTIFACT_FINDING_ACTION,
|
||||
targetType: "user",
|
||||
targetId: args.ownerUserId,
|
||||
metadata: {
|
||||
artifactKind: args.artifactKind,
|
||||
artifactName,
|
||||
version,
|
||||
trigger,
|
||||
sha256hash,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (target.role === "admin" || target.role === "moderator") {
|
||||
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
||||
target,
|
||||
findingAt: now,
|
||||
artifact: { kind: args.artifactKind, name: artifactName },
|
||||
version,
|
||||
trigger,
|
||||
});
|
||||
return { ok: true as const, escalated: false as const, reason: "protected_role" as const };
|
||||
}
|
||||
|
||||
const auditLogs = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", args.ownerUserId))
|
||||
.order("desc")
|
||||
.take(MALICIOUS_ARTIFACT_AUDIT_LOOKBACK);
|
||||
const priorFindings = auditLogs
|
||||
.map(readMaliciousArtifactFindingFromAudit)
|
||||
.filter((finding): finding is MaliciousArtifactFinding => Boolean(finding));
|
||||
const escalationReason = getMaliciousArtifactEscalationReason(priorFindings);
|
||||
if (!escalationReason) {
|
||||
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
||||
target,
|
||||
findingAt: now,
|
||||
artifact: { kind: args.artifactKind, name: artifactName },
|
||||
version,
|
||||
trigger,
|
||||
});
|
||||
return { ok: true as const, escalated: false as const };
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.users.autobanMalwareAuthorInternal, {
|
||||
ownerUserId: args.ownerUserId,
|
||||
slug: artifactName,
|
||||
trigger,
|
||||
...(sha256hash ? { sha256hash } : {}),
|
||||
artifactKind: args.artifactKind,
|
||||
artifactName,
|
||||
});
|
||||
|
||||
return { ok: true as const, escalated: true as const, reason: escalationReason };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by a scanner.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
@@ -2715,6 +2964,8 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
sha256hash: v.optional(v.string()),
|
||||
slug: v.string(),
|
||||
trigger: v.optional(v.string()),
|
||||
artifactKind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
|
||||
artifactName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const target = await ctx.db.get(args.ownerUserId);
|
||||
@@ -2807,6 +3058,18 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const trigger = args.trigger?.trim() || "scanner.malicious";
|
||||
const artifactKind = args.artifactKind ?? "skill";
|
||||
const artifactName = args.artifactName?.trim() || args.slug;
|
||||
await scheduleBanNotificationEmail(ctx, {
|
||||
target,
|
||||
bannedAt: now,
|
||||
source: "autoban",
|
||||
reason: trigger,
|
||||
trigger,
|
||||
artifact: { kind: artifactKind, name: artifactName },
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
||||
);
|
||||
|
||||
+6
-3
@@ -17,8 +17,9 @@ Use GitHub to sign in at [clawhub.ai](https://clawhub.ai).
|
||||
|
||||
Deleted, banned, or disabled accounts cannot complete normal ClawHub sign-in.
|
||||
If sign-in returns you to a logged-out state, your account may not be in good
|
||||
standing. [Open a GitHub issue](https://github.com/openclaw/clawhub/issues/new)
|
||||
if you believe this is a mistake.
|
||||
standing. If your account was banned or disabled, use the
|
||||
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
|
||||
mistake.
|
||||
|
||||
## CLI login
|
||||
|
||||
@@ -87,4 +88,6 @@ Revoked, invalid, or missing tokens return `401 Unauthorized`. Sign in again
|
||||
with `clawhub login` or provide a fresh token with `clawhub login --token`.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot continue using existing API tokens.
|
||||
If you believe this is a mistake, [open a GitHub issue](https://github.com/openclaw/clawhub/issues/new).
|
||||
If your account was banned or disabled, use the
|
||||
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
|
||||
mistake.
|
||||
|
||||
+16
-4
@@ -189,25 +189,37 @@ Stores your API token + cached registry URL.
|
||||
clawhub skill publish ./my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
### `scan [path]`
|
||||
### `scan --slug <slug>`
|
||||
|
||||
- Requires `clawhub login`.
|
||||
- Runs ClawHub ClawScan through `POST /api/v1/skills/-/scan`, then polls until the scan is terminal.
|
||||
- Scans are asynchronous and may take time to complete. While queued, the terminal spinner shows the current prioritized scan position and how many scans are ahead.
|
||||
- Local path scans are always ephemeral. They upload the local skill bundle for scanning, print the security report, and never create or update a published skill/version.
|
||||
- Published scans require ownership or publisher management access. Moderators/admins can use the same backend through `clawhub-mod`.
|
||||
- `--update` is valid only with `--slug`; it writes successful published scan results back to the selected version.
|
||||
- `--output <file.zip>` downloads the full report archive with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
|
||||
- `--json` prints the full poll response for automation.
|
||||
- Local path scans are no longer supported. Upload a new version, then use `scan download` to retrieve the stored scan results for that submitted version.
|
||||
|
||||
```bash
|
||||
clawhub scan ./my-skill
|
||||
clawhub scan ./my-skill --output report.zip
|
||||
clawhub scan --slug gifgrep
|
||||
clawhub scan --slug gifgrep --version 1.2.3
|
||||
clawhub scan --slug gifgrep --update --output report.zip
|
||||
```
|
||||
|
||||
### `scan download <name>`
|
||||
|
||||
- Requires `clawhub login`.
|
||||
- Downloads the stored scan report ZIP for a submitted skill or plugin version, including versions that were blocked or hidden by ClawHub security checks.
|
||||
- Skill downloads use the skill slug and default to `--kind skill`.
|
||||
- Plugin downloads use the package name and require `--kind plugin`.
|
||||
- `--version` is required so authors inspect the exact submitted version that ClawHub blocked.
|
||||
- `--output <file.zip>` chooses the destination path.
|
||||
|
||||
```bash
|
||||
clawhub scan download gifgrep --version 1.2.3
|
||||
clawhub scan download @scope/demo --version 2.0.0 --kind plugin --output report.zip
|
||||
```
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub ships an official reusable workflow at
|
||||
|
||||
+12
-7
@@ -373,10 +373,8 @@ Notes:
|
||||
|
||||
Authenticated submit endpoint for new ClawScan jobs.
|
||||
|
||||
Local upload scans use `multipart/form-data`:
|
||||
|
||||
- `payload`: JSON string, usually `{ "source": { "kind": "upload" }, "update": false }`
|
||||
- `files`: repeated local skill files
|
||||
Local upload scans are no longer supported. Requests using
|
||||
`multipart/form-data` or `{ "source": { "kind": "upload" } }` return `410`.
|
||||
|
||||
Published scans use JSON:
|
||||
|
||||
@@ -389,12 +387,10 @@ Published scans use JSON:
|
||||
|
||||
Notes:
|
||||
|
||||
- Local upload scans require auth but are ephemeral. They never mutate public skill, version, moderation, or trust state.
|
||||
- Scan request payloads and downloadable reports expire from the scan-request store after the retention window.
|
||||
- Local upload scans reject `update: true`.
|
||||
- Published scans require owner/publisher management access, or platform moderator/admin authority.
|
||||
- Published scans write back only when `update: true` and the scan completes successfully.
|
||||
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "upload|published", "update": false, "queue": { "queuedAhead": 0, "queuedAheadIsEstimate": false, "position": 1, "running": 0, "runningIsEstimate": false, "note": "Scans are asynchronous and may take time to complete." } }`.
|
||||
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "published", "update": false, "queue": { "queuedAhead": 0, "queuedAheadIsEstimate": false, "position": 1, "running": 0, "runningIsEstimate": false, "note": "Scans are asynchronous and may take time to complete." } }`.
|
||||
- Scan jobs are asynchronous. Manual scan requests are prioritized ahead of normal publish/backfill work, but completion still depends on worker availability.
|
||||
|
||||
### `GET /api/v1/skills/-/scan/{scanId}`
|
||||
@@ -413,6 +409,15 @@ Authenticated report archive endpoint.
|
||||
- Requires a succeeded scan; non-terminal scans return `409`.
|
||||
- Returns a ZIP with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
|
||||
|
||||
### `GET /api/v1/skills/-/scan/download/{name}?version=<version>&kind=skill|plugin`
|
||||
|
||||
Authenticated stored report archive endpoint for submitted versions.
|
||||
|
||||
- Requires owner/publisher management access to the skill or plugin, or platform moderator/admin authority.
|
||||
- Returns stored scan results for the exact submitted version, including blocked or hidden versions.
|
||||
- `kind` defaults to `skill`; use `kind=plugin` for plugin/package scans.
|
||||
- Returns the same ZIP shape as scan-request downloads.
|
||||
|
||||
### `POST /api/v1/skills/-/scan/batch`
|
||||
|
||||
Admin-only canonical batch rescan route. It accepts the same payload shape as legacy `POST /api/v1/skills/-/rescan-batch`.
|
||||
|
||||
+8
-3
@@ -79,9 +79,14 @@ result in account bans, token revocation, hidden content, or removed listings.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot use ClawHub API tokens. If CLI auth
|
||||
starts failing after account action, sign in to the web UI to review account
|
||||
state. If sign-in or normal CLI access is blocked,
|
||||
[open a GitHub issue](https://github.com/openclaw/clawhub/issues/new) for
|
||||
recovery review.
|
||||
state. If sign-in or normal CLI access is blocked by a ban or disabled account,
|
||||
use the [ClawHub appeal form](https://appeals.openclaw.ai/) for recovery review.
|
||||
|
||||
If a scanner-triggered email names a skill or plugin version as malicious,
|
||||
download the stored scan results for the blocked submitted version:
|
||||
`clawhub scan download <slug> --version <version>`. For plugins, add
|
||||
`--kind plugin`. Review the scan output, fix the listing, increment the version
|
||||
number, and upload the fixed version.
|
||||
|
||||
## Publisher guidance
|
||||
|
||||
|
||||
+130
-1
@@ -132,13 +132,142 @@ describe("clawhub e2e", () => {
|
||||
);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/API response:/);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cli scan rejects local folders before submitting a scan", async () => {
|
||||
let requestCount = 0;
|
||||
const server = createServer(async (_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-"));
|
||||
try {
|
||||
const skillDir = join(workdir, "my-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Local Skill\n", "utf8");
|
||||
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"scan",
|
||||
"./my-skill",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(requestCount).toBe(0);
|
||||
expect(result.stderr).toContain("Local folder scans are no longer supported");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cli scan download fetches a stored submitted-version scan report", async () => {
|
||||
const reportZip = zipSync({
|
||||
"manifest.json": strToU8(
|
||||
`${JSON.stringify({
|
||||
scanId: "skill:demo-skill:1.2.3",
|
||||
sourceKind: "published",
|
||||
status: "succeeded",
|
||||
})}\n`,
|
||||
),
|
||||
"clawscan.json": strToU8(`${JSON.stringify({ status: "malicious" })}\n`),
|
||||
});
|
||||
let requestedAuthorization = "";
|
||||
let requestedPath = "";
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestedPath = `${url.pathname}${url.search}`;
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
url.pathname === `${ApiRoutes.skillScans}/download/demo-skill` &&
|
||||
url.searchParams.get("version") === "1.2.3" &&
|
||||
url.searchParams.get("kind") === "skill"
|
||||
) {
|
||||
requestedAuthorization = req.headers.authorization ?? "";
|
||||
res.writeHead(200, { "Content-Type": "application/zip" });
|
||||
res.end(Buffer.from(reportZip));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-download-"));
|
||||
try {
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"scan",
|
||||
"download",
|
||||
"demo-skill",
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--output",
|
||||
"report.zip",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(requestedAuthorization).toBe("Bearer test-token");
|
||||
expect(requestedPath).toBe(
|
||||
`${ApiRoutes.skillScans}/download/demo-skill?version=1.2.3&kind=skill`,
|
||||
);
|
||||
expect(result.stdout).toContain("Report ZIP:");
|
||||
const downloaded = await readFile(join(workdir, "report.zip"));
|
||||
expect(unzipSync(downloaded)).toHaveProperty("manifest.json");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("assumes a logged-in user (whoami succeeds)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectHealthyPage,
|
||||
expectNoFatalErrorUi,
|
||||
trackRuntimeErrors,
|
||||
waitForHydration,
|
||||
@@ -91,6 +92,8 @@ type AccountDeletionFixtureState = {
|
||||
skillActive: boolean;
|
||||
skillSoftDeletedAt: number | null;
|
||||
packageExists: boolean;
|
||||
skillPubliclyVisible: boolean;
|
||||
packagePubliclyVisible: boolean;
|
||||
packageActive: boolean;
|
||||
packageSoftDeletedAt: number | null;
|
||||
authAccountCount: number;
|
||||
@@ -216,6 +219,8 @@ test("users can permanently delete their account and personal publisher resource
|
||||
deletedAt: null,
|
||||
},
|
||||
publisherExists: false,
|
||||
skillPubliclyVisible: false,
|
||||
packagePubliclyVisible: false,
|
||||
skillActive: false,
|
||||
packageActive: false,
|
||||
authAccountCount: 0,
|
||||
@@ -227,6 +232,8 @@ test("users can permanently delete their account and personal publisher resource
|
||||
expect(finalState.user.deactivatedAt).toEqual(expect.any(Number));
|
||||
expect(finalState.user.purgedAt).toEqual(expect.any(Number));
|
||||
}
|
||||
await expectHealthyPage(page, errors);
|
||||
errors.length = 0;
|
||||
|
||||
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { waitForHydration } from "../helpers/runtimeErrors";
|
||||
|
||||
type DevPersona = "owner" | "user" | "admin";
|
||||
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
|
||||
|
||||
// The quality gate fingerprints line shape, so vary local-auth fixtures by slug.
|
||||
const FINGERPRINT_SALT_LINES = [
|
||||
@@ -38,12 +38,27 @@ function fingerprintSaltBlock(args: { slug: string; versionLabel: string }) {
|
||||
|
||||
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
|
||||
const displayName =
|
||||
persona === "owner" ? "Local Owner" : persona === "user" ? "Local User" : "Local Admin";
|
||||
persona === "owner"
|
||||
? "Local Owner"
|
||||
: persona === "user"
|
||||
? "Local User"
|
||||
: persona === "abusePublisher"
|
||||
? "Local Abuse Test Publisher"
|
||||
: "Local Admin";
|
||||
const displayNamePattern =
|
||||
persona === "abusePublisher"
|
||||
? `${escapeRegExp("Local Abuse Test Publishe")}.*`
|
||||
: escapeRegExp(displayName);
|
||||
const exactHandle =
|
||||
persona === "owner"
|
||||
? `${escapeRegExp(expectedHandle)}(?![-\\w])`
|
||||
: escapeRegExp(expectedHandle);
|
||||
return new RegExp(`@(?:${exactHandle}|${escapeRegExp(displayName)})`, "i");
|
||||
return new RegExp(`@(?:${exactHandle}|${displayNamePattern})`, "i");
|
||||
}
|
||||
|
||||
function devPersonaMenuLabel(persona: DevPersona) {
|
||||
if (persona === "abusePublisher") return "abuse publisher";
|
||||
return persona;
|
||||
}
|
||||
|
||||
export function skillMd(args: { slug: string; displayName: string; versionLabel: string }) {
|
||||
@@ -80,7 +95,12 @@ export function escapeRegExp(value: string) {
|
||||
}
|
||||
|
||||
export async function expectLocalPersonaActive(page: Page, persona: DevPersona) {
|
||||
const expectedHandle = persona === "owner" ? "local" : `local-${persona}`;
|
||||
const expectedHandle =
|
||||
persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
await expect(page.locator("header .user-trigger")).toContainText(
|
||||
devPersonaHeaderPattern(persona, expectedHandle),
|
||||
{ timeout: 15_000 },
|
||||
@@ -92,7 +112,9 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByRole("button", { name: "Open local dev personas" }).click();
|
||||
await page.getByRole("menuitem", { name: new RegExp(`use ${persona}`, "i") }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: new RegExp(`use ${devPersonaMenuLabel(persona)}`, "i") })
|
||||
.click();
|
||||
try {
|
||||
await expectLocalPersonaActive(page, persona);
|
||||
} catch {
|
||||
@@ -101,7 +123,11 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
|
||||
await expectLocalPersonaActive(page, persona);
|
||||
}
|
||||
|
||||
return persona === "owner" ? "local" : `local-${persona}`;
|
||||
return persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
}
|
||||
|
||||
export async function signInAsLocalOwner(page: Page) {
|
||||
@@ -140,7 +166,7 @@ export async function selectOwnerHandle(page: Page, selector: string, ownerHandl
|
||||
await ownerControl.click();
|
||||
await page
|
||||
.getByRole("option", {
|
||||
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\b|\\s|·)`, "i"),
|
||||
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\s|·|$)`, "i"),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import convexBrowser from "convex/browser";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
|
||||
import { publishSkillVersion, signInAsLocalPublisher } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"malicious skill ban flow requires the local dev auth runner",
|
||||
);
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
|
||||
const { ConvexHttpClient } = convexBrowser;
|
||||
type ConvexHttpClientInstance = InstanceType<typeof ConvexHttpClient>;
|
||||
|
||||
type ClaimedScanJob = {
|
||||
job: { _id: Id<"securityScanJobs">; leaseToken: string };
|
||||
target?: { skill?: { slug?: string }; version?: { version?: string } };
|
||||
};
|
||||
|
||||
type CapturedEmail = {
|
||||
idempotencyKey: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
capturedAt: number;
|
||||
};
|
||||
|
||||
function convexClient() {
|
||||
const convexUrl = process.env.VITE_CONVEX_URL;
|
||||
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
|
||||
return new ConvexHttpClient(convexUrl);
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function readCapturedEmails() {
|
||||
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE;
|
||||
if (!captureFile) throw new Error("CLAWHUB_EMAIL_CAPTURE_FILE is required");
|
||||
if (!existsSync(captureFile)) return [];
|
||||
const raw = await readFile(captureFile, "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as CapturedEmail);
|
||||
}
|
||||
|
||||
async function waitForCapturedEmails(predicate: (emails: CapturedEmail[]) => boolean) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let latest: CapturedEmail[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
latest = await readCapturedEmails();
|
||||
if (predicate(latest)) return latest;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for captured emails. Saw: ${latest
|
||||
.map((email) => email.subject)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForClaimedScanJob(
|
||||
client: ConvexHttpClientInstance,
|
||||
slug: string,
|
||||
version: string,
|
||||
) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
|
||||
token: WORKER_TOKEN,
|
||||
workerId: `pw-malicious-skill-${slug}-${version}`,
|
||||
limit: 20,
|
||||
leaseMs: 60_000,
|
||||
})) as ClaimedScanJob[];
|
||||
const match = jobs.find(
|
||||
(job) => job.target?.skill?.slug === slug && job.target?.version?.version === version,
|
||||
);
|
||||
if (match) return match;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`Timed out waiting for security scan job for ${slug}@${version}`);
|
||||
}
|
||||
|
||||
async function completeScan(
|
||||
client: ConvexHttpClientInstance,
|
||||
args: { slug: string; version: string; verdict: "benign" | "malicious" },
|
||||
) {
|
||||
const scanJob = await waitForClaimedScanJob(client, args.slug, args.version);
|
||||
const malicious = args.verdict === "malicious";
|
||||
await client.action(api.securityScan.completeCodexScanJob, {
|
||||
token: WORKER_TOKEN,
|
||||
jobId: scanJob.job._id,
|
||||
leaseToken: scanJob.job.leaseToken,
|
||||
runId: "playwright-local-auth",
|
||||
llmAnalysis: {
|
||||
status: malicious ? "malicious" : "clean",
|
||||
verdict: args.verdict,
|
||||
confidence: "high",
|
||||
summary: malicious
|
||||
? "Synthetic local e2e malicious verdict."
|
||||
: "Synthetic local e2e clean verdict.",
|
||||
guidance: malicious
|
||||
? "Synthetic local e2e blocked upload."
|
||||
: "Synthetic local e2e clean upload.",
|
||||
model: "mock-local-e2e",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCurrentVersion(page: import("@playwright/test").Page, version: string) {
|
||||
const metadata = page.locator(".sidebar-metadata");
|
||||
await expect(metadata.getByText("Current version", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(metadata.getByText(`v${version}`, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
|
||||
return errors.filter(
|
||||
(error) => !(error.includes("CONVEX M(users:ensure)") && error.includes("User not found")),
|
||||
);
|
||||
}
|
||||
|
||||
test("malicious skill retries keep the clean latest visible, email the publisher, and ban on third rejection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const client = convexClient();
|
||||
const slug = `pw-malware-${Date.now().toString(36)}`;
|
||||
const displayName = "Playwright Malicious Skill Flow";
|
||||
|
||||
const ownerHandle = await signInAsLocalPublisher(page, "abusePublisher");
|
||||
await publishSkillVersion(page, testInfo, {
|
||||
ownerHandle,
|
||||
slug,
|
||||
displayName,
|
||||
version: "1.0.0",
|
||||
versionLabel: "clean baseline release",
|
||||
changelog: "Clean baseline release before malicious retry validation.",
|
||||
});
|
||||
await completeScan(client, { slug, version: "1.0.0", verdict: "benign" });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expectCurrentVersion(page, "1.0.0");
|
||||
|
||||
const maliciousVersions = ["1.0.1", "1.0.2", "1.0.3"] as const;
|
||||
const finalMaliciousVersion = maliciousVersions[maliciousVersions.length - 1];
|
||||
for (const version of maliciousVersions) {
|
||||
await page.getByRole("link", { name: "New version" }).click();
|
||||
await expect(page).toHaveURL(/\/skills\/publish\?updateSlug=/);
|
||||
await publishSkillVersion(page, testInfo, {
|
||||
ownerHandle,
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
versionLabel: `malicious retry ${version}`,
|
||||
changelog: `Synthetic malicious retry ${version}.`,
|
||||
});
|
||||
await completeScan(client, { slug, version, verdict: "malicious" });
|
||||
if (version === finalMaliciousVersion) {
|
||||
await waitForCapturedEmails((emails) =>
|
||||
emails.some((email) => email.subject === "Your ClawHub account was disabled"),
|
||||
);
|
||||
} else {
|
||||
await waitForCapturedEmails(
|
||||
(emails) =>
|
||||
emails.filter(
|
||||
(email) =>
|
||||
email.subject === "ClawHub blocked a skill version" &&
|
||||
email.text.includes(`Version: ${version}`) &&
|
||||
email.text.includes(`clawhub scan download ${slug} --version ${version}`),
|
||||
).length === 1,
|
||||
);
|
||||
}
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
if (version !== finalMaliciousVersion) {
|
||||
await expectCurrentVersion(page, "1.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
const emails = await waitForCapturedEmails(
|
||||
(captured) =>
|
||||
captured.filter((email) => email.subject === "ClawHub blocked a skill version").length ===
|
||||
2 && captured.some((email) => email.subject === "Your ClawHub account was disabled"),
|
||||
);
|
||||
const artifactEmails = emails.filter(
|
||||
(email) => email.subject === "ClawHub blocked a skill version",
|
||||
);
|
||||
expect(artifactEmails).toHaveLength(2);
|
||||
for (const email of artifactEmails) {
|
||||
expect(email.text).toContain("Your account can still sign in.");
|
||||
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.text).not.toContain("appeals.openclaw.ai");
|
||||
}
|
||||
|
||||
const accountBanEmail = emails.find(
|
||||
(email) => email.subject === "Your ClawHub account was disabled",
|
||||
);
|
||||
expect(accountBanEmail?.text).toContain("Appeal: https://appeals.openclaw.ai/");
|
||||
expect(accountBanEmail?.text).not.toContain("clawhub scan download");
|
||||
|
||||
await page.getByRole("button", { name: "Open local dev personas" }).click();
|
||||
await page.getByRole("menuitem", { name: /sign out/i }).click();
|
||||
await page.goto("/dashboard?error_description=This%20account%20has%20been%20banned", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveURL(/\/account-banned$/, { timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Your ClawHub account has been banned" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Open an appeal" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://appeals.openclaw.ai/",
|
||||
);
|
||||
|
||||
await expectHealthyPage(page, withoutExpectedBannedSessionTeardownErrors(errors));
|
||||
});
|
||||
+2
-1
@@ -12,7 +12,7 @@
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
|
||||
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
|
||||
@@ -109,6 +109,7 @@
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"resend": "6.12.4",
|
||||
"semver": "7.8.2",
|
||||
"shiki": "4.2.0",
|
||||
"sonner": "2.0.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.19.2",
|
||||
"version": "0.20.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
import { cmdCreatePublisher } from "./cli/commands/publishers.js";
|
||||
import { cmdScan } from "./cli/commands/scan.js";
|
||||
import { cmdScan, cmdScanDownload } from "./cli/commands/scan.js";
|
||||
import {
|
||||
cmdExplore,
|
||||
cmdInstall,
|
||||
@@ -374,9 +374,9 @@ registerCommand(program, ["publish"])
|
||||
await cmdPublish(opts, folder, options);
|
||||
});
|
||||
|
||||
registerCommand(program, ["scan"])
|
||||
.description("Run ClawScan on a local skill bundle or one of your published skills")
|
||||
.argument("[path]", "Local skill folder path")
|
||||
const scanCmd = registerCommand(program, ["scan"])
|
||||
.description("Run or download ClawHub scan reports")
|
||||
.argument("[path]", "Deprecated local skill folder path")
|
||||
.option("--slug <slug>", "Published skill slug to scan")
|
||||
.option("--version <version>", "Published skill version to scan")
|
||||
.option("--update", "Write published scan results back to the selected version")
|
||||
@@ -387,6 +387,22 @@ registerCommand(program, ["scan"])
|
||||
await cmdScan(opts, folder, options);
|
||||
});
|
||||
|
||||
registerCommand(scanCmd, ["scan", "download"])
|
||||
.description("Download stored scan results for a submitted skill or plugin version")
|
||||
.argument("<name>", "Skill slug or plugin package name")
|
||||
.option("--version <version>", "Submitted version to download scan results for")
|
||||
.option("--kind <kind>", "Artifact kind: skill or plugin", "skill")
|
||||
.option("-o, --output <path>", "Output ZIP file")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
const parentOptions = scanCmd.opts<{ version?: string; output?: string }>();
|
||||
await cmdScanDownload(opts, name, {
|
||||
...options,
|
||||
version: options.version ?? parentOptions.version,
|
||||
output: options.output ?? parentOptions.output,
|
||||
});
|
||||
});
|
||||
|
||||
registerCommand(program, ["delete"])
|
||||
.description("Soft-delete one of your skills")
|
||||
.argument("<slug>", "Skill slug")
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdScan } = await import("./scan");
|
||||
const { cmdScan, cmdScanDownload } = await import("./scan");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
@@ -88,7 +88,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("cmdScan", () => {
|
||||
it("uploads a local skill bundle and polls until complete", async () => {
|
||||
it("rejects local folder scans because stored submitted-version reports are canonical", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "local-skill");
|
||||
@@ -96,47 +96,13 @@ describe("cmdScan", () => {
|
||||
await writeFile(join(folder, "SKILL.md"), "# Local Skill\n", "utf8");
|
||||
await writeFile(join(folder, "notes.md"), "notes\n", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
scanId: "scan_123",
|
||||
jobId: "job_123",
|
||||
status: "queued",
|
||||
sourceKind: "upload",
|
||||
update: false,
|
||||
});
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(completedScan({ sourceKind: "upload" }));
|
||||
|
||||
await cmdScan(makeGlobalOpts(workdir), "local-skill", {});
|
||||
|
||||
const submitCall = httpMocks.apiRequestForm.mock.calls[0];
|
||||
expect(submitCall?.[1]).toMatchObject({
|
||||
method: "POST",
|
||||
path: ApiRoutes.skillScans,
|
||||
token: "tkn",
|
||||
});
|
||||
if (!submitCall) throw new Error("missing scan submit call");
|
||||
const form = (submitCall[1] as { form: FormData }).form;
|
||||
const payloadRaw = form.get("payload");
|
||||
expect(typeof payloadRaw).toBe("string");
|
||||
expect(JSON.parse(payloadRaw as string)).toEqual({
|
||||
source: { kind: "upload" },
|
||||
update: false,
|
||||
});
|
||||
const files = form?.getAll("files") as Array<Blob & { name?: string }>;
|
||||
expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.skillScans}/scan_123`,
|
||||
token: "tkn",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("ClawScan"));
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No suspicious behavior found."),
|
||||
await expect(cmdScan(makeGlobalOpts(workdir), "local-skill", {})).rejects.toThrow(
|
||||
"Local folder scans are no longer supported",
|
||||
);
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(registryMocks.getRegistry).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -212,3 +178,73 @@ describe("cmdScan", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdScanDownload", () => {
|
||||
it("downloads stored scan results for a submitted skill version", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
|
||||
|
||||
await cmdScanDownload(makeGlobalOpts(workdir), "demo-skill", {
|
||||
version: "1.2.3",
|
||||
output: "scan.zip",
|
||||
});
|
||||
|
||||
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
path: `${ApiRoutes.skillScans}/download/demo-skill?version=1.2.3&kind=skill`,
|
||||
token: "tkn",
|
||||
});
|
||||
expect(await readFile(join(workdir, "scan.zip"))).toEqual(Buffer.from([80, 75, 3, 4]));
|
||||
expect(mockLog).toHaveBeenCalledWith(`Report ZIP: ${join(workdir, "scan.zip")}`);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires a version because rejected versions must be addressed explicitly", async () => {
|
||||
await expect(cmdScanDownload(makeGlobalOpts(), "demo-skill", {})).rejects.toThrow(
|
||||
"--version required",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes the version in the default report filename", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
|
||||
|
||||
await cmdScanDownload(makeGlobalOpts(workdir), "demo-skill", {
|
||||
version: "../../evil",
|
||||
});
|
||||
|
||||
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
path: `${ApiRoutes.skillScans}/download/demo-skill?version=..%2F..%2Fevil&kind=skill`,
|
||||
token: "tkn",
|
||||
});
|
||||
expect(await readFile(join(workdir, "clawhub-scan-demo-skill-..-..-evil.zip"))).toEqual(
|
||||
Buffer.from([80, 75, 3, 4]),
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("supports plugin scan report downloads with an explicit kind", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
httpMocks.fetchBinary.mockResolvedValueOnce(new Uint8Array([80, 75, 3, 4]));
|
||||
|
||||
await cmdScanDownload(makeGlobalOpts(workdir), "@scope/demo", {
|
||||
kind: "plugin",
|
||||
version: "2.0.0",
|
||||
output: "plugin-scan.zip",
|
||||
});
|
||||
|
||||
expect(httpMocks.fetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
path: `${ApiRoutes.skillScans}/download/%40scope%2Fdemo?version=2.0.0&kind=plugin`,
|
||||
token: "tkn",
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { apiRequest, apiRequestForm, fetchBinary } from "../../http.js";
|
||||
import { apiRequest, fetchBinary } from "../../http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillScanStatusResponseSchema,
|
||||
ApiV1SkillScanSubmitResponseSchema,
|
||||
type ApiV1SkillScanStatusResponse,
|
||||
} from "../../schema/index.js";
|
||||
import { listTextFiles } from "../../skills.js";
|
||||
import { requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
@@ -24,19 +23,24 @@ type ScanOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type ScanDownloadOptions = {
|
||||
kind?: "skill" | "plugin";
|
||||
version?: string;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type ReportRecord = Record<string, unknown>;
|
||||
|
||||
export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, options: ScanOptions) {
|
||||
validateScanOptions(pathArg, options);
|
||||
if (pathArg?.trim()) rejectLocalScan();
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = createSpinner("Submitting scan");
|
||||
|
||||
try {
|
||||
const submitted = pathArg
|
||||
? await submitLocalScan(opts, registry, token, pathArg)
|
||||
: await submitPublishedScan(registry, token, options);
|
||||
const submitted = await submitPublishedScan(registry, token, options);
|
||||
|
||||
spinner.text = formatScanProgress("queued", submitted.scanId, submitted.queue);
|
||||
const status = await pollScan(registry, token, submitted.scanId, spinner);
|
||||
@@ -68,6 +72,34 @@ export async function cmdScan(opts: GlobalOpts, pathArg: string | undefined, opt
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdScanDownload(
|
||||
opts: GlobalOpts,
|
||||
nameArg: string,
|
||||
options: ScanDownloadOptions,
|
||||
) {
|
||||
const name = nameArg.trim();
|
||||
if (!name) fail("Skill or plugin name required");
|
||||
const version = options.version?.trim();
|
||||
if (!version) fail("--version required");
|
||||
const kind = options.kind ?? "skill";
|
||||
if (kind !== "skill" && kind !== "plugin") fail('--kind must be "skill" or "plugin"');
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const output = resolve(
|
||||
opts.workdir,
|
||||
options.output ?? `clawhub-scan-${safeOutputName(name)}-${safeOutputName(version)}.zip`,
|
||||
);
|
||||
const query = new URLSearchParams({ version, kind });
|
||||
const bytes = await fetchBinary(registry, {
|
||||
path: `${ApiRoutes.skillScans}/download/${encodeURIComponent(name)}?${query.toString()}`,
|
||||
token,
|
||||
});
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, bytes);
|
||||
console.log(`Report ZIP: ${output}`);
|
||||
}
|
||||
|
||||
function validateScanOptions(pathArg: string | undefined, options: ScanOptions) {
|
||||
const hasPath = Boolean(pathArg?.trim());
|
||||
const hasSlug = Boolean(options.slug?.trim());
|
||||
@@ -76,33 +108,9 @@ function validateScanOptions(pathArg: string | undefined, options: ScanOptions)
|
||||
if (hasPath && options.update) fail("--update is only valid with --slug");
|
||||
}
|
||||
|
||||
async function submitLocalScan(opts: GlobalOpts, registry: string, token: string, pathArg: string) {
|
||||
const folder = resolve(opts.workdir, pathArg);
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat?.isDirectory()) fail("Path must be a folder");
|
||||
|
||||
const files = await listTextFiles(folder);
|
||||
if (
|
||||
!files.some((file) => {
|
||||
const lower = file.relPath.toLowerCase();
|
||||
return lower === "skill.md";
|
||||
})
|
||||
) {
|
||||
fail("SKILL.md required");
|
||||
}
|
||||
if (files.length === 0) fail("No files found");
|
||||
|
||||
const form = new FormData();
|
||||
form.set("payload", JSON.stringify({ source: { kind: "upload" }, update: false }));
|
||||
for (const file of files) {
|
||||
const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? "text/plain" });
|
||||
form.append("files", blob, file.relPath);
|
||||
}
|
||||
|
||||
return await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.skillScans, token, form },
|
||||
ApiV1SkillScanSubmitResponseSchema,
|
||||
function rejectLocalScan(): never {
|
||||
fail(
|
||||
"Local folder scans are no longer supported. Upload a new version, then run `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,3 +329,11 @@ function dateValue(value: unknown) {
|
||||
? new Date(value).toISOString()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeOutputName(value: string) {
|
||||
return value
|
||||
.replace(/^@/, "")
|
||||
.replaceAll("/", "-")
|
||||
.replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type LocalDeploymentConfig = {
|
||||
const managedChildren = new Set<ChildProcess>();
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "clawhub-pw-local-auth-"));
|
||||
const envFile = join(tempDir, ".env.local");
|
||||
const emailCaptureFile = join(tempDir, "emails.jsonl");
|
||||
const localConvexStateBackupDir = join(tempDir, "convex-local-default.backup");
|
||||
const localEnvBackupFile = join(tempDir, ".env.local.backup");
|
||||
let backedUpLocalConvexState = false;
|
||||
@@ -339,6 +340,7 @@ async function main() {
|
||||
...process.env,
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID ?? "local-dev",
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET ?? "local-dev",
|
||||
CLAWHUB_EMAIL_CAPTURE_FILE: process.env.CLAWHUB_EMAIL_CAPTURE_FILE ?? emailCaptureFile,
|
||||
CONVEX_AGENT_MODE: process.env.CONVEX_AGENT_MODE ?? "anonymous",
|
||||
CONVEX_SITE_URL: convexSiteUrl,
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
@@ -362,6 +364,7 @@ async function main() {
|
||||
[
|
||||
`AUTH_GITHUB_ID=${e2eEnv.AUTH_GITHUB_ID}`,
|
||||
`AUTH_GITHUB_SECRET=${e2eEnv.AUTH_GITHUB_SECRET}`,
|
||||
`CLAWHUB_EMAIL_CAPTURE_FILE=${e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE}`,
|
||||
...(deployment ? [`CONVEX_DEPLOYMENT=${deployment}`] : []),
|
||||
`CONVEX_SITE_URL=${convexSiteUrl}`,
|
||||
...(deployment ? [`DEV_AUTH_CONVEX_DEPLOYMENT=${devAuthDeploymentMarker(deployment)}`] : []),
|
||||
@@ -408,6 +411,7 @@ async function main() {
|
||||
await setLocalConvexEnv(convexUrl, [
|
||||
{ name: "AUTH_GITHUB_ID", value: e2eEnv.AUTH_GITHUB_ID ?? "local-dev" },
|
||||
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
|
||||
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
|
||||
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment },
|
||||
{ name: "DEV_AUTH_ENABLED", value: "1" },
|
||||
{ name: "JWKS", value: authKeys.JWKS },
|
||||
|
||||
@@ -102,6 +102,12 @@ Ensure Convex env is set (auth + embeddings):
|
||||
- `JWT_PRIVATE_KEY`
|
||||
- `JWKS`
|
||||
- `OPENAI_API_KEY`
|
||||
- `RESEND_API_KEY` for account-ban notification email
|
||||
- `CLAWHUB_SECURITY_EMAIL` for account-action replies, defaulting to
|
||||
`security@notifications.openclaw.ai`
|
||||
- `CLAWHUB_SECURITY_EMAIL_FROM` for the outbound From header, defaulting to
|
||||
`ClawHub Security <noreply@notifications.openclaw.ai>` on the verified Resend
|
||||
domain
|
||||
- `SITE_URL` (your web app URL)
|
||||
- Optional webhook env (see `docs/webhook.md`)
|
||||
- Recommended GitHub App env for authenticated GitHub API reads used by publish
|
||||
|
||||
@@ -108,10 +108,15 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
compatibility, but the first-class CLI and docs surface is deprecated.
|
||||
Publisher recovery for false positives should use reports or out-of-band
|
||||
support, while account bans require out-of-band support.
|
||||
- Any ClawScan path that determines a skill is malicious must hide the skill and
|
||||
schedule the same account-level autoban/token-revocation workflow. Static
|
||||
scan findings are ClawScan input context only and must not schedule autobans
|
||||
or set public/install-blocking trust by themselves.
|
||||
- Any ClawScan path that determines a skill or plugin release is malicious must
|
||||
block that candidate version and notify the publisher with local
|
||||
`clawhub scan` remediation guidance. Scanner-triggered emails are
|
||||
artifact-level and must not link to account appeals. Account-level autoban,
|
||||
token revocation, and appeal email only happen after the silent escalation
|
||||
thresholds: two distinct malicious artifacts or three malicious attempts on
|
||||
the same artifact. Static scan findings are ClawScan input context only and
|
||||
must not schedule account autobans or set public/install-blocking trust by
|
||||
themselves.
|
||||
- Pending skill ownership transfers must not be accepted when the requesting
|
||||
owner is deleted/deactivated or when the skill is malicious, hidden, or
|
||||
removed. The accept path is the final shared gate before ownership changes,
|
||||
@@ -216,7 +221,9 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
post-Codex veto. The release worker must not downgrade a benign Codex verdict
|
||||
solely from regex telemetry.
|
||||
- Artifacts remain visible while Codex runs unless another non-scanner moderation
|
||||
hold applies. Codex malicious verdicts hide/block.
|
||||
hold applies. Codex malicious verdicts block the candidate version. On updates,
|
||||
the previous clean/current public version remains live; on first versions,
|
||||
nothing public is promoted.
|
||||
- Plugins under `@openclaw/*` owned by the OpenClaw publisher are trusted by
|
||||
default. They may still be audited, but scanner telemetry alone must not
|
||||
downgrade them.
|
||||
@@ -298,6 +305,13 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
on the restored user's package audit rows.
|
||||
Ban context lookup must tolerate duplicate Convex Auth account rows by
|
||||
selecting the currently banned user with matching ban audit evidence.
|
||||
- Ban notification emails must be public-safe: include the high-level action
|
||||
reason, affected skill/plugin when known, the external appeals link, and
|
||||
scanner context when the account was escalated from repeated malicious
|
||||
artifacts. Artifact-level scanner emails must instead say the version was
|
||||
blocked, keep appeals out of the copy, and link existing CLI scan docs. Emails
|
||||
must not expose raw moderator notes, reporter identifiers, internal finding
|
||||
ids, or other staff-only ban reason text.
|
||||
- Unban restore batches only restore packages/plugins hidden by the matching
|
||||
ban timestamp and must stop if the user has been banned again.
|
||||
- Stale unban restore batches must stop if the user was banned again before a
|
||||
|
||||
@@ -188,7 +188,7 @@ describe("restored UI design contract", () => {
|
||||
|
||||
const settingsSource = settings();
|
||||
expect(settingsSource).not.toMatch(/tweakcn|custom theme|overlay/i);
|
||||
expect(settingsSource).not.toMatch(/density|compact|relaxed|high contrast|code font size/i);
|
||||
expect(settingsSource).not.toMatch(/density|relaxed|high contrast|code font size/i);
|
||||
expect(settingsSource).not.toMatch(/default view|experimental features/i);
|
||||
|
||||
const themeSource = theme();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ACCESS_DENIED_SIGN_IN_MESSAGE,
|
||||
AUTH_CODE_NO_SESSION_MESSAGE,
|
||||
BANNED_SIGN_IN_MESSAGE,
|
||||
BANNED_ACCOUNT_PATH,
|
||||
DELETED_SIGN_IN_MESSAGE,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { getAuthErrorSnapshot, clearAuthError, setAuthError } from "../lib/useAuthError";
|
||||
@@ -88,7 +88,7 @@ describe("AuthCodeHandler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces user-facing sign-in errors from code verification", async () => {
|
||||
it("routes banned-account errors from code verification to the account-banned page", async () => {
|
||||
signInMock.mockRejectedValue(
|
||||
new Error("[CONVEX A] Server Error Called by client ConvexError: Account banned"),
|
||||
);
|
||||
@@ -97,8 +97,9 @@ describe("AuthCodeHandler", () => {
|
||||
render(<AuthCodeHandler />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAuthErrorSnapshot()).toBe(BANNED_SIGN_IN_MESSAGE);
|
||||
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
|
||||
});
|
||||
expect(getAuthErrorSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("restarts GitHub sign-in once when code verification finishes without a session", async () => {
|
||||
@@ -186,7 +187,7 @@ describe("AuthErrorHandler", () => {
|
||||
expect(getAuthErrorSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces provider errors from the URL and strips them", async () => {
|
||||
it("routes banned-account provider errors to the account-banned page", async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
@@ -196,12 +197,11 @@ describe("AuthErrorHandler", () => {
|
||||
render(<AuthErrorHandler />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAuthErrorSnapshot()).toBe(BANNED_SIGN_IN_MESSAGE);
|
||||
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
|
||||
});
|
||||
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
"/sign-in?next=%2Fdashboard#section",
|
||||
);
|
||||
expect(getAuthErrorSnapshot()).toBeNull();
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to the provider error when there is no description", async () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { convex } from "../convex/client";
|
||||
import {
|
||||
AUTH_CODE_NO_SESSION_MESSAGE,
|
||||
getUserFacingAuthError,
|
||||
isBannedAccountAuthError,
|
||||
normalizeAuthErrorMessage,
|
||||
routeToBannedAccountPage as navigateToBannedAccountPage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { clearAuthError, setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { AuthErrorMessage } from "./AuthErrorMessage";
|
||||
@@ -33,6 +35,20 @@ function getPendingAuthCode() {
|
||||
};
|
||||
}
|
||||
|
||||
function routeAuthErrorToBannedAccountPage() {
|
||||
if (typeof window === "undefined") return;
|
||||
clearAuthError();
|
||||
navigateToBannedAccountPage();
|
||||
}
|
||||
|
||||
function handleAuthErrorMessage(message: string) {
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeAuthErrorToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
setAuthError(message);
|
||||
}
|
||||
|
||||
export function AuthCodeHandler() {
|
||||
const { signIn } = useAuthActions();
|
||||
const handledCodeRef = useRef<string | null>(null);
|
||||
@@ -78,8 +94,13 @@ export function AuthCodeHandler() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeAuthErrorToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, "", pending.relativeUrl);
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
setAuthError(message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -88,8 +109,13 @@ export function AuthCodeHandler() {
|
||||
setAuthError(AUTH_CODE_NO_SESSION_MESSAGE);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeAuthErrorToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, "", pending.relativeUrl);
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
setAuthError(message);
|
||||
});
|
||||
}, [signInWithGitHub]);
|
||||
|
||||
@@ -118,10 +144,16 @@ export function AuthErrorHandler() {
|
||||
if (handledErrorRef.current === pending.description) return;
|
||||
handledErrorRef.current = pending.description;
|
||||
|
||||
window.history.replaceState(null, "", pending.relativeUrl);
|
||||
setAuthError(
|
||||
normalizeAuthErrorMessage(pending.description, "Sign in failed. Please try again."),
|
||||
const message = normalizeAuthErrorMessage(
|
||||
pending.description,
|
||||
"Sign in failed. Please try again.",
|
||||
);
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeAuthErrorToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, "", pending.relativeUrl);
|
||||
handleAuthErrorMessage(message);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import {
|
||||
ACCOUNT_APPEAL_LINK_TEXT,
|
||||
ACCOUNT_APPEAL_URL,
|
||||
CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT,
|
||||
CLAWHUB_ACCOUNT_ISSUE_URL,
|
||||
} from "../lib/authErrorMessage";
|
||||
|
||||
export function AuthErrorMessage({ message }: { message: string }) {
|
||||
const linkStart = message.indexOf(CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT);
|
||||
const link =
|
||||
message.indexOf(ACCOUNT_APPEAL_LINK_TEXT) === -1
|
||||
? { text: CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT, href: CLAWHUB_ACCOUNT_ISSUE_URL }
|
||||
: { text: ACCOUNT_APPEAL_LINK_TEXT, href: ACCOUNT_APPEAL_URL };
|
||||
|
||||
const linkStart = message.indexOf(link.text);
|
||||
if (linkStart === -1) return <>{message}</>;
|
||||
|
||||
const before = message.slice(0, linkStart);
|
||||
const after = message.slice(linkStart + CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT.length);
|
||||
const after = message.slice(linkStart + link.text.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<a
|
||||
href={CLAWHUB_ACCOUNT_ISSUE_URL}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
{CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}
|
||||
{link.text}
|
||||
</a>
|
||||
{after}
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Building2, Check, Shield, User, UserCog, Wrench } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { isBannedAccountAuthError, routeToBannedAccountPage } from "../lib/authErrorMessage";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
|
||||
type DevPersona = "owner" | "user" | "admin" | "officialOrgMember";
|
||||
type DevPersona = "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher";
|
||||
|
||||
const DEV_PERSONA_AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -47,6 +48,12 @@ const PERSONAS: Array<{
|
||||
description: "@local-official-member",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
value: "abusePublisher",
|
||||
label: "Use Abuse Publisher",
|
||||
description: "@local-abuse",
|
||||
icon: Wrench,
|
||||
},
|
||||
];
|
||||
|
||||
function isLocalDevPersonaEnabled() {
|
||||
@@ -115,7 +122,12 @@ export function DevPersonaFab() {
|
||||
}
|
||||
toast.success(`Using ${persona} persona`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Dev persona sign-in failed");
|
||||
const message = error instanceof Error ? error.message : "Dev persona sign-in failed";
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBusyPersona(null);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import {
|
||||
getUserFacingAuthError,
|
||||
isBannedAccountAuthError,
|
||||
routeToBannedAccountPage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import { NAV_ICONS } from "../lib/marketplaceIcons";
|
||||
import { filterNavItems, PRIMARY_NAV_ITEMS, SECONDARY_NAV_ITEMS } from "../lib/nav-items";
|
||||
@@ -509,9 +513,15 @@ export default function Header() {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
const message = getUserFacingAuthError(
|
||||
error,
|
||||
"Sign in failed. Please try again.",
|
||||
);
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
setAuthError(message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,8 @@ const signInMock = vi.fn();
|
||||
const clearAuthErrorMock = vi.fn();
|
||||
const setAuthErrorMock = vi.fn();
|
||||
const getUserFacingAuthErrorMock = vi.fn();
|
||||
const isBannedAccountAuthErrorMock = vi.fn();
|
||||
const routeToBannedAccountPageMock = vi.fn();
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
@@ -23,6 +25,8 @@ vi.mock("../lib/useAuthError", () => ({
|
||||
vi.mock("../lib/authErrorMessage", () => ({
|
||||
getUserFacingAuthError: (error: unknown, fallback: string) =>
|
||||
getUserFacingAuthErrorMock(error, fallback),
|
||||
isBannedAccountAuthError: (message: string) => isBannedAccountAuthErrorMock(message),
|
||||
routeToBannedAccountPage: () => routeToBannedAccountPageMock(),
|
||||
}));
|
||||
|
||||
describe("SignInButton", () => {
|
||||
@@ -31,7 +35,10 @@ describe("SignInButton", () => {
|
||||
clearAuthErrorMock.mockReset();
|
||||
setAuthErrorMock.mockReset();
|
||||
getUserFacingAuthErrorMock.mockReset();
|
||||
isBannedAccountAuthErrorMock.mockReset();
|
||||
routeToBannedAccountPageMock.mockReset();
|
||||
getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback);
|
||||
isBannedAccountAuthErrorMock.mockReturnValue(false);
|
||||
window.history.replaceState(null, "", "/skills?q=test#top");
|
||||
});
|
||||
|
||||
@@ -99,4 +106,19 @@ describe("SignInButton", () => {
|
||||
expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
it("routes banned-account sign-in rejections to the banned account page", async () => {
|
||||
const failure = new Error("oauth failed");
|
||||
signInMock.mockRejectedValue(failure);
|
||||
getUserFacingAuthErrorMock.mockReturnValue("This account has been banned.");
|
||||
isBannedAccountAuthErrorMock.mockReturnValue(true);
|
||||
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(setAuthErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import {
|
||||
getUserFacingAuthError,
|
||||
isBannedAccountAuthError,
|
||||
routeToBannedAccountPage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { clearAuthError, setAuthError } from "../lib/useAuthError";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
@@ -28,7 +32,12 @@ export function SignInButton({ redirectTo, children = "Sign In", ...props }: Sig
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
setAuthError(message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Star } from "lucide-react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SignInPrompt } from "./SignInPrompt";
|
||||
|
||||
const signInMock = vi.fn();
|
||||
const clearAuthErrorMock = vi.fn();
|
||||
const setAuthErrorMock = vi.fn();
|
||||
const isBannedAccountAuthErrorMock = vi.fn();
|
||||
const routeToBannedAccountPageMock = vi.fn();
|
||||
let authErrorMock: string | null = null;
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
@@ -18,15 +21,31 @@ vi.mock("@convex-dev/auth/react", () => ({
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
clearAuthError: () => clearAuthErrorMock(),
|
||||
setAuthError: (message: string) => setAuthErrorMock(message),
|
||||
useAuthError: () => ({ error: authErrorMock, clear: clearAuthErrorMock }),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/authErrorMessage", () => ({
|
||||
ACCOUNT_APPEAL_LINK_TEXT: "appeal this decision",
|
||||
ACCOUNT_APPEAL_URL: "https://appeals.openclaw.ai/",
|
||||
CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT: "open a GitHub issue",
|
||||
CLAWHUB_ACCOUNT_ISSUE_URL: "https://github.com/openclaw/clawhub/issues/new",
|
||||
getUserFacingAuthError: (_error: unknown, fallback: string) => fallback,
|
||||
isBannedAccountAuthError: (message: string | null | undefined) =>
|
||||
isBannedAccountAuthErrorMock(message),
|
||||
routeToBannedAccountPage: () => routeToBannedAccountPageMock(),
|
||||
}));
|
||||
|
||||
describe("SignInPrompt", () => {
|
||||
beforeEach(() => {
|
||||
authErrorMock = null;
|
||||
clearAuthErrorMock.mockReset();
|
||||
setAuthErrorMock.mockReset();
|
||||
isBannedAccountAuthErrorMock.mockReset();
|
||||
routeToBannedAccountPageMock.mockReset();
|
||||
signInMock.mockReset();
|
||||
isBannedAccountAuthErrorMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("renders title and description", () => {
|
||||
render(<SignInPrompt title="Sign in to test" description="Test description" />);
|
||||
expect(screen.getByRole("heading", { name: "Sign in to test" })).toBeTruthy();
|
||||
@@ -58,16 +77,29 @@ describe("SignInPrompt", () => {
|
||||
expect(onDismissError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("links account issue guidance in auth errors", () => {
|
||||
it("routes explicit banned-account auth errors to the banned account page", () => {
|
||||
isBannedAccountAuthErrorMock.mockReturnValue(true);
|
||||
render(
|
||||
<SignInPrompt
|
||||
title="Sign in"
|
||||
error="Sign in failed. Please open a GitHub issue if you believe this is a mistake."
|
||||
error="This ClawHub account is not in good standing and cannot sign in. Please appeal this decision if you believe this is a mistake."
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "open a GitHub issue" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/openclaw/clawhub/issues/new");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes global banned-account auth errors to the banned account page", () => {
|
||||
authErrorMock =
|
||||
"This ClawHub account is not in good standing and cannot sign in. Please appeal this decision if you believe this is a mistake.";
|
||||
isBannedAccountAuthErrorMock.mockReturnValue(true);
|
||||
|
||||
render(<SignInPrompt title="Sign in" />);
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(clearAuthErrorMock).toHaveBeenCalledTimes(1);
|
||||
expect(routeToBannedAccountPageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render dismiss button when onDismissError is missing", () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { isBannedAccountAuthError, routeToBannedAccountPage } from "../lib/authErrorMessage";
|
||||
import { useAuthError } from "../lib/useAuthError";
|
||||
import { AuthErrorMessage } from "./AuthErrorMessage";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
|
||||
@@ -33,6 +35,20 @@ export function SignInPrompt({
|
||||
onDismissError,
|
||||
className,
|
||||
}: SignInPromptProps) {
|
||||
const { error: globalAuthError, clear: clearGlobalAuthError } = useAuthError();
|
||||
const rawVisibleError = error ?? globalAuthError;
|
||||
const isBannedAuthError = isBannedAccountAuthError(rawVisibleError);
|
||||
const visibleError = isBannedAuthError ? null : rawVisibleError;
|
||||
const dismissVisibleError = error
|
||||
? onDismissError
|
||||
: (onDismissError ?? (globalAuthError ? clearGlobalAuthError : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBannedAuthError) return;
|
||||
if (globalAuthError) clearGlobalAuthError();
|
||||
routeToBannedAccountPage();
|
||||
}, [clearGlobalAuthError, globalAuthError, isBannedAuthError]);
|
||||
|
||||
const defaultAction = (
|
||||
<SignInButton
|
||||
size="sm"
|
||||
@@ -74,16 +90,16 @@ export function SignInPrompt({
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
{visibleError ? (
|
||||
<p
|
||||
className="mt-3 rounded-[var(--radius-sm)] border border-red-300/40 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/50 dark:text-red-300"
|
||||
role="alert"
|
||||
>
|
||||
<AuthErrorMessage message={error} />{" "}
|
||||
{onDismissError ? (
|
||||
<AuthErrorMessage message={visibleError} />{" "}
|
||||
{dismissVisibleError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissError}
|
||||
onClick={dismissVisibleError}
|
||||
aria-label="Dismiss"
|
||||
className="cursor-pointer border-none bg-transparent px-0.5 text-inherit"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import {
|
||||
getUserFacingAuthError,
|
||||
isBannedAccountAuthError,
|
||||
routeToBannedAccountPage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { getSkillCategoryForSkill } from "../lib/categories";
|
||||
import { getUserFacingConvexError } from "../lib/convexError";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
@@ -685,7 +689,12 @@ export function SkillDetailPage({
|
||||
? "/"
|
||||
: `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
void signIn("github", redirectTo ? { redirectTo } : undefined).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
const message = getUserFacingAuthError(error, "Sign in failed. Please try again.");
|
||||
if (isBannedAccountAuthError(message)) {
|
||||
routeToBannedAccountPage();
|
||||
return;
|
||||
}
|
||||
setAuthError(message);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_APPEAL_LINK_TEXT,
|
||||
ACCOUNT_APPEAL_URL,
|
||||
BANNED_ACCOUNT_PATH,
|
||||
BANNED_SIGN_IN_MESSAGE,
|
||||
getUserFacingAuthError,
|
||||
isBannedAccountAuthError,
|
||||
normalizeAuthErrorMessage,
|
||||
routeToBannedAccountPage,
|
||||
} from "./authErrorMessage";
|
||||
|
||||
describe("authErrorMessage", () => {
|
||||
it("routes banned-account sign-in errors to the appeals site", () => {
|
||||
expect(normalizeAuthErrorMessage("Account banned", "fallback")).toBe(BANNED_SIGN_IN_MESSAGE);
|
||||
expect(isBannedAccountAuthError("This account has been disabled.")).toBe(true);
|
||||
expect(BANNED_SIGN_IN_MESSAGE).toContain(ACCOUNT_APPEAL_LINK_TEXT);
|
||||
expect(ACCOUNT_APPEAL_URL).toBe("https://appeals.openclaw.ai/");
|
||||
});
|
||||
|
||||
it("navigates banned-account auth failures to the dedicated page", () => {
|
||||
window.history.replaceState(null, "", "/dashboard?error_description=Account%20banned");
|
||||
|
||||
routeToBannedAccountPage();
|
||||
|
||||
expect(window.location.pathname).toBe(BANNED_ACCOUNT_PATH);
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("does not route deleted-account errors to appeals", () => {
|
||||
expect(
|
||||
normalizeAuthErrorMessage(
|
||||
"This account has been permanently deleted and cannot be restored.",
|
||||
"fallback",
|
||||
),
|
||||
).toBe("This ClawHub account was permanently deleted and cannot sign in again.");
|
||||
});
|
||||
|
||||
it("does not treat generic permission denials as banned sign-in failures", () => {
|
||||
const message = getUserFacingAuthError(new Error("Forbidden"), "fallback");
|
||||
|
||||
expect(message).toBe(
|
||||
"This ClawHub account does not have permission to perform this action, or the account is not in good standing.",
|
||||
);
|
||||
expect(isBannedAccountAuthError(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
+27
-10
@@ -2,13 +2,38 @@ import { getUserFacingConvexError } from "./convexError";
|
||||
|
||||
export const CLAWHUB_ACCOUNT_ISSUE_URL = "https://github.com/openclaw/clawhub/issues/new";
|
||||
export const CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT = "open a GitHub issue";
|
||||
export const ACCOUNT_APPEAL_URL = "https://appeals.openclaw.ai/";
|
||||
export const ACCOUNT_APPEAL_LINK_TEXT = "appeal this decision";
|
||||
export const BANNED_ACCOUNT_PATH = "/account-banned";
|
||||
|
||||
export const BANNED_SIGN_IN_MESSAGE = `This ClawHub account is not in good standing and cannot sign in. Please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT} if you believe this is a mistake.`;
|
||||
export const BANNED_SIGN_IN_MESSAGE = `This ClawHub account is not in good standing and cannot sign in. Please ${ACCOUNT_APPEAL_LINK_TEXT} if you believe this is a mistake.`;
|
||||
export const DELETED_SIGN_IN_MESSAGE =
|
||||
"This ClawHub account was permanently deleted and cannot sign in again.";
|
||||
export const ACCESS_DENIED_SIGN_IN_MESSAGE = `Sign in was denied. Please try signing in with GitHub again. If this keeps happening, please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}.`;
|
||||
export const AUTH_CODE_NO_SESSION_MESSAGE = `Sign in did not complete. Please try signing in with GitHub again. If this keeps happening, please ${CLAWHUB_ACCOUNT_ISSUE_LINK_TEXT}.`;
|
||||
|
||||
export function isBannedAccountAuthError(message: string | null | undefined) {
|
||||
const lowered = message?.trim().toLowerCase();
|
||||
if (!lowered) return false;
|
||||
return (
|
||||
lowered.includes("account banned") ||
|
||||
lowered.includes("account has been banned") ||
|
||||
lowered.includes("account is banned") ||
|
||||
lowered.includes("not in good standing and cannot sign in") ||
|
||||
lowered.includes("account disabled") ||
|
||||
lowered.includes("account has been disabled") ||
|
||||
lowered.includes("account is disabled")
|
||||
);
|
||||
}
|
||||
|
||||
export function routeToBannedAccountPage() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.history.replaceState(null, "", BANNED_ACCOUNT_PATH);
|
||||
window.dispatchEvent(
|
||||
typeof PopStateEvent === "function" ? new PopStateEvent("popstate") : new Event("popstate"),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAuthErrorMessage(message: string | null | undefined, fallback: string) {
|
||||
const normalized = message?.trim();
|
||||
if (!normalized) return fallback;
|
||||
@@ -19,15 +44,7 @@ export function normalizeAuthErrorMessage(message: string | null | undefined, fa
|
||||
if (lowered.includes("cannot be restored") && lowered.includes("deleted")) {
|
||||
return DELETED_SIGN_IN_MESSAGE;
|
||||
}
|
||||
if (
|
||||
lowered.includes("account banned") ||
|
||||
lowered.includes("account has been banned") ||
|
||||
lowered.includes("account is banned") ||
|
||||
lowered.includes("not in good standing") ||
|
||||
lowered.includes("account disabled") ||
|
||||
lowered.includes("account has been disabled") ||
|
||||
lowered.includes("account is disabled")
|
||||
) {
|
||||
if (isBannedAccountAuthError(lowered)) {
|
||||
return BANNED_SIGN_IN_MESSAGE;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Route as ImportRouteImport } from './routes/import'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as AuditsRouteImport } from './routes/audits'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as AccountBannedRouteImport } from './routes/account-banned'
|
||||
import { Route as SlugRouteImport } from './routes/$slug'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as UsersIndexRouteImport } from './routes/users/index'
|
||||
@@ -110,6 +111,11 @@ const AdminRoute = AdminRouteImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AccountBannedRoute = AccountBannedRouteImport.update({
|
||||
id: '/account-banned',
|
||||
path: '/account-banned',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SlugRoute = SlugRouteImport.update({
|
||||
id: '/$slug',
|
||||
path: '/$slug',
|
||||
@@ -290,6 +296,7 @@ const PluginsScopeNameSecurityScannerRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$slug': typeof SlugRoute
|
||||
'/account-banned': typeof AccountBannedRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/audits': typeof AuditsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
@@ -337,6 +344,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$slug': typeof SlugRoute
|
||||
'/account-banned': typeof AccountBannedRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/audits': typeof AuditsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
@@ -385,6 +393,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$slug': typeof SlugRoute
|
||||
'/account-banned': typeof AccountBannedRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/audits': typeof AuditsRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
@@ -434,6 +443,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/$slug'
|
||||
| '/account-banned'
|
||||
| '/admin'
|
||||
| '/audits'
|
||||
| '/dashboard'
|
||||
@@ -481,6 +491,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/$slug'
|
||||
| '/account-banned'
|
||||
| '/admin'
|
||||
| '/audits'
|
||||
| '/dashboard'
|
||||
@@ -528,6 +539,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/$slug'
|
||||
| '/account-banned'
|
||||
| '/admin'
|
||||
| '/audits'
|
||||
| '/dashboard'
|
||||
@@ -576,6 +588,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SlugRoute: typeof SlugRoute
|
||||
AccountBannedRoute: typeof AccountBannedRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
AuditsRoute: typeof AuditsRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
@@ -691,6 +704,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/account-banned': {
|
||||
id: '/account-banned'
|
||||
path: '/account-banned'
|
||||
fullPath: '/account-banned'
|
||||
preLoaderRoute: typeof AccountBannedRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$slug': {
|
||||
id: '/$slug'
|
||||
path: '/$slug'
|
||||
@@ -982,6 +1002,7 @@ const PluginsScopeNameRouteWithChildren =
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SlugRoute: SlugRoute,
|
||||
AccountBannedRoute: AccountBannedRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
AuditsRoute: AuditsRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AccountBannedPage } from "./account-banned";
|
||||
|
||||
vi.mock("@tanstack/react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-router")>("@tanstack/react-router");
|
||||
return {
|
||||
...actual,
|
||||
createFileRoute: () => (config: unknown) => config,
|
||||
};
|
||||
});
|
||||
|
||||
describe("AccountBannedPage", () => {
|
||||
it("renders appeal-only banned account guidance", () => {
|
||||
render(<AccountBannedPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Your ClawHub account has been banned" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("This account cannot sign in to ClawHub.")).toBeTruthy();
|
||||
|
||||
const appealLink = screen.getByRole("link", { name: "Open an appeal" });
|
||||
expect(appealLink.getAttribute("href")).toBe("https://appeals.openclaw.ai/");
|
||||
expect(screen.queryByRole("button", { name: /sign in/i })).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -1,4 +1,10 @@
|
||||
import { createRootRoute, HeadContent, Scripts, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
createRootRoute,
|
||||
HeadContent,
|
||||
redirect,
|
||||
Scripts,
|
||||
useLocation,
|
||||
} from "@tanstack/react-router";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
@@ -9,12 +15,29 @@ import { ErrorBoundary } from "../components/ErrorBoundary";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { GenericNotFoundPage } from "../components/GenericNotFoundPage";
|
||||
import Header from "../components/Header";
|
||||
import {
|
||||
BANNED_ACCOUNT_PATH,
|
||||
isBannedAccountAuthError,
|
||||
normalizeAuthErrorMessage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
const OG_IMAGE_VERSION = "20260420-12";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
beforeLoad: ({ location }) => {
|
||||
if (location.pathname === BANNED_ACCOUNT_PATH) return;
|
||||
const authError = getAuthErrorDescription(location);
|
||||
if (!authError) return;
|
||||
const message = normalizeAuthErrorMessage(authError, "");
|
||||
if (!isBannedAccountAuthError(message)) return;
|
||||
|
||||
throw redirect({
|
||||
to: BANNED_ACCOUNT_PATH,
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
@@ -117,6 +140,22 @@ export const Route = createRootRoute({
|
||||
notFoundComponent: GenericNotFoundPage,
|
||||
});
|
||||
|
||||
function getAuthErrorDescription(location: { search?: unknown; searchStr?: string }) {
|
||||
const fromSearch =
|
||||
getSearchStringValue(location.search, "error_description") ??
|
||||
getSearchStringValue(location.search, "error");
|
||||
if (fromSearch) return fromSearch;
|
||||
if (!location.searchStr) return null;
|
||||
const params = new URLSearchParams(location.searchStr);
|
||||
return params.get("error_description")?.trim() || params.get("error")?.trim() || null;
|
||||
}
|
||||
|
||||
function getSearchStringValue(search: unknown, key: string) {
|
||||
if (!search || typeof search !== "object") return null;
|
||||
const value = (search as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.clawhubHydrated = "true";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ShieldX } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { ACCOUNT_APPEAL_URL } from "../lib/authErrorMessage";
|
||||
|
||||
export const Route = createFileRoute("/account-banned")({
|
||||
component: AccountBannedPage,
|
||||
});
|
||||
|
||||
export function AccountBannedPage() {
|
||||
return (
|
||||
<main className="relative mx-auto flex min-h-[430px] w-full flex-col overflow-hidden px-4 pb-12 pt-20 sm:px-6 sm:pt-24 lg:px-6">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-20 inset-x-10 h-64"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, color-mix(in srgb, var(--accent) 16%, transparent), color-mix(in srgb, var(--accent) 4%, transparent) 42%, transparent 74%)",
|
||||
filter: "blur(2px)",
|
||||
maskImage: "linear-gradient(to right, transparent, black 22%, black 78%, transparent)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to right, transparent, black 22%, black 78%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<section className="relative z-10 mx-auto w-full max-w-[780px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] px-5 py-8 shadow-[0_18px_50px_rgba(0,0,0,0.12)] sm:px-8 sm:py-10">
|
||||
<span className="mb-5 inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink-soft)]">
|
||||
<ShieldX size={22} />
|
||||
</span>
|
||||
<h1 className="font-display text-2xl font-black leading-tight text-[color:var(--ink)] sm:text-4xl">
|
||||
Your ClawHub account has been banned
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-7 text-[color:var(--ink-soft)]">
|
||||
This account cannot sign in to ClawHub.
|
||||
</p>
|
||||
<p className="mt-2 max-w-2xl text-base leading-7 text-[color:var(--ink-soft)]">
|
||||
Visit appeals.openclaw.ai to open an appeal if you believe this was a mistake.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Button asChild variant="primary">
|
||||
<a href={ACCOUNT_APPEAL_URL}>Open an appeal</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user