Compare commits

...
Author SHA1 Message Date
momothemage edb702bb57 feat: add org support to skill ownership transfers — changelog entry 2026-04-10 17:15:40 +08:00
momothemage 6c8ace3755 fix: clarify org-transfer sender deferral and surface org-targeted incoming transfers 2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 162f4d24f8 fix: honor sender's target publisher on accept, disallow override
When the sender specified a target publisher (toPublisherId), the
acceptor's publisherId arg is now ignored. The publisher override
only applies to user-targeted transfers where the recipient chooses
where to assign ownership.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 3e7deacce8 fix: validate target publisher is active before creating transfer
requestTransferInternal now checks that toPublisherId is not
deleted/deactivated before persisting the transfer. Prevents
creating dead transfers that can never be accepted.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš a90563d9d2 fix: accept empty JSON body on transfer accept and enforce org role on cancel
- Accept handler no longer rejects empty JSON bodies. If parsing fails
  (empty body, no content-type), publisherId stays undefined and the
  transfer proceeds with the default publisher. Fixes clients that
  send Content-Type: application/json with no body.
- Cancel always validates org membership when fromPublisherId is set,
  even for the original requester. Prevents cancelled-after-removal
  where a user who lost org admin role could still cancel.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 74455bb860 fix: update transfer test for object body instead of pre-stringified JSON
The existing test expected body as a JSON string, but apiRequest
already calls JSON.stringify internally — passing a pre-stringified
body causes double-encoding. Updated test to verify the body object
directly, matching the corrected CLI behavior.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš 704d9457aa refactor: scope CLI to skill-only transfers in PR 1
Remove package auto-detection, --type flag, and package-specific
response schema fields. PR 1 only adds skill org support — package
transfer CLI support belongs in PR 2 alongside the backend endpoints.
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš bf7a1d32c9 fix: address review feedback on skill transfer org support
- Add clarifying comment that ownerUserId reflects the accepting admin,
  not the original toUserId, for org-targeted transfers
- Remove redundant .map() type remapping in transfer listing — the
  query already returns type: "skill"
- Fix double JSON encoding in CLI transfer accept body
2026-04-10 17:07:59 +08:00
Tomáš Ďuriš d467ae3d75 feat: add org (publisher) support to skill ownership transfers
Add fromPublisherId/toPublisherId fields to skillOwnershipTransfers schema
and make toUserId optional to support org-to-org, user-to-org, and
org-to-user transfer flows.

- Extract shared transfer helpers into convex/lib/transfers.ts
- Update skillTransfers.ts with org-aware request/accept/reject/cancel
- Add publisher handle resolution and org admin fallback in skillsV1.ts
- Add type:"skill" field to transfer listing responses in transfersV1.ts
- Update CLI with --type and --publisher flags for transfer commands
- Update response schemas, docs (http-api, cli, orgs)
2026-04-10 17:07:59 +08:00
pqdongandmomothemage 5826001795 feat: Add support for Chinese Japanese and Korean(CJK) skills search (#1596)
Merged via squash.

Prepared head SHA: ab58f01be7
Co-authored-by: pq-dong <40668796+pq-dong@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-10 12:20:40 +08:00
Momo 0708a43fde fix: prevent starring soft-deleted skills and fix star count reconciliation (#1605) 2026-04-09 22:16:51 +08:00
Luke 311a123fbe refactor: address actionable review cleanup (#1601) 2026-04-09 20:00:25 +10:00
ImLukeF 59e93862ed fix: restore ci checks after lockfile drift 2026-04-09 19:45:51 +10:00
Luke 8fcd53f899 fix: detect exposed resource identifiers in skill scans (#1598) 2026-04-09 19:32:55 +10:00
Luke ba2c73e180 fix: detect generated-source template injection in skill scans (#1597) 2026-04-09 19:10:39 +10:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fa87dc3509 build(deps-dev): bump vite in the npm_and_yarn group across 1 directory (#1561)
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.0.1 to 8.0.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 08:21:53 -05:00
Val Alexander 9df6fe37c5 Merge pull request #1564 from openclaw/okcodes/fix-lightmode
Add sortable paginated data table components
2026-04-06 20:37:44 -05:00
32 changed files with 1789 additions and 133 deletions
+7
View File
@@ -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
+97 -20
View File
@@ -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=="],
}
}
+6
View File
@@ -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;
+68
View File
@@ -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
\`\`\`
## 使用场景
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
- **组词生成**:输入一个汉字,自动生成常用词语和成语
- **听写练习**:随机生成汉字听写测试,巩固学习效果
## 学习建议
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
`,
},
];
+16
View File
@@ -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>) {
+75 -21
View File
@@ -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);
+4 -1
View File
@@ -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);
}
}
+6 -1
View File
@@ -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);
}
+167
View File
@@ -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",
+67
View File
@@ -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[]) {
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+51
View File
@@ -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
View File
@@ -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,
};
+3 -1
View File
@@ -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]> & {
+309
View File
@@ -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");
});
});
});
+91
View File
@@ -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
View File
@@ -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
View File
@@ -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"]);
+75
View File
@@ -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
View File
@@ -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;
},
});
+123 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+6 -4
View File
@@ -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 () => {
+20 -8
View File
@@ -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);