mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 18:02:09 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb702bb57 | ||
|
|
6c8ace3755 | ||
|
|
162f4d24f8 | ||
|
|
3e7deacce8 | ||
|
|
a90563d9d2 | ||
|
|
74455bb860 | ||
|
|
704d9457aa | ||
|
|
bf7a1d32c9 | ||
|
|
d467ae3d75 | ||
|
|
5826001795 | ||
|
|
0708a43fde | ||
|
|
311a123fbe | ||
|
|
59e93862ed | ||
|
|
8fcd53f899 | ||
|
|
ba2c73e180 | ||
|
|
fa87dc3509 | ||
|
|
9df6fe37c5 |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -72,7 +73,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2",
|
||||
},
|
||||
},
|
||||
@@ -297,7 +298,7 @@
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
|
||||
|
||||
@@ -473,35 +474,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.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
@@ -1205,7 +1206,7 @@
|
||||
|
||||
"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.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1345,7 +1346,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
@@ -1491,6 +1492,8 @@
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -1503,8 +1506,82 @@
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
|
||||
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
|
||||
"vitest/vite/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -48,6 +48,7 @@ import type * as lib_contentTypes from "../lib/contentTypes.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";
|
||||
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
@@ -76,6 +77,7 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
@@ -92,6 +94,7 @@ import type * as lib_userSearch from "../lib/userSearch.js";
|
||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as llmEval from "../llmEval.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
@@ -161,6 +164,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
@@ -189,6 +193,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
@@ -205,6 +210,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
|
||||
@@ -237,6 +237,74 @@ xuezh snapshot --profile default
|
||||
xuezh review next --limit 10
|
||||
xuezh audio process-voice --file ./utterance.wav
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "hanzi-helper",
|
||||
displayName: "汉字助手",
|
||||
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: "github:example/hanzi-helper",
|
||||
systems: ["aarch64-darwin", "x86_64-linux"],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: ["HANZI_DB_PATH"],
|
||||
stateDirs: [".config/hanzi"],
|
||||
example:
|
||||
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
|
||||
},
|
||||
cliHelp: `汉字助手 - Chinese character learning and analysis
|
||||
|
||||
Usage:
|
||||
hanzi-helper [command]
|
||||
|
||||
Available Commands:
|
||||
lookup 查询汉字信息(笔画、部首、释义)
|
||||
radical 按部首检索汉字
|
||||
stroke 按笔画数筛选汉字
|
||||
words 生成汉字组词
|
||||
practice 练习汉字书写
|
||||
quiz 汉字听写测试
|
||||
|
||||
Flags:
|
||||
-h, --help help for hanzi-helper
|
||||
--json Output JSON
|
||||
`,
|
||||
},
|
||||
},
|
||||
rawSkillMd: `---
|
||||
name: hanzi-helper
|
||||
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
|
||||
---
|
||||
|
||||
# 汉字助手
|
||||
|
||||
## 功能介绍
|
||||
|
||||
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
hanzi-helper lookup --char 学
|
||||
hanzi-helper radical --name 木
|
||||
hanzi-helper stroke --count 8
|
||||
hanzi-helper words --char 大 --limit 20
|
||||
\`\`\`
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
|
||||
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
|
||||
- **组词生成**:输入一个汉字,自动生成常用词语和成语
|
||||
- **听写练习**:随机生成汉字听写测试,巩固学习效果
|
||||
|
||||
## 学习建议
|
||||
|
||||
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -448,6 +448,22 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
["SSH_KEY_DIR"],
|
||||
["generate", "rotate", "deploy", "list", "revoke"],
|
||||
),
|
||||
|
||||
// CJK Language Support (2)
|
||||
makeSkill(
|
||||
"nihongo-check",
|
||||
"日本語チェッカー",
|
||||
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
|
||||
["NIHONGO_API_KEY"],
|
||||
["check", "translate", "kanji", "grammar", "vocabulary"],
|
||||
),
|
||||
makeSkill(
|
||||
"hangukgeo-helper",
|
||||
"한국어 도우미",
|
||||
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
|
||||
["HANGUL_API_KEY"],
|
||||
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
|
||||
),
|
||||
];
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import type { LlmEvalDimension } from "../lib/securityPrompt";
|
||||
import { publishVersionForUser } from "../skills";
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
@@ -206,7 +207,7 @@ type SkillSecuritySnapshot = {
|
||||
normalizedStatus: NormalizedSecurityStatus;
|
||||
confidence: string | null;
|
||||
summary: string | null;
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
|
||||
dimensions: LlmEvalDimension[] | null;
|
||||
guidance: string | null;
|
||||
findings: string | null;
|
||||
model: string | null;
|
||||
@@ -262,7 +263,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
|
||||
}
|
||||
|
||||
function hasLlmDimensionWarnings(
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
|
||||
dimensions: LlmEvalDimension[] | undefined,
|
||||
) {
|
||||
if (!Array.isArray(dimensions)) return false;
|
||||
return dimensions.some((dimension) => {
|
||||
@@ -968,11 +969,26 @@ async function handleTransferRequest(
|
||||
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
|
||||
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
|
||||
|
||||
// Resolve optional publisher handle to a publisher ID
|
||||
const toPublisherHandleRaw =
|
||||
typeof parsed.payload.toPublisherHandle === "string"
|
||||
? parsed.payload.toPublisherHandle.trim()
|
||||
: "";
|
||||
let toPublisherId: Id<"publishers"> | undefined;
|
||||
if (toPublisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: toPublisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
toPublisherId = publisher._id;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
|
||||
actorUserId: transferContext.userId,
|
||||
skillId: transferContext.skill._id,
|
||||
toUserHandle: toUserHandleRaw,
|
||||
toPublisherId,
|
||||
message,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
@@ -991,30 +1007,68 @@ async function handleTransferDecision(
|
||||
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
|
||||
if (!transferContext.ok) return transferContext.response;
|
||||
|
||||
const pendingTransfer =
|
||||
decision === "cancel"
|
||||
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
fromUserId: transferContext.userId,
|
||||
})
|
||||
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
toUserId: transferContext.userId,
|
||||
});
|
||||
let pendingTransfer;
|
||||
if (decision === "cancel") {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
|
||||
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
// Fallback: allow org admins to cancel transfers initiated by other admins
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Try user-specific lookup first, then fall back to any pending transfer
|
||||
// for the skill (allows org admins other than toUserId to accept/reject)
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
|
||||
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
|
||||
|
||||
const mutation =
|
||||
decision === "accept"
|
||||
? internal.skillTransfers.acceptTransferInternal
|
||||
: decision === "reject"
|
||||
? internal.skillTransfers.rejectTransferInternal
|
||||
: internal.skillTransfers.cancelTransferInternal;
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(mutation, {
|
||||
// For accept, resolve optional publisher handle to forward publisherId
|
||||
let publisherId: Id<"publishers"> | undefined;
|
||||
if (decision === "accept") {
|
||||
const parsed = await parseJsonPayload(request, headers);
|
||||
if (parsed.ok) {
|
||||
const publisherHandleRaw =
|
||||
typeof parsed.payload.publisherHandle === "string"
|
||||
? parsed.payload.publisherHandle.trim()
|
||||
: "";
|
||||
if (publisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: publisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
publisherId = publisher._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
actorUserId: transferContext.userId,
|
||||
transferId: pendingTransfer._id,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await (decision === "accept"
|
||||
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
|
||||
...baseArgs,
|
||||
publisherId,
|
||||
})
|
||||
: decision === "reject"
|
||||
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
|
||||
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
return transferErrorToResponse(error, headers);
|
||||
|
||||
@@ -22,7 +22,10 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
skillId: skill._id,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Skill not found") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,14 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const transfers =
|
||||
const skillTransfers =
|
||||
direction === "incoming"
|
||||
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
|
||||
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
|
||||
|
||||
const transfers = skillTransfers.sort(
|
||||
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
|
||||
);
|
||||
|
||||
return json({ transfers }, 200, rate.headers);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,173 @@ describe("moderationEngine", () => {
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "word-document-organizer",
|
||||
displayName: "Word Document Organizer",
|
||||
summary: "Organize and restyle Word documents",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Generate a Python helper like this:",
|
||||
"```python",
|
||||
'doc_path = "${document_path}"',
|
||||
'output_path = "${output_path}" if "${output_path}" else doc_path',
|
||||
'template = "${style_template}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-docs",
|
||||
displayName: "API Docs",
|
||||
summary: "Shows users how to call an API",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this request template:",
|
||||
"```bash",
|
||||
'curl "https://example.com/search?q=${query}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this payload:",
|
||||
"```json",
|
||||
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
|
||||
).toContain("connection_id");
|
||||
});
|
||||
|
||||
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Call the Sheets bridge like this:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
|
||||
).toContain("spreadsheet ID");
|
||||
});
|
||||
|
||||
it("does not flag placeholder resource identifiers in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use placeholders in public docs:",
|
||||
"```json",
|
||||
'{"connection_id": "YOUR_CONNECTION_ID"}',
|
||||
"```",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Placeholder example first:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
"Real leaked URL later:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("blocks obfuscated terminal install payload prompts in markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "evil-installer",
|
||||
|
||||
@@ -50,6 +50,14 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
|
||||
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
|
||||
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
|
||||
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
|
||||
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
|
||||
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
|
||||
const GENERATED_SOURCE_CONTEXT_PATTERN =
|
||||
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
|
||||
const HARDCODED_CONNECTION_ID_PATTERN =
|
||||
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
|
||||
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
|
||||
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
|
||||
|
||||
function hasMaliciousInstallPrompt(content: string) {
|
||||
const hasTerminalInstruction =
|
||||
@@ -75,6 +83,10 @@ function truncateEvidence(evidence: string, maxLen = 160) {
|
||||
return `${evidence.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
function looksLikePlaceholderIdentifier(identifier: string) {
|
||||
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: ModerationFinding[],
|
||||
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
|
||||
@@ -92,6 +104,14 @@ function findFirstLine(content: string, pattern: RegExp) {
|
||||
return { line: 1, text: lines[0] ?? "" };
|
||||
}
|
||||
|
||||
function findLineAtIndex(content: string, index: number) {
|
||||
const line = content.slice(0, index).split("\n").length;
|
||||
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
||||
const nextNewline = content.indexOf("\n", index);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
return { line, text: content.slice(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
if (!CODE_EXTENSION.test(path)) return;
|
||||
|
||||
@@ -227,6 +247,53 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
|
||||
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
|
||||
) {
|
||||
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "User-controlled placeholder is embedded directly into generated source code.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
|
||||
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete connection_id instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
const spreadsheetUrlPattern = new RegExp(
|
||||
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
|
||||
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
|
||||
);
|
||||
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
|
||||
const spreadsheetId = spreadsheetUrlMatch[1];
|
||||
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
|
||||
|
||||
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
|
||||
@@ -12,11 +12,13 @@ export type ModerationFinding = {
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export const MODERATION_ENGINE_VERSION = "v2.2.0";
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.0";
|
||||
|
||||
export const REASON_CODES = {
|
||||
DANGEROUS_EXEC: "suspicious.dangerous_exec",
|
||||
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
|
||||
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
|
||||
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
|
||||
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
|
||||
EXFILTRATION: "suspicious.potential_exfiltration",
|
||||
OBFUSCATED_CODE: "suspicious.obfuscated_code",
|
||||
|
||||
@@ -5,6 +5,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
|
||||
|
||||
const SHARED_KEYS = [
|
||||
"name",
|
||||
"normalizedName",
|
||||
@@ -22,7 +24,7 @@ const SHARED_KEYS = [
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
|
||||
] as const satisfies readonly SharedPackageKey[];
|
||||
|
||||
const CAPABILITY_SHARED_KEYS = [
|
||||
"packageId",
|
||||
|
||||
@@ -47,4 +47,55 @@ describe("searchText", () => {
|
||||
it("normalize uses lowercase", () => {
|
||||
expect(__test.normalize("AbC")).toBe("abc");
|
||||
});
|
||||
|
||||
// CJK (Chinese, Japanese, Korean) support tests
|
||||
describe("CJK tokenization", () => {
|
||||
it("tokenizes Chinese text using Intl.Segmenter", () => {
|
||||
const tokens = tokenize("中文搜索");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(tokens).toContain("中文");
|
||||
expect(tokens).toContain("搜索");
|
||||
});
|
||||
|
||||
it("tokenizes mixed Chinese and English text", () => {
|
||||
const tokens = tokenize("React 组件开发");
|
||||
expect(tokens).toContain("react");
|
||||
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Chinese query tokens against Chinese skill names", () => {
|
||||
const queryTokens = tokenize("翻译");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches partial Chinese words", () => {
|
||||
const queryTokens = tokenize("助手");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Japanese text", () => {
|
||||
const tokens = tokenize("こんにちは世界");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles Korean text", () => {
|
||||
const tokens = tokenize("안녕하세요");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty or whitespace-only input", () => {
|
||||
expect(tokenize("")).toEqual([]);
|
||||
expect(tokenize(" ")).toEqual([]);
|
||||
expect(tokenize("!!!")).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects CJK language correctly", () => {
|
||||
expect(__test.detectCJKLanguage("中文")).toBe("zh");
|
||||
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
|
||||
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
|
||||
expect(__test.detectCJKLanguage("hello")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-3
@@ -1,12 +1,135 @@
|
||||
const WORD_RE = /[a-z0-9]+/g;
|
||||
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
|
||||
|
||||
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
|
||||
|
||||
let zhSegmenter: Intl.Segmenter | null = null;
|
||||
let jaSegmenter: Intl.Segmenter | null = null;
|
||||
let koSegmenter: Intl.Segmenter | null = null;
|
||||
|
||||
function getZhSegmenter(): Intl.Segmenter {
|
||||
if (!zhSegmenter) {
|
||||
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
|
||||
}
|
||||
return zhSegmenter;
|
||||
}
|
||||
|
||||
function getJaSegmenter(): Intl.Segmenter {
|
||||
if (!jaSegmenter) {
|
||||
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
|
||||
}
|
||||
return jaSegmenter;
|
||||
}
|
||||
|
||||
function getKoSegmenter(): Intl.Segmenter {
|
||||
if (!koSegmenter) {
|
||||
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
|
||||
}
|
||||
return koSegmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: split CJK text into individual characters.
|
||||
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
|
||||
*/
|
||||
function segmentCJKByChar(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (const ch of text) {
|
||||
if (CJK_RE.test(ch)) {
|
||||
tokens.push(ch);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary CJK language in a text
|
||||
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
|
||||
*/
|
||||
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
|
||||
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
|
||||
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
|
||||
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
|
||||
if (hiraganaCount + katakanaCount > 0) {
|
||||
return "ja";
|
||||
}
|
||||
if (hangulCount > 0) {
|
||||
return "ko";
|
||||
}
|
||||
if (chineseCount > 0) {
|
||||
return "zh";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment CJK text using Intl.Segmenter, falling back to character-level
|
||||
* tokenization when the API is unavailable.
|
||||
*/
|
||||
function segmentCJK(text: string): string[] {
|
||||
if (!hasSegmenter) return segmentCJKByChar(text);
|
||||
|
||||
const lang = detectCJKLanguage(text);
|
||||
if (!lang) return [];
|
||||
|
||||
let segmenter: Intl.Segmenter;
|
||||
switch (lang) {
|
||||
case "ja":
|
||||
segmenter = getJaSegmenter();
|
||||
break;
|
||||
case "ko":
|
||||
segmenter = getKoSegmenter();
|
||||
break;
|
||||
default:
|
||||
segmenter = getZhSegmenter();
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const { segment, isWordLike } of segmenter.segment(text)) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed && isWordLike) {
|
||||
segments.push(trimmed);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize text for search, supporting both English and CJK languages
|
||||
*
|
||||
* For English: uses word boundaries (whitespace, punctuation)
|
||||
* For CJK: uses Intl.Segmenter for proper word segmentation
|
||||
*/
|
||||
export function tokenize(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return normalize(value).match(WORD_RE) ?? [];
|
||||
|
||||
const normalized = normalize(value);
|
||||
|
||||
if (!CJK_RE.test(normalized)) {
|
||||
return normalized.match(/[a-z0-9]+/g) ?? [];
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
if (CJK_RE.test(part)) {
|
||||
const cjkTokens = segmentCJK(part);
|
||||
tokens.push(...cjkTokens);
|
||||
} else {
|
||||
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
|
||||
tokens.push(...asciiTokens);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function matchesExactTokens(
|
||||
@@ -24,4 +147,8 @@ export function matchesExactTokens(
|
||||
);
|
||||
}
|
||||
|
||||
export const __test = { normalize, tokenize, matchesExactTokens };
|
||||
export const __test = {
|
||||
normalize,
|
||||
detectCJKLanguage,
|
||||
segmentCJKByChar,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
|
||||
|
||||
/**
|
||||
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
|
||||
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
|
||||
@@ -35,7 +37,7 @@ const SHARED_KEYS = [
|
||||
"moderationReason",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
|
||||
] as const satisfies readonly SharedSkillKey[];
|
||||
|
||||
/** Fields stored in the skillSearchDigest table. */
|
||||
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./transfers";
|
||||
|
||||
describe("transfers", () => {
|
||||
it("TRANSFER_EXPIRY_MS is 7 days", () => {
|
||||
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
describe("isTransferExpired", () => {
|
||||
it("returns true when expiresAt is in the past", () => {
|
||||
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt is in the future", () => {
|
||||
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt equals now", () => {
|
||||
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTransferHandle", () => {
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeTransferHandle(" alice ")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips leading @ and lowercases", () => {
|
||||
expect(normalizeTransferHandle("@Alice")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips multiple leading @ signs", () => {
|
||||
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
|
||||
});
|
||||
|
||||
it("lowercases without @", () => {
|
||||
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferOwnership", () => {
|
||||
it("passes for direct owner (personal, no publisher)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org owner role", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "owner",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-admin org member (publisher role)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects non-owner non-member", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects personal item when actor is not the owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferAcceptPermission", () => {
|
||||
it("passes for personal target when actor is the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects personal target when actor is not the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
|
||||
it("passes for org target when actor is admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects org target when actor is not admin/owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
/** 7 days in milliseconds */
|
||||
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
|
||||
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims whitespace, strips leading `@` characters, and lowercases:
|
||||
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
|
||||
*/
|
||||
export function normalizeTransferHandle(value: string): string {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` has permission to initiate a transfer.
|
||||
*
|
||||
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
|
||||
* actor must be the `ownerUserId`.
|
||||
* - If the item is org-owned: actor must be an admin or owner in that org's
|
||||
* publisherMembers.
|
||||
*
|
||||
* @throws {Error} "Forbidden" on failure
|
||||
*/
|
||||
export async function validateTransferOwnership(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.ownerPublisherId) {
|
||||
// Personally owned — actor must be the owner
|
||||
if (params.actorUserId !== params.ownerUserId) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org-owned — actor must be admin or owner in the org
|
||||
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` can accept a transfer.
|
||||
*
|
||||
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
|
||||
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
|
||||
*
|
||||
* @throws {Error} "No pending transfer found" on failure
|
||||
*/
|
||||
export async function validateTransferAcceptPermission(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
toUserId?: Id<"users"> | null;
|
||||
toPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.toPublisherId) {
|
||||
// Personal target — actor must be the target user
|
||||
if (params.actorUserId !== params.toUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org target — publisher must be active and actor must be admin or owner
|
||||
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
|
||||
.db;
|
||||
const publisher = await db.get(params.toPublisherId);
|
||||
if (!isPublisherActive(publisher)) {
|
||||
throw new Error("Publisher not found");
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
}
|
||||
+23
-11
@@ -4,6 +4,7 @@ import {
|
||||
type PackageChannel,
|
||||
type PackageFamily,
|
||||
type PackagePublishRequest,
|
||||
type PackageVerificationTier,
|
||||
} from "clawhub-schema";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
@@ -127,7 +128,7 @@ type PublicPackageListItem = {
|
||||
latestVersion: string | null;
|
||||
capabilityTags: string[];
|
||||
executesCode: boolean;
|
||||
verificationTier: Doc<"packageSearchDigest">["verificationTier"] | null;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
};
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
@@ -163,6 +164,14 @@ type PublicPageCursorState = {
|
||||
};
|
||||
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
|
||||
|
||||
function stringifyId(value: Id<"users"> | Id<"publishers">): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringifyOptionalId(value: Id<"publishers"> | null | undefined): string | null {
|
||||
return value ? stringifyId(value) : null;
|
||||
}
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
@@ -2133,6 +2142,11 @@ export const insertReleaseInternal = internalMutation({
|
||||
args.channel ??
|
||||
(existing?.channel === "private" ? "private" : publisherTrusted ? "official" : "community");
|
||||
const nextIsOfficial = nextChannel === "official";
|
||||
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
|
||||
const nextOwnerUserId = stringifyId(args.ownerUserId);
|
||||
const nextName = args.name;
|
||||
const nextRuntimeId = args.runtimeId ?? null;
|
||||
const nextVersion = args.version;
|
||||
if (existing) {
|
||||
const existingIsLegacyPersonalPackage =
|
||||
!existing.ownerPublisherId &&
|
||||
@@ -2144,18 +2158,18 @@ export const insertReleaseInternal = internalMutation({
|
||||
const existingOwnerKey = existing.ownerPublisherId
|
||||
? `publisher:${existing.ownerPublisherId}`
|
||||
: existingIsLegacyPersonalPackage
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
: `user:${existing.ownerUserId}`;
|
||||
const nextOwnerKey = args.ownerPublisherId
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
: `user:${args.ownerUserId}`;
|
||||
const nextOwnerKey = nextOwnerPublisherId
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
: `user:${nextOwnerUserId}`;
|
||||
if (existingOwnerKey !== nextOwnerKey) {
|
||||
throw new ConvexError("Package already exists and belongs to another publisher");
|
||||
}
|
||||
}
|
||||
if (existing && existing.family !== args.family) {
|
||||
throw new ConvexError(
|
||||
`Package "${args.name}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2166,7 +2180,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
existing.runtimeId !== args.runtimeId
|
||||
) {
|
||||
throw new ConvexError(
|
||||
`Package "${args.name}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (args.family === "code-plugin" && args.runtimeId) {
|
||||
@@ -2175,9 +2189,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(
|
||||
`Plugin id "${args.runtimeId}" is already claimed by another package`,
|
||||
);
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2214,7 +2226,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${args.version} already exists`);
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
+4
-1
@@ -1286,7 +1286,9 @@ const userSkillRootInstalls = defineTable({
|
||||
const skillOwnershipTransfers = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
fromUserId: v.id("users"),
|
||||
toUserId: v.id("users"),
|
||||
toUserId: v.optional(v.id("users")),
|
||||
fromPublisherId: v.optional(v.id("publishers")),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
status: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
@@ -1303,6 +1305,7 @@ const skillOwnershipTransfers = defineTable({
|
||||
.index("by_from_user", ["fromUserId"])
|
||||
.index("by_to_user", ["toUserId"])
|
||||
.index("by_to_user_status", ["toUserId", "status"])
|
||||
.index("by_to_publisher_status", ["toPublisherId", "status"])
|
||||
.index("by_from_user_status", ["fromUserId", "status"])
|
||||
.index("by_skill_status", ["skillId", "status"]);
|
||||
|
||||
|
||||
@@ -389,4 +389,79 @@ describe("skillTransfers", () => {
|
||||
expect.objectContaining({ ownerUserId: "users:2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal allows org admin to request transfer", async () => {
|
||||
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
|
||||
// even though ownerUserId is users:1
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:org1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org1") {
|
||||
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "users:3",
|
||||
handle: "recipient",
|
||||
displayName: "Recipient",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return { withIndex: () => ({ collect: async () => [] }) };
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:2",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return { withIndex: () => ({ unique: async () => null }) };
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@recipient",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.transferId).toBe("skillOwnershipTransfers:new");
|
||||
});
|
||||
});
|
||||
|
||||
+174
-37
@@ -5,18 +5,16 @@ import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./lib/transfers";
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
|
||||
function normalizeHandle(value: string) {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isExpired(transfer: TransferDoc, now: number) {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
|
||||
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
|
||||
const user = await db.get(userId);
|
||||
@@ -53,7 +51,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
|
||||
|
||||
let active: TransferDoc | null = null;
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) {
|
||||
if (isTransferExpired(transfer, now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: now });
|
||||
continue;
|
||||
}
|
||||
@@ -83,14 +81,26 @@ async function validatePendingTransferForActor(
|
||||
const transfer = await db.get(params.transferId);
|
||||
if (!transfer) throw new Error("Transfer not found");
|
||||
|
||||
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
|
||||
if (
|
||||
params.role === "recipient" &&
|
||||
transfer.toUserId &&
|
||||
transfer.toUserId !== params.actorUserId &&
|
||||
!transfer.toPublisherId
|
||||
) {
|
||||
// For org-targeted transfers (toPublisherId is set), skip this check —
|
||||
// validateTransferAcceptPermission handles org membership validation separately
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
if (params.role === "sender") {
|
||||
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
|
||||
// Personal transfer: actor must be the original sender
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
// Org-owned transfer: actor's org membership is verified by the caller
|
||||
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
|
||||
}
|
||||
if (transfer.status !== "pending") throw new Error("No pending transfer found");
|
||||
if (isExpired(transfer, params.now)) {
|
||||
if (isTransferExpired(transfer, params.now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
|
||||
throw new Error("Transfer has expired");
|
||||
}
|
||||
@@ -102,6 +112,7 @@ export const requestTransferInternal = internalMutation({
|
||||
actorUserId: v.id("users"),
|
||||
skillId: v.id("skills"),
|
||||
toUserHandle: v.string(),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
message: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -110,14 +121,28 @@ export const requestTransferInternal = internalMutation({
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
|
||||
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
|
||||
const toHandle = normalizeTransferHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
if (toUser._id === args.actorUserId && !args.toPublisherId) {
|
||||
throw new Error("Cannot transfer to yourself");
|
||||
}
|
||||
|
||||
if (args.toPublisherId) {
|
||||
const toPublisher = await ctx.db.get(args.toPublisherId);
|
||||
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
|
||||
throw new Error("Target publisher not found");
|
||||
}
|
||||
}
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
if (activePending) throw new Error("A transfer is already pending for this skill");
|
||||
@@ -128,6 +153,8 @@ export const requestTransferInternal = internalMutation({
|
||||
skillId: skill._id,
|
||||
fromUserId: args.actorUserId,
|
||||
toUserId: toUser._id,
|
||||
fromPublisherId: skill.ownerPublisherId,
|
||||
toPublisherId: args.toPublisherId,
|
||||
status: "pending",
|
||||
message: message || undefined,
|
||||
requestedAt: now,
|
||||
@@ -155,6 +182,7 @@ export const acceptTransferInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
transferId: v.id("skillOwnershipTransfers"),
|
||||
publisherId: v.optional(v.id("publishers")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
@@ -167,19 +195,45 @@ export const acceptTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== transfer.fromUserId) {
|
||||
const ownerChanged = transfer.fromPublisherId
|
||||
? skill.ownerPublisherId !== transfer.fromPublisherId
|
||||
: skill.ownerUserId !== transfer.fromUserId;
|
||||
if (ownerChanged) {
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
// Determine target publisher: sender's choice > recipient override > personal
|
||||
// When the sender specified a target publisher, honor it unconditionally
|
||||
let targetPublisherId: Id<"publishers">;
|
||||
if (transfer.toPublisherId) {
|
||||
targetPublisherId = transfer.toPublisherId;
|
||||
} else if (args.publisherId) {
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toPublisherId: args.publisherId,
|
||||
});
|
||||
targetPublisherId = args.publisherId;
|
||||
} else {
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
targetPublisherId = newPublisher._id;
|
||||
}
|
||||
|
||||
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
|
||||
// not necessarily the original toUserId. The toUserHandle on the request just
|
||||
// routes the transfer — actual ownership reflects who acted on it.
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -190,12 +244,16 @@ export const acceptTransferInternal = internalMutation({
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
await ctx.db.patch(transfer._id, {
|
||||
status: "accepted",
|
||||
respondedAt: now,
|
||||
toUserId: args.actorUserId,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -229,6 +287,12 @@ export const rejectTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -259,6 +323,15 @@ export const cancelTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
// For org-owned transfers, always verify actor still has admin/owner role
|
||||
if (transfer.fromPublisherId) {
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: transfer.fromUserId,
|
||||
ownerPublisherId: transfer.fromPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -279,28 +352,63 @@ export const listIncomingInternal = internalQuery({
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.userId);
|
||||
|
||||
const transfers = await ctx.db
|
||||
// Query transfers directed at this user personally
|
||||
const userTransfers = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
|
||||
.collect();
|
||||
|
||||
// Query transfers directed at orgs where this user is an admin or owner
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.collect();
|
||||
const adminPublisherIds = memberships
|
||||
.filter((m) => m.role === "owner" || m.role === "admin")
|
||||
.map((m) => m.publisherId);
|
||||
|
||||
const orgTransferArrays = await Promise.all(
|
||||
adminPublisherIds.map((publisherId) =>
|
||||
ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_publisher_status", (q) =>
|
||||
q.eq("toPublisherId", publisherId).eq("status", "pending"),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
const orgTransfers = orgTransferArrays.flat();
|
||||
|
||||
// Merge and deduplicate by transfer ID
|
||||
const seen = new Set<string>();
|
||||
const allTransfers: TransferDoc[] = [];
|
||||
for (const t of [...userTransfers, ...orgTransfers]) {
|
||||
if (!seen.has(t._id)) {
|
||||
seen.add(t._id);
|
||||
allTransfers.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) continue;
|
||||
for (const transfer of allTransfers) {
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
const fromUser = await ctx.db.get(transfer.fromUserId);
|
||||
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
fromUser: {
|
||||
@@ -308,6 +416,7 @@ export const listIncomingInternal = internalQuery({
|
||||
handle: fromUser.handle ?? null,
|
||||
displayName: fromUser.displayName ?? null,
|
||||
},
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -332,29 +441,41 @@ export const listOutgoingInternal = internalQuery({
|
||||
.collect();
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) continue;
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
const toUser = await ctx.db.get(transfer.toUserId);
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
|
||||
|
||||
let toUser:
|
||||
| { _id: Id<"users">; handle: string | null; displayName: string | null }
|
||||
| undefined;
|
||||
if (transfer.toUserId) {
|
||||
const tu = await ctx.db.get(transfer.toUserId);
|
||||
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
|
||||
toUser = {
|
||||
_id: tu._id,
|
||||
handle: tu.handle ?? null,
|
||||
displayName: tu.displayName ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
toUser: {
|
||||
_id: toUser._id,
|
||||
handle: toUser.handle ?? null,
|
||||
displayName: toUser.displayName ?? null,
|
||||
},
|
||||
toUser,
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -365,6 +486,22 @@ export const listOutgoingInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const transfer = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
|
||||
.first();
|
||||
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
@@ -378,7 +515,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
@@ -396,7 +533,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ vi.mock("@convex-dev/auth/server", () => ({
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
import { getPendingScanSkillsInternal } from "./skills";
|
||||
import {
|
||||
getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
getPendingScanSkillsInternal,
|
||||
} from "./skills";
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string;
|
||||
@@ -25,6 +28,17 @@ const getPendingScanSkillsHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStaticScanBackfillBatchHandler = (
|
||||
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
|
||||
Record<string, unknown>,
|
||||
{
|
||||
skills: Array<{ skillId: string; versionId: string; slug: string }>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.getPendingScanSkillsInternal", () => {
|
||||
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
|
||||
const recentSkills = [
|
||||
@@ -215,6 +229,114 @@ describe("skills.getPendingScanSkillsInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
||||
it("includes latest active skills with missing or stale static scan engine versions", async () => {
|
||||
const skills = [
|
||||
{
|
||||
_id: "skills:missing-static",
|
||||
_creationTime: 10,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:stale-static",
|
||||
_creationTime: 20,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:current-static",
|
||||
_creationTime: 30,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:current-static",
|
||||
slug: "current-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:hidden-static",
|
||||
_creationTime: 40,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
latestVersionId: "skillVersions:hidden-static",
|
||||
slug: "hidden-static",
|
||||
},
|
||||
];
|
||||
|
||||
const versions = new Map<string, unknown>([
|
||||
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
|
||||
[
|
||||
"skillVersions:stale-static",
|
||||
{
|
||||
_id: "skillVersions:stale-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:current-static",
|
||||
{
|
||||
_id: "skillVersions:current-static",
|
||||
staticScan: { engineVersion: "v2.4.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:hidden-static",
|
||||
{
|
||||
_id: "skillVersions:hidden-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ gt: () => ({}) });
|
||||
if (indexName !== "by_creation_time") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => skills,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getStaticScanBackfillBatchHandler(ctx, {
|
||||
batchSize: 10,
|
||||
cursor: 0,
|
||||
});
|
||||
|
||||
expect(result.skills).toEqual([
|
||||
{
|
||||
skillId: "skills:missing-static",
|
||||
versionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
skillId: "skills:stale-static",
|
||||
versionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
]);
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
versionId: string,
|
||||
|
||||
+218
-3
@@ -38,6 +38,7 @@ import { deriveModerationFlags } from "./lib/moderation";
|
||||
import { buildModerationSnapshot } from "./lib/moderationEngine";
|
||||
import {
|
||||
legacyFlagsFromVerdict,
|
||||
MODERATION_ENGINE_VERSION,
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
publishVersionForUser,
|
||||
queueHighlightedWebhook,
|
||||
} from "./lib/skillPublish";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
|
||||
import {
|
||||
@@ -459,7 +461,7 @@ async function syncSkillModerationFromLatestVersion(
|
||||
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
@@ -469,7 +471,7 @@ function buildConflictingSkillUrl(
|
||||
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
@@ -485,7 +487,7 @@ function buildSlugTakenErrorMessage(
|
||||
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
@@ -497,6 +499,16 @@ function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
| {
|
||||
_id: Id<"users"> | Id<"publishers">;
|
||||
handle?: string | null;
|
||||
deletedAt?: number | null;
|
||||
deactivatedAt?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
@@ -3683,6 +3695,56 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active latest skill versions whose static scan is missing or uses an older engine version.
|
||||
* Used to backfill new static rules onto already-published skills.
|
||||
*/
|
||||
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 25;
|
||||
const cursor = args.cursor ?? 0;
|
||||
|
||||
const candidates = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 4);
|
||||
|
||||
const results: Array<{
|
||||
skillId: Id<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
slug: string;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const skill of candidates) {
|
||||
nextCursor = skill._creationTime;
|
||||
if (results.length >= batchSize) break;
|
||||
|
||||
if (skill.softDeletedAt) continue;
|
||||
if ((skill.moderationStatus ?? "active") !== "active") continue;
|
||||
if (!skill.latestVersionId) continue;
|
||||
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version) continue;
|
||||
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
|
||||
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
const done = candidates.length < batchSize * 4;
|
||||
return { skills: results, nextCursor, done };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get skills with stale moderationReason that have vtAnalysis cached.
|
||||
* Used to sync moderationReason with cached VT results.
|
||||
@@ -3781,6 +3843,159 @@ export const getPendingVTSkillsInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
return { ok: true as const, skipped: "not_latest" as const };
|
||||
}
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch: {
|
||||
...basePatch,
|
||||
updatedAt: now,
|
||||
},
|
||||
now,
|
||||
});
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
reason:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
|
||||
"malicious.static_scan",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, status: args.staticScan.status };
|
||||
},
|
||||
});
|
||||
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescanned: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
let rescanned = args.rescanned ?? 0;
|
||||
for (const skill of batch.skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: skill.skillId,
|
||||
versionId: skill.versionId,
|
||||
});
|
||||
rescanned += 1;
|
||||
}
|
||||
|
||||
if (!batch.done) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
|
||||
cursor: batch.nextCursor,
|
||||
batchSize,
|
||||
rescanned,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rescanned,
|
||||
nextCursor: batch.nextCursor,
|
||||
done: batch.done,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertAdmin(user);
|
||||
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
|
||||
batchSize: args.batchSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Emergency escalation by skillId for legacy rows without sha256hash.
|
||||
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
|
||||
|
||||
+3
-1
@@ -34,6 +34,8 @@ export const toggle = mutation({
|
||||
return { starred: false };
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) throw new Error("Skill not found");
|
||||
|
||||
await ctx.db.insert("stars", {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
@@ -70,7 +72,7 @@ export const addStarInternal = internalMutation({
|
||||
args: { userId: v.id("users"), skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
const existing = await ctx.db
|
||||
.query("stars")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
||||
|
||||
@@ -230,8 +230,11 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
let scanned = 0;
|
||||
let patched = 0;
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue;
|
||||
scanned += 1;
|
||||
// Count actual star records for this skill
|
||||
const starRecords = await ctx.db
|
||||
.query("stars")
|
||||
@@ -263,7 +266,7 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: page.length,
|
||||
scanned,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
|
||||
+5
-3
@@ -177,13 +177,15 @@ Stores your API token + cached registry URL.
|
||||
|
||||
### `transfer`
|
||||
|
||||
- Ownership transfer workflow.
|
||||
- Skill ownership transfer workflow.
|
||||
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
|
||||
- Subcommands:
|
||||
- `transfer request <slug> <handle> [--message "..."] [--yes]`
|
||||
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
|
||||
- `transfer list [--outgoing]`
|
||||
- `transfer accept <slug> [--yes]`
|
||||
- `transfer accept <slug> [--publisher @org] [--yes]`
|
||||
- `transfer reject <slug> [--yes]`
|
||||
- `transfer cancel <slug> [--yes]`
|
||||
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
|
||||
- Endpoints:
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
|
||||
+19
-2
@@ -478,16 +478,33 @@ Notes:
|
||||
|
||||
### Transfer ownership endpoints
|
||||
|
||||
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
|
||||
|
||||
#### Skill transfers
|
||||
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
|
||||
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
|
||||
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
|
||||
- `POST /api/v1/skills/{slug}/transfer/reject`
|
||||
- `POST /api/v1/skills/{slug}/transfer/cancel`
|
||||
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
|
||||
|
||||
#### Transfer listing
|
||||
|
||||
- `GET /api/v1/transfers/incoming`
|
||||
- `GET /api/v1/transfers/outgoing`
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
- Returns skill transfers, sorted by `requestedAt` descending.
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
|
||||
#### Transfer rules
|
||||
|
||||
- Pending transfers expire after 7 days.
|
||||
- Only one pending transfer per skill at a time.
|
||||
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
|
||||
- Org transfers require `admin` or `owner` role on the source/target publisher.
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
|
||||
+6
-9
@@ -325,12 +325,9 @@ Semantics:
|
||||
|
||||
## Transfer Model
|
||||
|
||||
Current transfers are user-to-user only. That is too narrow.
|
||||
|
||||
New transfer target should be a publisher.
|
||||
|
||||
Support:
|
||||
Transfers support publisher-based flows for both skills and packages:
|
||||
|
||||
- user publisher -> user publisher
|
||||
- user publisher -> org publisher
|
||||
- org publisher -> user publisher
|
||||
- org publisher -> org publisher
|
||||
@@ -339,12 +336,12 @@ Transfer acceptance rule:
|
||||
|
||||
- actor must have `owner` or `admin` on target publisher
|
||||
|
||||
Audit should record:
|
||||
Audit records:
|
||||
|
||||
- actor user id
|
||||
- source publisher id
|
||||
- target publisher id
|
||||
- resource id
|
||||
- source publisher id (`fromPublisherId`)
|
||||
- target publisher id (`toPublisherId`)
|
||||
- resource id (skill or package)
|
||||
|
||||
## Search Digest Changes
|
||||
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
@@ -98,7 +99,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,10 +503,11 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
|
||||
|
||||
transfer
|
||||
.command("request")
|
||||
.description("Request skill transfer to another user")
|
||||
.description("Request skill transfer to another user or organization")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.argument("<handle>", "Recipient handle (e.g., @username)")
|
||||
.option("--message <text>", "Optional message for recipient")
|
||||
.option("--publisher <handle>", "Target org publisher handle")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -524,8 +525,9 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("accept")
|
||||
.description("Accept incoming transfer for a skill")
|
||||
.description("Accept incoming skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--publisher <handle>", "Accept to org publisher instead of personal")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -534,7 +536,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("reject")
|
||||
.description("Reject incoming transfer for a skill")
|
||||
.description("Reject incoming skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
@@ -544,7 +546,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("cancel")
|
||||
.description("Cancel outgoing transfer for a skill")
|
||||
.description("Cancel outgoing skill transfer")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
|
||||
@@ -64,8 +64,10 @@ describe("transfer commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
|
||||
body?: Record<string, string>;
|
||||
};
|
||||
expect(requestArgs.body?.toUserHandle).toBe("alice");
|
||||
});
|
||||
|
||||
it("list calls incoming transfers endpoint", async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
toHandleArg: string,
|
||||
options: ConfirmOptions & { message?: string },
|
||||
options: ConfirmOptions & { message?: string; publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
|
||||
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -82,16 +82,21 @@ export async function cmdTransferRequest(
|
||||
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string | undefined> = {
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
};
|
||||
if (options.publisher) {
|
||||
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
|
||||
}
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
}),
|
||||
body,
|
||||
},
|
||||
ApiV1TransferRequestResponseSchema,
|
||||
);
|
||||
@@ -150,11 +155,12 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
|
||||
async function runTransferDecision(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
spec: DecisionSpec,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
@@ -167,12 +173,18 @@ async function runTransferDecision(
|
||||
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> | undefined =
|
||||
spec.action === "accept" && options.publisher
|
||||
? { publisherHandle: options.publisher.replace(/^@+/, "") }
|
||||
: undefined;
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
|
||||
token,
|
||||
...(body ? { body } : {}),
|
||||
},
|
||||
ApiV1TransferDecisionResponseSchema,
|
||||
);
|
||||
@@ -188,7 +200,7 @@ async function runTransferDecision(
|
||||
export function cmdTransferAccept(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
|
||||
|
||||
Reference in New Issue
Block a user