Compare commits

..
Author SHA1 Message Date
Patrick Erichsen 2e5ffdc565 Merge pull request #1861 from openclaw/pe/rescan
feat: add owner rescan security surfaces
2026-04-28 16:32:26 -07:00
Peter Steinberger 4e13e729fb ci: narrow ClawSweeper dispatch cancellation 2026-04-28 11:53:07 +01:00
Peter Steinberger d17e100cca ci: harden clawsweeper dispatch workflow 2026-04-28 11:35:24 +01:00
Peter Steinberger c732b38569 ci: debounce clawsweeper dispatch metadata 2026-04-28 11:31:51 +01:00
Patrick Erichsen 81ca04662c fix: prevent mobile install copy overlap 2026-04-28 01:20:09 -07:00
Patrick Erichsen ce69ab6a38 fix: polish mobile rescan security surfaces 2026-04-28 01:17:13 -07:00
Patrick Erichsen a28d94c345 feat: show in progress scans 2026-04-28 00:52:51 -07:00
Peter Steinberger 3701733797 fix: normalize vt engine stats before caching 2026-04-28 08:47:50 +01:00
Peter Steinberger b8ba595d06 docs: note vt code insight calibration 2026-04-28 08:40:09 +01:00
Patrick Erichsen ef2846b2e4 feat: add owner rescan security surfaces 2026-04-28 00:39:44 -07:00
Peter Steinberger 232e429dee fix: clear uncorroborated vt suspicious state 2026-04-28 08:39:39 +01:00
Deepak Jain 932155cb8f docs: clarify static scan suppression gate 2026-04-28 08:39:17 +01:00
Deepak Jain d855d09ab0 fix: calibrate vt code insight moderation
Refs #1830
2026-04-28 08:39:17 +01:00
Peter Steinberger 16e87c147d ci: harden ClawSweeper dispatcher credentials 2026-04-28 06:48:40 +01:00
Peter Steinberger cbe22e70b9 ci: fix ClawSweeper dispatcher payload 2026-04-28 06:44:28 +01:00
Peter Steinberger 837331c967 ci: add ClawSweeper event dispatcher 2026-04-28 06:43:39 +01:00
Peter Steinberger 6ce443496d fix: keep package list queries single-page 2026-04-28 06:28:27 +01:00
Peter Steinberger 8fd4f3b051 ci: fix production smoke coverage 2026-04-28 06:18:40 +01:00
Peter Steinberger a2153909da fix: avoid plugin catalog query limits 2026-04-28 06:13:57 +01:00
Deepak Jain 75e1b4633e fix: constrain plugin catalog queries (#1842)
Refs #1699.

Use family-indexed plugin catalog paths instead of broad package scans:
- `/api/v1/plugins` merges separate `code-plugin` and `bundle-plugin` list streams with an endpoint-specific cursor.
- `/api/v1/plugins/search` searches both plugin families directly, dedupes, sorts, and limits results.
- Keeps generic `/api/v1/packages` behavior unchanged.

Validation:
- bunx vitest run convex/httpApiV1.handlers.test.ts convex/packages.public.test.ts
- bunx tsc --noEmit
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bun run lint
- git diff --check
2026-04-28 06:03:15 +01:00
Patrick Erichsen 4cda4a1fa4 Merge branch 'main' of https://github.com/openclaw/clawhub into pe/rescan
# Conflicts:
#	convex/skills.ts
2026-04-27 21:15:31 -07:00
Patrick Erichsen 5fce3ca2f4 Merge pull request #1850 from openclaw/pe/shadcn-ui-primitives
feat: adopt shadcn-managed ui primitives
2026-04-27 20:52:41 -07:00
Patrick Erichsen 87bca06939 feat: adopt shadcn-managed ui primitives 2026-04-27 20:48:15 -07:00
Deepak Jain bf25b38c39 fix: tolerate stale auth in star status (#1843)
Refs #1819.

Read-only star status queries now treat stale, missing, deleted, or deactivated auth users as not starred instead of throwing. Star and unstar mutations still require an active authenticated user.

Validated locally:
- bunx vitest run convex/stars.test.ts convex/lib/access.test.ts
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bunx tsc --noEmit
- git diff --check origin/main...HEAD
2026-04-28 04:23:05 +01:00
Deepak Jain f20dd624a5 fix: flag exposed secrets in skill docs (#1847)
* fix: flag exposed secrets in skill docs

Refs #1760

* fix: harden secret evidence redaction
2026-04-28 03:39:36 +01:00
Patrick Erichsen d3ed4434b9 feat: seed local rescan fixtures 2026-04-27 13:53:14 -07:00
Patrick Erichsen 1c430cc11d feat: add scanner-specific security pages 2026-04-27 13:44:37 -07:00
Patrick Erichsen fb3bcbafb1 feat: show owner flagged inventory on dashboard 2026-04-27 13:27:08 -07:00
Patrick Erichsen e6c3d6ff28 feat: add owner rescan requests 2026-04-27 13:25:59 -07:00
100 changed files with 9048 additions and 2558 deletions
@@ -0,0 +1,62 @@
name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
steps:
- name: Debounce bursty metadata events
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
- name: Dispatch exact ClawSweeper review
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
SOURCE_EVENT: ${{ github.event_name }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
--arg source_event "$SOURCE_EVENT" \
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
+1 -1
View File
@@ -163,7 +163,7 @@ jobs:
- name: Install Playwright browser
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
run: bunx playwright install --with-deps chromium
run: bunx playwright install --with-deps chromium webkit
- name: Write authenticated storage state
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
### Fixes
- Moderation: calibrate VirusTotal Code Insight suspicious verdicts so uncorroborated AI-only findings do not keep otherwise clean skills quarantined (#1830, #1841) (thanks @deepujain).
## 0.11.0 - 2026-04-28
### Changed
+29 -81
View File
@@ -13,10 +13,15 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@@ -38,6 +43,7 @@
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.34.1",
"convex-helpers": "^0.1.114",
"fflate": "^0.8.2",
@@ -473,15 +479,19 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
@@ -495,19 +505,25 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
@@ -841,6 +857,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -1519,76 +1537,30 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
@@ -1621,6 +1593,8 @@
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1644,31 +1618,5 @@
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+8
View File
@@ -95,10 +95,14 @@ import type * as lib_userSkillStats from "../lib/userSkillStats.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 model_packages_rescans from "../model/packages/rescans.js";
import type * as model_rescans_policy from "../model/rescans/policy.js";
import type * as model_skills_rescans from "../model/skills/rescans.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";
import type * as rescanRequests from "../rescanRequests.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
@@ -212,10 +216,14 @@ declare const fullApi: ApiFromModules<{
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
"model/packages/rescans": typeof model_packages_rescans;
"model/rescans/policy": typeof model_rescans_policy;
"model/skills/rescans": typeof model_skills_rescans;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
publishers: typeof publishers;
rateLimits: typeof rateLimits;
rescanRequests: typeof rescanRequests;
search: typeof search;
seed: typeof seed;
seedSouls: typeof seedSouls;
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import { seedRescanUxFixturesHandler } from "./devSeed";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const list = (table: string) => {
tables[table] ??= [];
return tables[table];
};
const db = {
get: async (id: string) => {
const table = id.split(":")[0] ?? "";
return list(table).find((doc) => doc._id === id) ?? null;
},
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
_id: `${table}:${counters[table]}`,
_creationTime: counters[table],
...doc,
};
list(table).push(inserted);
return inserted._id;
},
patch: async (id: string, patch: Record<string, unknown>) => {
const table = id.split(":")[0] ?? "";
const doc = list(table).find((candidate) => candidate._id === id);
if (doc) Object.assign(doc, patch);
},
delete: async (id: string) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows.splice(index, 1);
},
query: (table: string) => ({
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
collect: async () => matched(),
unique: async () => matched()[0] ?? null,
order: () => ({
collect: async () => matched(),
}),
};
},
}),
};
return { db, tables };
}
describe("devSeed rescan UX fixtures", () => {
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
const { db, tables } = createDb();
const args = {
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
};
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
expect(tables.publishers).toHaveLength(1);
expect(tables.skills).toHaveLength(1);
expect(tables.skills?.[0]).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
moderationStatus: "hidden",
moderationVerdict: "malicious",
}),
);
expect(tables.packages).toHaveLength(2);
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "malicious",
}),
);
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "suspicious",
}),
);
const scannedPackage = tables.packages?.find(
(pkg) => pkg.name === "local-scanned-runtime-plugin",
);
const scannedRelease = tables.packageReleases?.find(
(release) => release.packageId === scannedPackage?._id,
);
expect(scannedRelease).toEqual(
expect.objectContaining({
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: expect.objectContaining({ status: "clean" }),
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
const skillRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
const pluginRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
expect(skillRequests).toHaveLength(1);
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
});
});
+778 -21
View File
@@ -1,9 +1,15 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { ActionCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalMutation as rawInternalMutation } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
import { generateToken, hashToken } from "./lib/tokens";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
type SeedSkillSpec = {
slug: string;
@@ -25,6 +31,37 @@ type SeedActionResult = {
type SeedMutationResult = Record<string, unknown>;
const LOCAL_SEED_HANDLE = "local";
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
const SCANNED_PLUGIN_NAME = "local-scanned-runtime-plugin";
const FLAGGED_SKILL_MD = `---
name: local-flagged-wallet-sync
description: Local dev fixture for flagged dashboard and rescan UI.
---
# Local Flagged Wallet Sync
This seeded skill is intentionally flagged so local development can exercise owner-only recovery
flows, dashboard unavailable states, and rescan request limits.
`;
const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
This seeded plugin is intentionally flagged so local development can exercise plugin owner
inventory and cap-exhausted rescan UI.
`;
const SCANNED_PLUGIN_README = `# Local Scanned Runtime Plugin
This seeded plugin is public and intentionally has completed scan results so local development can
preview plugin scanner detail pages without owner-only visibility.
`;
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
role: "admin" | "user";
};
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: "padel",
@@ -345,6 +382,26 @@ async function seedNixSkillsHandler(
results.push({ slug: spec.slug, ...result });
}
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
await Promise.all([
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
]);
const fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
{
reset: args.reset,
flaggedSkillStorageId,
flaggedSkillMd: FLAGGED_SKILL_MD,
flaggedPluginStorageId,
flaggedPluginReadme: FLAGGED_PLUGIN_README,
scannedPluginStorageId,
scannedPluginReadme: SCANNED_PLUGIN_README,
},
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
return { ok: true, results };
}
@@ -388,6 +445,724 @@ export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction(
handler: seedPadelSkillHandler,
});
async function ensureLocalSeedOwner(ctx: MutationCtx) {
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", LOCAL_SEED_HANDLE))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: LOCAL_SEED_HANDLE,
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const user = await ctx.db.get(userId);
if (!user) throw new Error("Local seed user was not created");
const publisher = await ensurePersonalPublisherForUser(ctx, user);
if (!publisher) throw new Error("Local seed publisher was not created");
return { userId, publisherId: publisher._id };
}
async function deleteRescanRequestsForSkillVersion(ctx: MutationCtx, versionId: unknown) {
if (!versionId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version", (q) =>
q.eq("targetKind", "skill").eq("skillVersionId", versionId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteRescanRequestsForPackageRelease(ctx: MutationCtx, releaseId: unknown) {
if (!releaseId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_package_release", (q) =>
q.eq("targetKind", "plugin").eq("packageReleaseId", releaseId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteSeedSkillFixture(ctx: MutationCtx) {
const existing = await findSeedSkillFixture(ctx);
if (!existing) return;
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const version of versions) {
await deleteRescanRequestsForSkillVersion(ctx, version._id);
await ctx.db.delete(version._id);
}
const embeddings = await ctx.db
.query("skillEmbeddings")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const embedding of embeddings) {
const maps = await ctx.db
.query("embeddingSkillMap")
.withIndex("by_embedding", (q) => q.eq("embeddingId", embedding._id))
.collect();
for (const map of maps) await ctx.db.delete(map._id);
await ctx.db.delete(embedding._id);
}
await ctx.db.delete(existing._id);
}
async function findSeedSkillFixture(ctx: MutationCtx) {
return await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", FLAGGED_SKILL_SLUG))
.unique();
}
async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
const existing = await findSeedPluginFixtureByName(ctx, name);
if (!existing) return;
const releases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
.collect();
for (const release of releases) {
await deleteRescanRequestsForPackageRelease(ctx, release._id);
await ctx.db.delete(release._id);
}
await ctx.db.delete(existing._id);
}
async function deleteSeedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function deleteScannedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
async function findSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
return await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(name)))
.unique();
}
async function findSeedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function findScannedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
reasonCodes: ["malicious.local_dev_fixture"],
findings: [
{
code: "malicious.local_dev_fixture",
severity: "critical" as const,
file: "SKILL.md",
line: 1,
message: "Local dev fixture intentionally flagged for owner recovery testing.",
evidence: "seeded fixture",
},
],
summary: "Local dev fixture intentionally flagged as malicious.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
function staticSuspiciousScan(now: number) {
return {
status: "suspicious" as const,
reasonCodes: ["suspicious.local_dev_fixture"],
findings: [
{
code: "suspicious.local_dev_fixture",
severity: "warn" as const,
file: "README.md",
line: 3,
message: "Local dev fixture exercises scanner evidence UI for a public plugin.",
evidence: "runtime plugin requests local tool execution",
},
],
summary: "Local dev fixture completed static analysis with a suspicious finding.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
async function insertCompletedRescanRequests(
ctx: MutationCtx,
params:
| {
targetKind: "skill";
skillId: unknown;
skillVersionId: unknown;
packageId?: never;
packageReleaseId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
}
| {
targetKind: "plugin";
packageId: unknown;
packageReleaseId: unknown;
skillId?: never;
skillVersionId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
},
) {
for (let index = 0; index < params.count; index += 1) {
const createdAt = params.now - (params.count - index) * 60_000;
await ctx.db.insert("rescanRequests", {
targetKind: params.targetKind,
skillId: params.skillId as never,
skillVersionId: params.skillVersionId as never,
packageId: params.packageId as never,
packageReleaseId: params.packageReleaseId as never,
targetVersion: params.targetVersion,
requestedByUserId: params.ownerUserId as never,
ownerUserId: params.ownerUserId as never,
ownerPublisherId: params.ownerPublisherId as never,
status: "completed",
createdAt,
updatedAt: createdAt + 30_000,
completedAt: createdAt + 30_000,
});
}
}
type SeedRescanUxFixturesArgs = {
reset?: boolean;
flaggedSkillStorageId: Id<"_storage">;
flaggedSkillMd: string;
flaggedPluginStorageId: Id<"_storage">;
flaggedPluginReadme: string;
scannedPluginStorageId: Id<"_storage">;
scannedPluginReadme: string;
};
export async function seedRescanUxFixturesHandler(
ctx: MutationCtx,
args: SeedRescanUxFixturesArgs,
) {
const existingSkill = await findSeedSkillFixture(ctx);
const existingPlugin = await findSeedPluginFixture(ctx);
const existingScannedPlugin = await findScannedPluginFixture(ctx);
if (existingSkill && existingPlugin && existingScannedPlugin && !args.reset) {
return {
ok: true,
skipped: true,
ownerUserId: existingSkill.ownerUserId,
ownerPublisherId: existingSkill.ownerPublisherId ?? existingPlugin.ownerPublisherId,
flaggedSkillId: existingSkill._id,
flaggedSkillVersionId: existingSkill.latestVersionId,
flaggedPluginId: existingPlugin._id,
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
scannedPluginId: existingScannedPlugin._id,
scannedPluginReleaseId: existingScannedPlugin.latestReleaseId,
};
}
await deleteSeedSkillFixture(ctx);
await deleteSeedPluginFixture(ctx);
await deleteScannedPluginFixture(ctx);
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const staticScan = staticMaliciousScan(now);
const scannedStaticScan = staticSuspiciousScan(now);
const skillId = await ctx.db.insert("skills", {
slug: FLAGGED_SKILL_SLUG,
displayName: "Local Flagged Wallet Sync",
summary: "Seeded flagged skill for local owner inventory and rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
moderationStatus: "hidden",
moderationReason: "scanner.static.malicious",
moderationVerdict: "malicious",
moderationReasonCodes: ["malicious.local_dev_fixture"],
moderationEvidence: staticScan.findings,
moderationSummary: staticScan.summary,
moderationEngineVersion: staticScan.engineVersion,
moderationEvaluatedAt: now,
moderationFlags: ["blocked.malware"],
isSuspicious: true,
statsDownloads: 4,
statsStars: 1,
statsInstallsCurrent: 0,
statsInstallsAllTime: 2,
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 0,
comments: 0,
},
createdAt: now,
updatedAt: now,
});
const skillVersionId = await ctx.db.insert("skillVersions", {
skillId,
version: "0.1.0",
changelog: "Seeded flagged local version for rescan UI testing.",
files: [
{
path: "SKILL.md",
size: args.flaggedSkillMd.length,
storageId: args.flaggedSkillStorageId,
sha256: "seeded-flagged-skill",
contentType: "text/markdown",
},
],
parsed: {
frontmatter: {
name: FLAGGED_SKILL_SLUG,
description: "Local dev fixture for flagged dashboard and rescan UI.",
},
},
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
sha256hash: "seeded-flagged-skill-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
});
await ctx.db.patch(skillId, {
latestVersionId: skillVersionId,
moderationSourceVersionId: skillVersionId,
tags: { latest: skillVersionId },
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 1,
comments: 0,
},
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "skill",
skillId,
skillVersionId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: 1,
now,
});
const packageId = await ctx.db.insert("packages", {
name: FLAGGED_PLUGIN_NAME,
normalizedName: normalizePackageName(FLAGGED_PLUGIN_NAME),
displayName: "Local Flagged Runtime Plugin",
summary: "Seeded flagged plugin for local owner inventory and cap-exhausted rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.flagged.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
scanStatus: "malicious",
stats: { downloads: 2, installs: 0, stars: 0, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const packageReleaseId = await ctx.db.insert("packageReleases", {
packageId,
version: "0.1.0",
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
summary: "Seeded flagged plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.flaggedPluginReadme.length,
storageId: args.flaggedPluginStorageId,
sha256: "seeded-flagged-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-flagged-plugin-integrity",
extractedPackageJson: {
name: FLAGGED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
sha256hash: "seeded-flagged-plugin-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(packageId, {
latestReleaseId: packageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
},
tags: { latest: packageReleaseId },
stats: { downloads: 2, installs: 0, stars: 0, versions: 1 },
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "plugin",
packageId,
packageReleaseId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
now,
});
const scannedPackageId = await ctx.db.insert("packages", {
name: SCANNED_PLUGIN_NAME,
normalizedName: normalizePackageName(SCANNED_PLUGIN_NAME),
displayName: "Local Scanned Runtime Plugin",
summary: "Seeded public plugin with completed security scans for scanner page previews.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.scanned.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools", "security"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
scanStatus: "suspicious",
stats: { downloads: 7, installs: 1, stars: 1, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const scannedPackageReleaseId = await ctx.db.insert("packageReleases", {
packageId: scannedPackageId,
version: "0.1.0",
changelog: "Seeded public scanned release for plugin scanner page previews.",
summary: "Seeded scanned plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.scannedPluginReadme.length,
storageId: args.scannedPluginStorageId,
sha256: "seeded-scanned-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-scanned-plugin-integrity",
extractedPackageJson: {
name: SCANNED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: {
status: "clean",
verdict: "clean",
analysis: "Local dev fixture scanned clean by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "medium",
summary: "Local dev fixture flagged for review because it executes local tools.",
dimensions: [
{
name: "execution",
label: "Local execution",
rating: "concern",
detail: "Runtime plugin executes local tooling and should be reviewed before install.",
},
],
guidance: "Review the runtime command surface before trusting this plugin.",
findings: "The fixture is intentionally safe, but models a plugin with reviewable behavior.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan: scannedStaticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(scannedPackageId, {
latestReleaseId: scannedPackageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded public scanned release for plugin scanner page previews.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
},
tags: { latest: scannedPackageReleaseId },
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 5,
totalStars: 1,
totalDownloads: 4,
updatedAt: now,
});
return {
ok: true,
ownerUserId: userId,
ownerPublisherId: publisherId,
flaggedSkillId: skillId,
flaggedSkillVersionId: skillVersionId,
flaggedPluginId: packageId,
flaggedPluginReleaseId: packageReleaseId,
scannedPluginId: scannedPackageId,
scannedPluginReleaseId: scannedPackageReleaseId,
};
}
export const seedRescanUxFixturesMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
flaggedSkillStorageId: v.id("_storage"),
flaggedSkillMd: v.string(),
flaggedPluginStorageId: v.id("_storage"),
flaggedPluginReadme: v.string(),
scannedPluginStorageId: v.id("_storage"),
scannedPluginReadme: v.string(),
},
handler: seedRescanUxFixturesHandler,
});
export const seedCliRoleHelpFixtures = rawInternalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const admin = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-admin",
displayName: "CLI Admin",
role: "admin",
});
const user = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-user",
displayName: "CLI User",
role: "user",
});
const adminToken = await replaceRoleHelpFixtureToken(ctx, admin._id, now);
const userToken = await replaceRoleHelpFixtureToken(ctx, user._id, now);
return {
ok: true,
admin: { handle: admin.handle, role: admin.role, token: adminToken },
user: { handle: user.handle, role: user.role, token: userToken },
};
},
});
async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixtureUser) {
const now = Date.now();
const existing = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", user.handle))
.unique();
const patch = {
handle: user.handle,
displayName: user.displayName,
role: user.role,
deletedAt: undefined,
deactivatedAt: undefined,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return { ...existing, ...patch };
}
const userId = await ctx.db.insert("users", {
...patch,
createdAt: now,
});
const created = await ctx.db.get(userId);
if (!created) throw new Error(`Failed to create ${user.handle}`);
return created;
}
async function replaceRoleHelpFixtureToken(
ctx: MutationCtx,
userId: Id<"users">,
now: number,
) {
const existingTokens = await ctx.db
.query("apiTokens")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
for (const token of existingTokens) {
if (token.label === "CLI role help e2e") {
await ctx.db.patch(token._id, { revokedAt: now });
}
}
const { token, prefix } = generateToken();
await ctx.db.insert("apiTokens", {
userId,
label: "CLI role help e2e",
prefix,
tokenHash: await hashToken(token),
createdAt: now,
lastUsedAt: undefined,
revokedAt: undefined,
});
return token;
}
export const seedSkillMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
@@ -430,26 +1205,14 @@ export const seedSkillMutation = internalMutation({
}
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", "local"))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: "local",
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const skillId = await ctx.db.insert("skills", {
slug: args.slug,
displayName: args.displayName,
summary: args.summary,
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
@@ -469,12 +1232,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: args.version,
+342 -7
View File
@@ -55,6 +55,26 @@ function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
}
function makeCatalogItem(
name: string,
options: {
family: "code-plugin" | "bundle-plugin" | "skill";
updatedAt: number;
score?: number;
},
) {
return {
name,
displayName: name,
family: options.family,
channel: "community",
isOfficial: false,
createdAt: options.updatedAt,
updatedAt: options.updatedAt,
...(typeof options.score === "number" ? { score: options.score } : {}),
};
}
function makeCtx(partial: Record<string, unknown>) {
const partialRunQuery =
typeof partial.runQuery === "function"
@@ -2114,6 +2134,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.handle).toBe("p");
expect(json.user.role).toBeNull();
});
it("delete and undelete require auth", async () => {
@@ -2162,6 +2183,86 @@ describe("httpApiV1 handlers", () => {
expect(response2.status).toBe(200);
});
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "skill",
name: args.slug,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "skill",
name: "demo",
remainingRequests: 2,
maxRequests: 3,
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
);
});
it("package rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "package",
name: args.name,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "package",
name: "@scope/demo",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
);
});
it("transfer request requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2499,7 +2600,11 @@ describe("httpApiV1 handlers", () => {
});
it("packages search forwards executesCode and capabilityTag", async () => {
const runQuery = vi.fn().mockResolvedValue([]);
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
@@ -2511,10 +2616,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "test",
limit: 5,
executesCode: true,
capabilityTag: "tools",
paginationOpts: { cursor: null, numItems: 50 },
}),
);
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
@@ -2542,6 +2646,123 @@ describe("httpApiV1 handlers", () => {
);
});
it("plugins list defaults to plugin package families", async () => {
const codePlugin = {
name: "code-plugin",
displayName: "Code Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 20,
updatedAt: 200,
};
const bundlePlugin = {
name: "bundle-plugin",
displayName: "Bundle Plugin",
family: "bundle-plugin",
channel: "community",
isOfficial: false,
createdAt: 10,
updatedAt: 100,
};
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin") {
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=7"),
);
expect(response.status).toBe(200);
expect((await response.json()).items.map((entry: { name: string }) => entry.name)).toEqual([
"code-plugin",
"bundle-plugin",
]);
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
for (const [, args] of runQuery.mock.calls) {
expect(args).toEqual(
expect.objectContaining({
paginationOpts: { cursor: null, numItems: 7 },
}),
);
}
});
it("plugins list paginates with separate plugin family cursors", async () => {
const codeNewest = makeCatalogItem("code-newest", {
family: "code-plugin",
updatedAt: 300,
});
const codeOlder = makeCatalogItem("code-older", {
family: "code-plugin",
updatedAt: 100,
});
const bundleMiddle = makeCatalogItem("bundle-middle", {
family: "bundle-plugin",
updatedAt: 200,
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
const pagination = args.paginationOpts as { cursor: string | null };
if (args.family === "code-plugin" && pagination.cursor === null) {
return { page: [codeNewest], isDone: false, continueCursor: "code-cursor" };
}
if (args.family === "code-plugin" && pagination.cursor === "code-cursor") {
return { page: [codeOlder], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin" && pagination.cursor === null) {
return { page: [bundleMiddle], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected args ${JSON.stringify(args)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const firstResponse = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=1"),
);
expect(firstResponse.status).toBe(200);
const firstJson = await firstResponse.json();
expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual(["code-newest"]);
expect(firstJson.nextCursor).toMatch(/^pkgplugins:/);
const secondUrl = new URL("https://example.com/api/v1/plugins");
secondUrl.searchParams.set("limit", "1");
secondUrl.searchParams.set("cursor", firstJson.nextCursor);
const secondResponse = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(secondUrl),
);
expect(secondResponse.status).toBe(200);
const secondJson = await secondResponse.json();
expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([
"bundle-middle",
]);
const packageCalls = runQuery.mock.calls
.map(([, args]) => args as { family?: string; paginationOpts?: { cursor: string | null } })
.filter((args) => args.family === "code-plugin" || args.family === "bundle-plugin");
expect(
packageCalls.map((args) => ({
family: args.family,
cursor: args.paginationOpts?.cursor ?? null,
})),
).toEqual([
{ family: "code-plugin", cursor: null },
{ family: "bundle-plugin", cursor: null },
{ family: "code-plugin", cursor: "code-cursor" },
{ family: "bundle-plugin", cursor: null },
]);
});
it("packages search supports family=skill on the generic route", async () => {
const runQuery = vi.fn().mockResolvedValue([]);
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2560,6 +2781,114 @@ describe("httpApiV1 handlers", () => {
);
});
it("plugins search defaults to plugin package families", async () => {
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return {
page: [
{
name: "weather-code",
displayName: "Weather Code",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 10,
updatedAt: 100,
},
],
isDone: true,
continueCursor: "",
};
}
if (args.family === "bundle-plugin") {
return {
page: [
{
name: "weather-bundle",
displayName: "Weather Bundle",
family: "bundle-plugin",
channel: "community",
isOfficial: false,
createdAt: 20,
updatedAt: 200,
},
],
isDone: true,
continueCursor: "",
};
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/search?q=weather&limit=7"),
);
expect(response.status).toBe(200);
expect(
(await response.json()).results.map(
(entry: { package: { name: string } }) => entry.package.name,
),
).toEqual(["weather-bundle", "weather-code"]);
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
for (const [, args] of runQuery.mock.calls) {
expect(args).toEqual(
expect.objectContaining({
paginationOpts: { cursor: null, numItems: 50 },
}),
);
}
});
it("plugins search dedupes and sorts results from both plugin families", async () => {
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return {
page: [
makeCatalogItem("shared-plugin", { family: "code-plugin", updatedAt: 100 }),
makeCatalogItem("plugin-code", { family: "code-plugin", updatedAt: 50 }),
],
isDone: true,
continueCursor: "",
};
}
if (args.family === "bundle-plugin") {
return {
page: [
makeCatalogItem("plugin-bundle", { family: "bundle-plugin", updatedAt: 80 }),
makeCatalogItem("shared-plugin", { family: "bundle-plugin", updatedAt: 60 }),
],
isDone: true,
continueCursor: "",
};
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/search?q=plugin&limit=3"),
);
expect(response.status).toBe(200);
expect(
(await response.json()).results.map(
(entry: { score: number; package: { family: string; name: string } }) => ({
family: entry.package.family,
name: entry.package.name,
}),
),
).toEqual([
{ family: "bundle-plugin", name: "plugin-bundle" },
{ family: "code-plugin", name: "plugin-code" },
{ family: "code-plugin", name: "shared-plugin" },
]);
});
it("packages list forwards viewerUserId for authenticated private package browsing", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
@@ -2583,7 +2912,12 @@ describe("httpApiV1 handlers", () => {
it("packages search forwards viewerUserId for authenticated private package search", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const runQuery = vi.fn().mockResolvedValue([]);
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if ("userId" in args) return { _id: args.userId };
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
@@ -2595,9 +2929,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "secret",
channel: "private",
viewerUserId: "users:owner",
paginationOpts: { cursor: null, numItems: 50 },
}),
);
});
@@ -2629,7 +2963,8 @@ describe("httpApiV1 handlers", () => {
if (query === internal.users.getByIdInternal) {
throw new Error("Table mismatch");
}
if ("query" in args && args.query === "secret") return [];
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2647,9 +2982,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "secret",
channel: "community",
viewerUserId: undefined,
paginationOpts: { cursor: null, numItems: 50 },
}),
);
});
+285 -24
View File
@@ -1,4 +1,3 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import {
PackagePublishRequestSchema,
PackageTrustedPublisherUpsertRequestSchema,
@@ -9,6 +8,7 @@ import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import {
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
@@ -62,6 +62,7 @@ const internalRefs = internal as unknown as {
getReleaseByPackageAndVersionInternal: unknown;
getReleaseByIdInternal: unknown;
insertAuditLogInternal: unknown;
requestRescanForApiTokenInternal: unknown;
softDeletePackageInternal: unknown;
};
packagePublishTokens: {
@@ -90,12 +91,8 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
if (apiTokenUserId) return apiTokenUserId;
try {
const userId = (await getAuthUserId(ctx)) ?? null;
const userId = (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
if (!userId) return null;
const user = await runQueryRef<Doc<"users"> | null>(ctx, internal.users.getByIdInternal, {
userId,
});
if (!user || user.deletedAt || user.deactivatedAt) return null;
return userId;
} catch {
// Public package reads should degrade to anonymous when cookie-backed auth is stale.
@@ -254,6 +251,11 @@ type UnifiedCatalogCursorState = {
skills: CatalogSourceCursorState;
};
type PluginCatalogCursorState = {
codePlugins: CatalogSourceCursorState;
bundlePlugins: CatalogSourceCursorState;
};
type CatalogPageResult = {
page: CatalogListItem[];
isDone: boolean;
@@ -268,6 +270,7 @@ type CatalogSourceState = {
};
const UNIFIED_CATALOG_CURSOR_PREFIX = "pkgcatalog:";
const PLUGIN_CATALOG_CURSOR_PREFIX = "pkgplugins:";
function defaultCatalogSourceCursorState(): CatalogSourceCursorState {
return { cursor: null, offset: 0, pageSize: null, done: false };
@@ -308,6 +311,42 @@ function decodeUnifiedCatalogCursor(raw: string | null | undefined): UnifiedCata
}
}
function encodePluginCatalogCursor(state: PluginCatalogCursorState) {
return `${PLUGIN_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`;
}
function decodePluginCatalogCursor(raw: string | null | undefined): PluginCatalogCursorState {
const normalize = (
input: Partial<CatalogSourceCursorState> | undefined,
): CatalogSourceCursorState => ({
cursor: typeof input?.cursor === "string" ? input.cursor : null,
offset: typeof input?.offset === "number" && input.offset > 0 ? input.offset : 0,
pageSize: typeof input?.pageSize === "number" && input.pageSize > 0 ? input.pageSize : null,
done: input?.done === true,
});
if (!raw?.startsWith(PLUGIN_CATALOG_CURSOR_PREFIX)) {
return {
codePlugins: { ...defaultCatalogSourceCursorState(), cursor: raw ?? null },
bundlePlugins: defaultCatalogSourceCursorState(),
};
}
try {
const parsed = JSON.parse(
raw.slice(PLUGIN_CATALOG_CURSOR_PREFIX.length),
) as Partial<PluginCatalogCursorState>;
return {
codePlugins: normalize(parsed.codePlugins),
bundlePlugins: normalize(parsed.bundlePlugins),
};
} catch {
return {
codePlugins: defaultCatalogSourceCursorState(),
bundlePlugins: defaultCatalogSourceCursorState(),
};
}
}
function initCatalogSource(state: CatalogSourceCursorState): CatalogSourceState {
return {
state: { ...state },
@@ -324,7 +363,7 @@ function finalizeCatalogSource(source: CatalogSourceState): CatalogSourceCursorS
cursor: source.pageCursor,
offset: source.index,
pageSize: source.state.pageSize,
done: source.page.isDone,
done: false,
};
}
return {
@@ -372,6 +411,100 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
return a.name.localeCompare(b.name);
}
const HTTP_PACKAGE_SEARCH_PAGE_SIZE = 50;
const HTTP_PACKAGE_SEARCH_SCAN_PAGES = 20;
function catalogSearchScore(item: CatalogListItem, queryText: string) {
const needle = queryText.toLowerCase();
const name = item.name.toLowerCase();
const display = item.displayName.toLowerCase();
const runtimeId = item.runtimeId?.toLowerCase() ?? "";
const summary = (item.summary ?? "").toLowerCase();
let score = 0;
if (name === needle) score += 200;
else if (name.startsWith(needle)) score += 120;
else if (name.includes(needle)) score += 80;
if (display === needle) score += 150;
else if (display.startsWith(needle)) score += 70;
else if (display.includes(needle)) score += 40;
if (runtimeId === needle) score += 180;
else if (runtimeId.startsWith(needle)) score += 90;
else if (runtimeId.includes(needle)) score += 45;
if (summary.includes(needle)) score += 20;
if ((item.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
score += 12;
}
if (item.isOfficial) score += 5;
return score;
}
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
return (
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
compareCatalogItems(a.package, b.package)
);
}
async function searchPackageCatalogByListing(
ctx: ActionCtx,
args: {
query: string;
limit: number;
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
},
): Promise<CatalogSearchEntry[]> {
const queryText = args.query.trim().toLowerCase();
if (!queryText) return [];
const matches: CatalogSearchEntry[] = [];
const seen = new Set<string>();
let cursor: string | null = null;
let done = false;
let loops = 0;
while (!done && loops < HTTP_PACKAGE_SEARCH_SCAN_PAGES) {
loops += 1;
const result: {
page: CatalogListItem[];
isDone: boolean;
continueCursor: string | null;
} = await runQueryRef(ctx, internalRefs.packages.listPageForViewerInternal, {
family: args.family,
channel: args.channel,
isOfficial: args.isOfficial,
executesCode: args.executesCode,
capabilityTag: args.capabilityTag,
viewerUserId: args.viewerUserId,
paginationOpts: { cursor, numItems: HTTP_PACKAGE_SEARCH_PAGE_SIZE },
});
for (const item of result.page) {
const key = `${item.family}:${item.name}`;
if (seen.has(key)) continue;
const score = catalogSearchScore(item, queryText);
if (score <= 0) continue;
seen.add(key);
matches.push({ score, package: item });
}
done = result.isDone;
cursor = result.continueCursor;
if (!cursor && !done) break;
}
return matches.sort(compareCatalogSearchEntries).slice(0, args.limit);
}
async function resolveSkillTags(
ctx: ActionCtx,
tags: Record<string, Id<"skillVersions">>,
@@ -504,7 +637,7 @@ async function listPackages(
ctx: ActionCtx,
request: Request,
family?: PackageListQueryArgs["family"],
options?: { includeSkills?: boolean },
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
@@ -631,6 +764,84 @@ async function listPackages(
);
}
if (!effectiveFamily && options?.pluginFamilies?.length) {
const decodedCursor = decodePluginCatalogCursor(cursor);
const codePluginSource = initCatalogSource(decodedCursor.codePlugins);
const bundlePluginSource = initCatalogSource(decodedCursor.bundlePlugins);
const pageSize = limit;
const items: CatalogListItem[] = [];
const fetchPluginPage = async (
pluginFamily: "code-plugin" | "bundle-plugin",
pageCursor: string | null,
numItems: number,
) => {
const result = await runQueryRef<{
page: CatalogListItem[];
isDone: boolean;
continueCursor: string | null;
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
family: pluginFamily,
channel,
isOfficial,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: pageCursor, numItems },
});
return {
page: result.page,
isDone: result.isDone,
continueCursor: result.continueCursor ?? "",
};
};
while (items.length < limit) {
const [codePluginCandidate, bundlePluginCandidate] = await Promise.all([
options.pluginFamilies.includes("code-plugin")
? ensureCatalogSourcePage(codePluginSource, pageSize, (pageCursor, numItems) =>
fetchPluginPage("code-plugin", pageCursor, numItems),
)
: Promise.resolve(null),
options.pluginFamilies.includes("bundle-plugin")
? ensureCatalogSourcePage(bundlePluginSource, pageSize, (pageCursor, numItems) =>
fetchPluginPage("bundle-plugin", pageCursor, numItems),
)
: Promise.resolve(null),
]);
if (!codePluginCandidate && !bundlePluginCandidate) break;
if (
!bundlePluginCandidate ||
(codePluginCandidate &&
compareCatalogItems(codePluginCandidate, bundlePluginCandidate) <= 0)
) {
items.push(codePluginCandidate!);
codePluginSource.index += 1;
} else {
items.push(bundlePluginCandidate);
bundlePluginSource.index += 1;
}
}
const nextState = {
codePlugins: finalizeCatalogSource(codePluginSource),
bundlePlugins: finalizeCatalogSource(bundlePluginSource),
};
const isDoneAll =
nextState.codePlugins.done &&
nextState.codePlugins.offset === 0 &&
nextState.bundlePlugins.done &&
nextState.bundlePlugins.offset === 0;
return json(
{
items,
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
},
200,
rate.headers,
);
}
const result = await runQueryRef<{
page: unknown[];
isDone: boolean;
@@ -656,7 +867,10 @@ export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
}
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
return await listPackages(ctx, request, undefined, { includeSkills: false });
return await listPackages(ctx, request, undefined, {
includeSkills: false,
pluginFamilies: ["code-plugin", "bundle-plugin"],
});
}
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
@@ -823,6 +1037,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[1] === "rescan" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.packages.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
name: segments[0]!,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404);
}
@@ -1028,7 +1267,7 @@ async function getSkillVersionForRequest(
async function searchPackages(
ctx: ActionCtx,
request: Request,
options?: { includeSkills?: boolean },
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
@@ -1071,10 +1310,34 @@ async function searchPackages(
},
);
} else if (family || !includeSkills) {
results = await runQueryRef<CatalogSearchEntry[]>(
ctx,
internalRefs.packages.searchForViewerInternal,
{
if (!family && options?.pluginFamilies?.length) {
const pluginResults = await Promise.all(
options.pluginFamilies.map((pluginFamily) =>
searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
family: pluginFamily,
channel,
isOfficial,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
}),
),
);
const seen = new Set<string>();
results = pluginResults
.flat()
.filter((entry) => {
const key = `${entry.package.family}:${entry.package.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort(compareCatalogSearchEntries)
.slice(0, limit);
} else {
results = await searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
family,
@@ -1083,11 +1346,11 @@ async function searchPackages(
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
},
);
});
}
} else {
const [packageResults, skillResults] = await Promise.all([
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
channel,
@@ -1113,12 +1376,7 @@ async function searchPackages(
seen.add(key);
return true;
})
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
compareCatalogItems(a.package, b.package),
)
.sort(compareCatalogSearchEntries)
.slice(0, limit);
}
return json({ results }, 200, rate.headers);
@@ -1428,7 +1686,10 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request
const segments = getPathSegments(request, "/api/v1/plugins/");
if (segments.length === 0) return text("Not found", 404);
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
return await searchPackages(ctx, request, { includeSkills: false });
return await searchPackages(ctx, request, {
includeSkills: false,
pluginFamilies: ["code-plugin", "bundle-plugin"],
});
}
return text("Not found", 404);
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
@@ -231,6 +231,16 @@ type SkillSecuritySnapshot = {
};
};
const internalRefs = internal as unknown as {
skills: {
requestRescanForApiTokenInternal: unknown;
};
};
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
function isDefinitiveSecurityStatus(
status: NormalizedSecurityStatus | null | undefined,
): status is "clean" | "suspicious" | "malicious" {
@@ -1165,6 +1175,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
}
}
if (segments.length === 2 && action === "rescan") {
if (!slug) return text("Slug required", 400, rate.headers);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.skills.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
slug,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (action === "transfer") {
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
}
+1
View File
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
},
200,
+62 -6
View File
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
export type Role = "admin" | "moderator" | "user";
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
function readEnv(name: string) {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function isDevImpersonationAllowed() {
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
return (
deployment.startsWith("anonymous:") ||
deployment.startsWith("dev:") ||
deployment.startsWith("local:") ||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
);
}
async function getDevImpersonatedUserId(
ctx: Pick<MutationCtx | QueryCtx, "db">,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
.unique();
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
async function getDevImpersonatedUserIdFromAction(
ctx: ActionCtx,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
});
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
export async function getOptionalActiveAuthUserId(
ctx: MutationCtx | QueryCtx,
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserId(ctx);
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserId(ctx);
}
}
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserIdFromAction(ctx);
}
}
export async function requireUser(ctx: MutationCtx | QueryCtx) {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserId(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
export async function requireUserFromAction(
ctx: ActionCtx,
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
+107
View File
@@ -23,6 +23,57 @@ describe("moderationEngine", () => {
expect(result.status).toBe("clean");
});
it("flags hardcoded API secrets in skill documentation and redacts every evidence copy", () => {
const exposedSecret = "ak_live_1234567890abcdefSECRET";
const result = runStaticModerationScan({
slug: "seo-admin",
displayName: "SEO Admin",
summary: "Manage production SEO content",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 256 }],
fileContents: [
{
path: "SKILL.md",
content: [
"# SEO Admin",
"Production endpoint: https://example.com/admin/api",
`API secret: ${exposedSecret} # rotate ${exposedSecret}`,
].join("\n"),
},
],
});
expect(result.reasonCodes).toContain("suspicious.exposed_secret_literal");
expect(result.status).toBe("suspicious");
expect(result.findings[0]?.evidence).toContain("[REDACTED]");
expect(result.findings[0]?.evidence).not.toContain(exposedSecret);
});
it("does not flag placeholder or env-var secret examples", () => {
const result = runStaticModerationScan({
slug: "demo",
displayName: "Demo",
summary: "A normal integration skill",
frontmatter: {},
metadata: {},
files: [{ path: "SKILL.md", size: 128 }],
fileContents: [
{
path: "SKILL.md",
content: [
"Set `API secret: your-secret-here` before running the sample.",
"const api_key = process.env.PROVIDER_API_KEY;",
"api_secret = os.environ['PROVIDER_API_SECRET']",
].join("\n"),
},
],
});
expect(result.reasonCodes).not.toContain("suspicious.exposed_secret_literal");
expect(result.status).toBe("clean");
});
it("flags dynamic eval usage as suspicious", () => {
const result = runStaticModerationScan({
slug: "demo",
@@ -527,4 +578,60 @@ describe("moderationEngine", () => {
expect(snapshot.reasonCodes).toContain("suspicious.env_credential_access");
expect(snapshot.reasonCodes).toContain("suspicious.vt_suspicious");
});
it("does not let uncorroborated VT Code Insight suspicious override clean local scans", () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "",
engineVersion: "v2.1.1",
checkedAt: Date.now(),
},
vtAnalysis: {
status: "suspicious",
scanner: "code_insight",
source: "VirusTotal Code Insight",
engineStats: {
malicious: 0,
suspicious: 0,
harmless: 12,
undetected: 54,
},
},
llmStatus: "clean",
});
expect(snapshot.verdict).toBe("clean");
expect(snapshot.reasonCodes).toEqual([]);
});
it("keeps VT Code Insight suspicious when AV engines also report suspicious", () => {
const snapshot = buildModerationSnapshot({
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "",
engineVersion: "v2.1.1",
checkedAt: Date.now(),
},
vtAnalysis: {
status: "suspicious",
scanner: "code_insight",
source: "VirusTotal Code Insight",
engineStats: {
malicious: 0,
suspicious: 1,
harmless: 12,
undetected: 53,
},
},
llmStatus: "clean",
});
expect(snapshot.verdict).toBe("suspicious");
expect(snapshot.reasonCodes).toContain("suspicious.vt_suspicious");
});
});
+99 -3
View File
@@ -13,6 +13,22 @@ import {
} from "./moderationReasonCodes";
type TextFile = { path: string; content: string };
type VirusTotalEngineStats = {
malicious?: number;
suspicious?: number;
undetected?: number;
harmless?: number;
};
type VirusTotalAnalysis = {
status?: string;
scanner?: string;
source?: string;
engineStats?: VirusTotalEngineStats;
metadata?: {
stats?: VirusTotalEngineStats;
};
};
export type StaticScanInput = {
slug: string;
@@ -58,6 +74,10 @@ 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;
const SECRET_ASSIGNMENT_PATTERN =
/\b(?:api[_\s-]?(?:secret|key)|secret[_\s-]?key|access[_\s-]?token|auth[_\s-]?token|bearer[_\s-]?token|password)\b\s*[:=]\s*["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
const AUTH_HEADER_SECRET_PATTERN =
/\b(?:authorization|x-api-key|x-api-secret)\b\s*[:=]\s*(?:Bearer\s+)?["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
function hasMaliciousInstallPrompt(content: string) {
const hasTerminalInstruction =
@@ -87,6 +107,31 @@ function looksLikePlaceholderIdentifier(identifier: string) {
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
}
function looksLikePlaceholderSecret(secret: string) {
const normalized = secret.trim().toLowerCase();
if (!normalized) return true;
if (/^(?:x+|_+|-+|\*+|\.{3})$/.test(normalized)) return true;
if (/process\.env\.|os\.environ[.[]|getenv\s*\(/.test(normalized)) return true;
return /(your|example|placeholder|change-?me|replace|redacted|dummy|sample|test-token|token-here|secret-here|api-key-here)/i.test(
normalized,
);
}
function findHardcodedSecret(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(SECRET_ASSIGNMENT_PATTERN) ?? line.match(AUTH_HEADER_SECRET_PATTERN);
const secret = match?.[1];
if (!secret || looksLikePlaceholderSecret(secret)) continue;
return {
line: i + 1,
text: line.replaceAll(secret, "[REDACTED]"),
};
}
return null;
}
function addFinding(
findings: ModerationFinding[],
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
@@ -297,6 +342,18 @@ function scanCodeFile(
function scanMarkdownFile(path: string, content: string, findings: ModerationFinding[]) {
if (!MARKDOWN_EXTENSION.test(path)) return;
const secretMatch = findHardcodedSecret(content);
if (secretMatch) {
addFinding(findings, {
code: REASON_CODES.EXPOSED_SECRET_LITERAL,
severity: "critical",
file: path,
line: secretMatch.line,
message: "Documentation appears to expose a hardcoded API secret or token.",
evidence: secretMatch.text,
});
}
if (hasMaliciousInstallPrompt(content)) {
const match = findFirstLine(
content,
@@ -412,11 +469,42 @@ function dedupeEvidence(evidence: ModerationFinding[]) {
return out.slice(0, 40);
}
function addScannerStatusReason(reasonCodes: string[], scanner: "vt" | "llm", status?: string) {
function isStaticScanClean(staticScan: StaticScanResult | undefined) {
// Older moderation records can predate static scan persistence; absence means
// there are no static findings available to corroborate an external signal.
return !staticScan || staticScan.reasonCodes.length === 0 || staticScan.status === "clean";
}
function isAvEngineStatsClean(stats: VirusTotalEngineStats | undefined) {
if (!stats) return false;
return (stats.malicious ?? 0) === 0 && (stats.suspicious ?? 0) === 0;
}
function getVtEngineStats(analysis: VirusTotalAnalysis | undefined) {
return analysis?.engineStats ?? analysis?.metadata?.stats;
}
function isUncorroboratedVtCodeInsightSuspicious(params: {
vtAnalysis?: VirusTotalAnalysis;
staticScan?: StaticScanResult;
llmStatus?: string;
}) {
if (params.vtAnalysis?.scanner !== "code_insight") return false;
if (!isExternalScannerClean(params.llmStatus)) return false;
if (!isStaticScanClean(params.staticScan)) return false;
return isAvEngineStatsClean(getVtEngineStats(params.vtAnalysis));
}
function addScannerStatusReason(
reasonCodes: string[],
scanner: "vt" | "llm",
status?: string,
options: { suppressSuspicious?: boolean } = {},
) {
const normalized = status?.trim().toLowerCase();
if (normalized === "malicious") {
reasonCodes.push(`malicious.${scanner}_malicious`);
} else if (normalized === "suspicious") {
} else if (normalized === "suspicious" && !options.suppressSuspicious) {
reasonCodes.push(`suspicious.${scanner}_suspicious`);
}
}
@@ -493,6 +581,7 @@ function isExternalScannerClean(status: string | undefined): boolean {
export function buildModerationSnapshot(params: {
staticScan?: StaticScanResult;
vtAnalysis?: VirusTotalAnalysis;
vtStatus?: string;
llmStatus?: string;
sourceVersionId?: Id<"skillVersions">;
@@ -510,7 +599,14 @@ export function buildModerationSnapshot(params: {
}
const reasonCodes = [...staticCodes];
addScannerStatusReason(reasonCodes, "vt", params.vtStatus);
const vtStatus = params.vtStatus ?? params.vtAnalysis?.status;
addScannerStatusReason(reasonCodes, "vt", vtStatus, {
suppressSuspicious: isUncorroboratedVtCodeInsightSuspicious({
vtAnalysis: params.vtAnalysis,
staticScan: params.staticScan,
llmStatus: params.llmStatus,
}),
});
addScannerStatusReason(reasonCodes, "llm", params.llmStatus);
const normalizedCodes = normalizeReasonCodes(reasonCodes);
+2 -1
View File
@@ -12,13 +12,14 @@ export type ModerationFinding = {
evidence: string;
};
export const MODERATION_ENGINE_VERSION = "v2.4.1";
export const MODERATION_ENGINE_VERSION = "v2.4.2";
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",
EXPOSED_SECRET_LITERAL: "suspicious.exposed_secret_literal",
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
EXFILTRATION: "suspicious.potential_exfiltration",
OBFUSCATED_CODE: "suspicious.obfuscated_code",
+28
View File
@@ -77,6 +77,34 @@ export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRo
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
}
export type OwnedResourceActor = {
_id: Id<"users">;
role?: Doc<"users">["role"];
};
export async function assertCanManageOwnedResource(
ctx: DbCtx,
params: {
actor: OwnedResourceActor;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
allowedPublisherRoles?: PublisherRole[];
allowPlatformAdmin?: boolean;
},
) {
if (params.allowPlatformAdmin && params.actor.role === "admin") return;
if (params.ownerUserId === params.actor._id) return;
if (!params.ownerPublisherId) throw new ConvexError("Forbidden");
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actor._id);
if (
!membership ||
!isPublisherRoleAllowed(membership.role, params.allowedPublisherRoles ?? ["admin"])
) {
throw new ConvexError("Forbidden");
}
}
export async function getPublisherByHandle(ctx: DbCtx, handle: string | undefined | null) {
const normalized = normalizePublisherHandle(handle);
if (!normalized) return null;
+41
View File
@@ -0,0 +1,41 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
import type { OwnedResourceActor } from "../../lib/publishers";
export async function getLatestPackageRescanTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
packageId: Id<"packages">,
) {
const pkg = await ctx.db.get(packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
if (!pkg.latestReleaseId) throw new ConvexError("Plugin has no published release");
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt) throw new ConvexError("Latest plugin release not found");
return { pkg, release };
}
export async function insertPackageRescanRequest(
ctx: Pick<MutationCtx, "db">,
actor: OwnedResourceActor,
target: {
pkg: Doc<"packages">;
release: Doc<"packageReleases">;
},
) {
const now = Date.now();
return await ctx.db.insert("rescanRequests", {
targetKind: "plugin",
packageId: target.pkg._id,
packageReleaseId: target.release._id,
targetVersion: target.release.version,
requestedByUserId: actor._id,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
status: "in_progress",
createdAt: now,
updatedAt: now,
});
}
+204
View File
@@ -0,0 +1,204 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
export const MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE = 3;
const ACTIVE_RESCAN_STATUS = "in_progress" as const;
const NON_TERMINAL_SCAN_STATUSES = new Set(["loading", "not_found", "pending"]);
const FAILED_SCAN_STATUSES = new Set(["error", "failed", "stale"]);
export type RescanTarget =
| {
kind: "skill";
artifactId: Id<"skillVersions">;
}
| {
kind: "plugin";
artifactId: Id<"packageReleases">;
};
export function serializeRescanRequest(request: Doc<"rescanRequests"> | null) {
if (!request) return null;
return {
_id: request._id,
targetKind: request.targetKind,
targetVersion: request.targetVersion,
requestedByUserId: request.requestedByUserId,
status: request.status,
error: request.error,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
completedAt: request.completedAt,
};
}
type ScanSignal = {
status: string;
checkedAt: number;
};
export type RescanScanState = {
staticScan?: ScanSignal;
vtAnalysis?: ScanSignal;
llmAnalysis?: ScanSignal;
};
function freshTerminalSignal(signal: ScanSignal | undefined, requestedAt: number) {
if (!signal || signal.checkedAt < requestedAt) return null;
const status = signal.status.trim().toLowerCase();
if (NON_TERMINAL_SCAN_STATUSES.has(status)) return null;
return status;
}
function terminalRequestStatusForScanState(
scanState: RescanScanState,
requestedAt: number,
): "completed" | "failed" | null {
const statuses = [
freshTerminalSignal(scanState.staticScan, requestedAt),
freshTerminalSignal(scanState.vtAnalysis, requestedAt),
freshTerminalSignal(scanState.llmAnalysis, requestedAt),
];
if (statuses.some((status) => status === null)) return null;
if (statuses.some((status) => FAILED_SCAN_STATUSES.has(status!))) return "failed";
return "completed";
}
export async function listRequestsForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version", (q) =>
q.eq("targetKind", "skill").eq("skillVersionId", target.artifactId),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release", (q) =>
q.eq("targetKind", "plugin").eq("packageReleaseId", target.artifactId),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
}
export async function getInProgressRequestForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version_status", (q) =>
q
.eq("targetKind", "skill")
.eq("skillVersionId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.first();
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release_status", (q) =>
q
.eq("targetKind", "plugin")
.eq("packageReleaseId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.first();
}
export async function assertCanRequestRescan(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
const existingInProgress = await getInProgressRequestForTarget(ctx, target);
if (existingInProgress) {
throw new ConvexError("A rescan request is already in progress for this release");
}
const existingRequests = await listRequestsForTarget(ctx, target);
if (existingRequests.length >= MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE) {
throw new ConvexError(
`Rescan request limit reached for this release (${MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE})`,
);
}
}
export async function buildRescanState(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
const requests = await listRequestsForTarget(ctx, target);
const inProgressRequest =
requests.find((request) => request.status === ACTIVE_RESCAN_STATUS) ?? null;
const requestCount = Math.min(requests.length, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
return {
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
requestCount,
remainingRequests: Math.max(0, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE - requestCount),
canRequest:
requestCount < MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE && inProgressRequest === null,
inProgressRequest: serializeRescanRequest(inProgressRequest),
latestRequest: serializeRescanRequest(requests[0] ?? null),
};
}
async function listInProgressRequestsForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version_status", (q) =>
q
.eq("targetKind", "skill")
.eq("skillVersionId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release_status", (q) =>
q
.eq("targetKind", "plugin")
.eq("packageReleaseId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
}
export async function finalizeInProgressRescanRequestsForTarget(
ctx: Pick<MutationCtx, "db">,
target: RescanTarget,
scanState: RescanScanState,
) {
const requests = await listInProgressRequestsForTarget(ctx, target);
const now = Date.now();
for (const request of requests) {
const status = terminalRequestStatusForScanState(scanState, request.createdAt);
if (!status) continue;
await ctx.db.patch(request._id, {
status,
updatedAt: now,
completedAt: now,
});
}
}
export function errorMessage(error: unknown) {
return error instanceof Error ? error.message.slice(0, 500) : "Unknown rescan dispatch error";
}
+39
View File
@@ -0,0 +1,39 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
import type { OwnedResourceActor } from "../../lib/publishers";
export async function getLatestSkillRescanTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
skillId: Id<"skills">,
) {
const skill = await ctx.db.get(skillId);
if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found");
if (!skill.latestVersionId) throw new ConvexError("Skill has no published version");
const version = await ctx.db.get(skill.latestVersionId);
if (!version || version.softDeletedAt) throw new ConvexError("Latest skill version not found");
return { skill, version };
}
export async function insertSkillRescanRequest(
ctx: Pick<MutationCtx, "db">,
actor: OwnedResourceActor,
target: {
skill: Doc<"skills">;
version: Doc<"skillVersions">;
},
) {
const now = Date.now();
return await ctx.db.insert("rescanRequests", {
targetKind: "skill",
skillId: target.skill._id,
skillVersionId: target.version._id,
targetVersion: target.version.version,
requestedByUserId: actor._id,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
status: "in_progress",
createdAt: now,
updatedAt: now,
});
}
+31 -31
View File
@@ -956,36 +956,11 @@ describe("packages public queries", () => {
expect(result.page.map((entry) => entry.name)).toEqual(["official-demo"]);
});
it("keeps scanning official-only listings without a family filter", async () => {
const { ctx } = makeDigestCtx({
it("uses the official index for official-only listings without a family filter", async () => {
const { ctx, indexNames, paginate } = makeDigestCtx({
pages: [
{
page: [makeDigest("noise-1", { isOfficial: false })],
isDone: false,
continueCursor: "cursor:1",
},
{
page: [makeDigest("noise-2", { isOfficial: false })],
isDone: false,
continueCursor: "cursor:2",
},
{
page: [makeDigest("noise-3", { isOfficial: false })],
isDone: false,
continueCursor: "cursor:3",
},
{
page: [makeDigest("noise-4", { isOfficial: false })],
isDone: false,
continueCursor: "cursor:4",
},
{
page: [makeDigest("noise-5", { isOfficial: false })],
isDone: false,
continueCursor: "cursor:5",
},
{
page: [makeDigest("official-late", { isOfficial: true, updatedAt: 10 })],
page: [makeDigest("official-late", { isOfficial: true })],
isDone: true,
continueCursor: "",
},
@@ -998,6 +973,8 @@ describe("packages public queries", () => {
});
expect(result.page.map((entry) => entry.name)).toEqual(["official-late"]);
expect(indexNames).toEqual(["by_active_official_updated"]);
expect(paginate).toHaveBeenCalledTimes(1);
});
it("filters private packages and capability flags in public search", async () => {
@@ -1375,7 +1352,7 @@ describe("packages public queries", () => {
expect(ctx.db.query).not.toHaveBeenCalledWith("publisherMembers");
});
it("caps public list scans below the Convex read limit budget", async () => {
it("keeps public list pages to one paginated query per invocation", async () => {
const { ctx, paginate } = makeDigestCtx({
pages: Array.from({ length: 120 }, (_, index) => ({
page: [makeDigest(`noise-${index}`, { executesCode: false })],
@@ -1390,7 +1367,10 @@ describe("packages public queries", () => {
});
expect(result.page).toEqual([]);
expect(paginate).toHaveBeenCalledTimes(100);
expect(result.isDone).toBe(false);
expect(result.continueCursor).toBeTruthy();
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 100 });
});
it("caps public search scans below the Convex read limit budget", async () => {
@@ -2614,6 +2594,15 @@ describe("packages public queries", () => {
})),
};
}
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
@@ -2879,7 +2868,18 @@ describe("package scan backfill", () => {
if (id === "packages:demo") return pkg;
return null;
}),
query: vi.fn(),
query: vi.fn((table: string) => {
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn(async () => []),
})),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
+329 -99
View File
@@ -11,11 +11,19 @@ import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { action, internalAction, internalMutation, internalQuery, query } from "./functions";
import {
action,
internalAction,
internalMutation,
internalQuery,
mutation,
query,
} from "./functions";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
requireUserFromAction,
} from "./lib/access";
import { requireGitHubAccountAge } from "./lib/githubAccount";
@@ -33,7 +41,11 @@ import {
} from "./lib/packageRegistry";
import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity";
import { toPublicPublisher } from "./lib/public";
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
import {
assertCanManageOwnedResource,
getOwnerPublisher,
getPublisherMembership,
} from "./lib/publishers";
import {
findOversizedPublishFile,
getPublishFileSizeError,
@@ -43,14 +55,35 @@ import {
import { tokenize } from "./lib/searchText";
import { hashSkillFiles } from "./lib/skills";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getLatestPackageRescanTarget, insertPackageRescanRequest } from "./model/packages/rescans";
import {
assertCanRequestRescan,
buildRescanState,
errorMessage,
finalizeInProgressRescanRequestsForTarget,
} from "./model/rescans/policy";
const MAX_PACKAGE_SCAN_DOCUMENTS = 30_000;
const MAX_PUBLIC_LIST_SCAN_PAGES = 200;
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
const MAX_SEARCH_PAGE_SIZE = 200;
const MAX_SEARCH_SCAN_DOCUMENTS = 1_000;
const MAX_SEARCH_SCAN_PAGES = 20;
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
const vtEngineStatsValidator = v.object({
malicious: v.optional(v.number()),
suspicious: v.optional(v.number()),
undetected: v.optional(v.number()),
harmless: v.optional(v.number()),
});
const vtAnalysisValidator = v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
scanner: v.optional(v.string()),
engineStats: v.optional(vtEngineStatsValidator),
checkedAt: v.number(),
});
const internalRefs = internal as unknown as {
llmEval: {
evaluatePackageReleaseWithLlm: unknown;
@@ -76,6 +109,9 @@ const internalRefs = internal as unknown as {
getByIdInternal: unknown;
revokeInternal: unknown;
};
rescanRequests: {
markStatusInternal: unknown;
};
skills: {
getSkillBySlugInternal: unknown;
};
@@ -246,6 +282,7 @@ type DashboardPackageListItem = {
createdAt: number;
updatedAt: number;
pendingReview?: true;
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestRelease: {
version: string;
createdAt: number;
@@ -416,6 +453,13 @@ async function toDashboardPackageListItem(
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
rescanState:
latestRelease && !latestRelease.softDeletedAt
? await buildRescanState(ctx, {
kind: "plugin",
artifactId: latestRelease._id,
})
: null,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? {
@@ -936,12 +980,12 @@ async function requireTrustedPublisherEditor(
pkg: Doc<"packages">,
actorUserId: Id<"users">,
) {
if (pkg.ownerUserId === actorUserId) return;
if (!pkg.ownerPublisherId) throw new ConvexError("Forbidden");
const membership = await getPublisherMembership(ctx, pkg.ownerPublisherId, actorUserId);
if (!membership || membership.role === "publisher") {
throw new ConvexError("Forbidden");
}
await assertCanManageOwnedResource(ctx, {
actor: { _id: actorUserId },
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
allowPlatformAdmin: false,
});
}
export const getByName = query({
@@ -1164,90 +1208,84 @@ async function listPackagePageImpl(
const targetCount = args.paginationOpts.numItems;
const collected: PublicPackageListItem[] = [];
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
let cursor = decodedCursor.cursor;
let offset = decodedCursor.offset;
let pageSize = decodedCursor.pageSize;
let done = decodedCursor.done;
let loops = 0;
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
if (decodedCursor.done && decodedCursor.offset === 0) {
return { page: collected, isDone: true, continueCursor: "" };
}
const pageCursor = decodedCursor.cursor;
const offset = decodedCursor.offset;
const effectivePageSize = Math.min(
MAX_PUBLIC_LIST_PAGE_SIZE,
Math.max(
targetCount,
decodedCursor.pageSize ?? 0,
offset > 0 ? offset + targetCount : targetCount,
),
);
const family = args.family;
const channel = args.channel;
const isOfficial = args.isOfficial;
while (
(offset > 0 || !done) &&
collected.length < targetCount &&
loops < MAX_PUBLIC_LIST_SCAN_PAGES &&
remainingScanBudget > 0
) {
loops += 1;
const effectivePageSize = Math.min(
remainingScanBudget,
offset > 0 && pageSize
? Math.max(pageSize, offset + 1)
: Math.max(targetCount * 3, targetCount),
);
if (effectivePageSize <= 0) break;
remainingScanBudget -= effectivePageSize;
const pageCursor = cursor;
const builder = args.capabilityTag
? buildPackageCapabilityDigestQuery(ctx, {
capabilityTag: args.capabilityTag,
family,
channel,
isOfficial,
executesCode: args.executesCode,
})
: buildPackageDigestQuery(ctx, {
family,
channel,
isOfficial,
executesCode: args.executesCode,
});
const page: {
page: PackageDigestLike[];
isDone: boolean;
continueCursor: string;
} = await builder.order("desc").paginate({ cursor: pageCursor, numItems: effectivePageSize });
for (let index = offset; index < page.page.length; index += 1) {
const digest = page.page[index] as PackageDigestLike;
if (!(await canViewPackage(digest))) continue;
if (channel && digest.channel !== channel) continue;
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
continue;
}
if (!digestMatchesFilters(digest, args)) continue;
collected.push(toPublicPackageListItem(digest));
if (collected.length >= targetCount) {
const nextOffset = index + 1;
if (nextOffset < page.page.length) {
cursor = pageCursor;
offset = nextOffset;
pageSize = effectivePageSize;
done = page.isDone;
} else {
cursor = page.continueCursor;
offset = 0;
pageSize = effectivePageSize;
done = page.isDone;
}
return {
page: collected,
isDone: done && offset === 0,
continueCursor: encodePublicPageCursor({ cursor, offset, pageSize, done }),
};
}
const builder = args.capabilityTag
? buildPackageCapabilityDigestQuery(ctx, {
capabilityTag: args.capabilityTag,
family,
channel,
isOfficial,
executesCode: args.executesCode,
})
: buildPackageDigestQuery(ctx, {
family,
channel,
isOfficial,
executesCode: args.executesCode,
});
const page: {
page: PackageDigestLike[];
isDone: boolean;
continueCursor: string;
} = await builder.order("desc").paginate({ cursor: pageCursor, numItems: effectivePageSize });
for (let index = offset; index < page.page.length; index += 1) {
const digest = page.page[index] as PackageDigestLike;
if (!(await canViewPackage(digest))) continue;
if (channel && digest.channel !== channel) continue;
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
continue;
}
if (!digestMatchesFilters(digest, args)) continue;
collected.push(toPublicPackageListItem(digest));
if (collected.length >= targetCount) {
const nextOffset = index + 1;
const nextState =
nextOffset < page.page.length
? {
cursor: pageCursor,
offset: nextOffset,
pageSize: effectivePageSize,
done: page.isDone,
}
: {
cursor: page.continueCursor,
offset: 0,
pageSize: effectivePageSize,
done: page.isDone,
};
return {
page: collected,
isDone: nextState.done && nextState.offset === 0,
continueCursor: encodePublicPageCursor(nextState),
};
}
done = page.isDone;
cursor = page.continueCursor;
offset = 0;
pageSize = effectivePageSize;
}
return {
page: collected,
isDone: done,
continueCursor: encodePublicPageCursor({ cursor, offset, pageSize, done }),
isDone: page.isDone,
continueCursor: encodePublicPageCursor({
cursor: page.continueCursor,
offset: 0,
pageSize: effectivePageSize,
done: page.isDone,
}),
};
}
@@ -2208,7 +2246,9 @@ 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 "${nextRuntimeIdLabel}" is already claimed by another package`);
throw new ConvexError(
`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`,
);
}
}
@@ -2374,15 +2414,7 @@ export const updateReleaseScanResultsInternal = internalMutation({
args: {
releaseId: v.id("packageReleases"),
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(
v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
checkedAt: v.number(),
}),
),
vtAnalysis: v.optional(vtAnalysisValidator),
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
@@ -2408,10 +2440,16 @@ export const updateReleaseScanResultsInternal = internalMutation({
await ctx.db.patch(args.releaseId, patch);
}
if (args.vtAnalysis !== undefined) {
await syncLatestPackageVerification(ctx, {
const updatedRelease = {
...activeRelease,
...patch,
} as Doc<"packageReleases">);
} as Doc<"packageReleases">;
await syncLatestPackageVerification(ctx, updatedRelease);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
}
},
});
@@ -2443,7 +2481,13 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!isReleaseActive(release)) return;
const updatedRelease = { ...release, llmAnalysis: args.llmAnalysis };
await ctx.db.patch(args.releaseId, { llmAnalysis: args.llmAnalysis });
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
},
});
@@ -2491,10 +2535,16 @@ export const updateReleaseStaticScanInternal = internalMutation({
await ctx.db.patch(args.releaseId, patch);
await syncLatestPackageVerification(ctx, {
const updatedRelease = {
...activeRelease,
...patch,
} as Doc<"packageReleases">);
} as Doc<"packageReleases">;
await syncLatestPackageVerification(ctx, updatedRelease);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
},
});
@@ -2621,3 +2671,183 @@ export const backfillPackageReleaseScans = action({
});
},
});
async function markPackageRescanRequest(
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
requestId: Id<"rescanRequests">,
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(
internalRefs.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
args: {
packageId: v.id("packages"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
return {
targetKind: "plugin" as const,
targetVersion: target.release.version,
packageReleaseId: target.release._id,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
})),
};
},
});
export const getOwnerRescanStateByName = query({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const viewerUserId = await getOptionalViewerUserId(ctx);
if (!viewerUserId) return null;
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) return null;
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt) return null;
const actor = await ctx.db.get(viewerUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
if (actor.role !== "admin") {
const canAccess = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
if (!canAccess) return null;
}
return {
targetKind: "plugin" as const,
targetVersion: release.version,
packageReleaseId: release._id,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: release._id,
})),
};
},
});
export const requestRescan = mutation({
args: {
packageId: v.id("packages"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
const requestId = await insertPackageRescanRequest(ctx, user, target);
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
requestId,
releaseId: target.release._id,
});
return {
requestId,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
})),
};
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
name: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
const target = await getLatestPackageRescanTarget(ctx, pkg._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
const requestId = await insertPackageRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
requestId,
releaseId: target.release._id,
});
const state = await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
return {
ok: true,
targetKind: "package" as const,
name: target.pkg.normalizedName,
version: target.release.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchPackageRescanInternal = internalAction({
args: {
requestId: v.id("rescanRequests"),
releaseId: v.id("packageReleases"),
},
handler: async (ctx, args) => {
try {
await runActionRef(ctx, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
releaseId: args.releaseId,
});
await runActionRef(ctx, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
releaseId: args.releaseId,
});
await runActionRef(ctx, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
releaseId: args.releaseId,
});
} catch (error) {
await markPackageRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
throw error;
}
},
});
+2 -3
View File
@@ -1,9 +1,8 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicPublisher } from "./lib/public";
import {
ensurePersonalPublisherForUser,
@@ -404,7 +403,7 @@ export const resolvePublishTargetForUserInternal = internalMutation({
export const listMine = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return [];
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return [];
+19
View File
@@ -0,0 +1,19 @@
import { v } from "convex/values";
import { internalMutation } from "./functions";
export const markStatusInternal = internalMutation({
args: {
requestId: v.id("rescanRequests"),
status: v.union(v.literal("completed"), v.literal("failed")),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
await ctx.db.patch(args.requestId, {
status: args.status,
error: args.error,
updatedAt: now,
completedAt: now,
});
},
});
+515
View File
@@ -0,0 +1,515 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchPackageRescanInternal,
requestRescan as requestPackageRescan,
} from "./packages";
import {
dispatchSkillRescanInternal,
getRescanState as getSkillRescanState,
requestRescan as requestSkillRescan,
} from "./skills";
import { requireUser } from "./lib/access";
import {
finalizeInProgressRescanRequestsForTarget,
MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
} from "./model/rescans/policy";
vi.mock("./lib/access", () => ({
requireUser: vi.fn(),
}));
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const requestSkillRescanHandler = (
requestSkillRescan as unknown as WrappedHandler<{ skillId: string }>
)._handler;
const requestPackageRescanHandler = (
requestPackageRescan as unknown as WrappedHandler<{ packageId: string }>
)._handler;
const getSkillRescanStateHandler = (
getSkillRescanState as unknown as WrappedHandler<{ skillId: string }>
)._handler;
const dispatchSkillRescanHandler = (
dispatchSkillRescanInternal as unknown as WrappedHandler<{
requestId: string;
skillId: string;
versionId: string;
}>
)._handler;
const dispatchPackageRescanHandler = (
dispatchPackageRescanInternal as unknown as WrappedHandler<{
requestId: string;
releaseId: string;
}>
)._handler;
type RescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
skillId?: string;
skillVersionId?: string;
packageId?: string;
packageReleaseId?: string;
targetVersion: string;
requestedByUserId: string;
ownerUserId: string;
ownerPublisherId?: string;
status: "in_progress" | "completed" | "failed";
createdAt: number;
updatedAt: number;
completedAt?: number;
};
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb(options?: {
requests?: RescanRequest[];
userRole?: "admin" | "moderator" | "user";
ownerPublisherId?: string;
membershipRole?: "owner" | "admin" | "publisher";
skillLatestVersionId?: string;
packageLatestReleaseId?: string;
skillSoftDeletedAt?: number;
skillVersionSoftDeletedAt?: number;
packageSoftDeletedAt?: number;
packageReleaseSoftDeletedAt?: number;
}) {
const requests = [...(options?.requests ?? [])];
const skill = {
_id: "skills:1",
slug: "flagged-skill",
ownerUserId: "users:owner",
ownerPublisherId: options?.ownerPublisherId,
latestVersionId: options?.skillLatestVersionId ?? "skillVersions:latest",
softDeletedAt: options?.skillSoftDeletedAt,
};
const version = {
_id: "skillVersions:latest",
skillId: "skills:1",
version: "1.2.3",
softDeletedAt: options?.skillVersionSoftDeletedAt,
};
const pkg = {
_id: "packages:1",
name: "flagged-plugin",
family: "code-plugin",
ownerUserId: "users:owner",
ownerPublisherId: options?.ownerPublisherId,
latestReleaseId: options?.packageLatestReleaseId ?? "packageReleases:latest",
softDeletedAt: options?.packageSoftDeletedAt,
};
const release = {
_id: "packageReleases:latest",
packageId: "packages:1",
version: "2.0.0",
softDeletedAt: options?.packageReleaseSoftDeletedAt,
};
const actor = {
_id: "users:actor",
role: options?.userRole ?? "user",
deletedAt: undefined,
deactivatedAt: undefined,
};
const db = {
get: vi.fn(async (id: string) => {
if (id === "skills:1") return skill;
if (id === "skillVersions:latest") return version;
if (id === "packages:1") return pkg;
if (id === "packageReleases:latest") return release;
if (id === "users:actor") return actor;
return null;
}),
insert: vi.fn(async (table: string, doc: Omit<RescanRequest, "_id">) => {
if (table !== "rescanRequests") throw new Error(`unexpected insert ${table}`);
const inserted = {
_id: `rescanRequests:${requests.length + 1}`,
...doc,
} as RescanRequest;
requests.push(inserted);
return inserted._id;
}),
patch: vi.fn(async (id: string, patch: Partial<RescanRequest>) => {
const request = requests.find((candidate) => candidate._id === id);
if (request) Object.assign(request, patch);
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
if (name !== "by_publisher_user") throw new Error(`unexpected index ${name}`);
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
return {
unique: async () =>
options?.membershipRole
? {
publisherId: constraints.publisherId,
userId: constraints.userId,
role: options.membershipRole,
}
: null,
};
},
};
}
if (table !== "rescanRequests") throw new Error(`unexpected table ${table}`);
return {
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = requests
.filter((request) => matches(request as unknown as Record<string, unknown>, constraints))
.sort((a, b) => b.createdAt - a.createdAt);
return {
order: () => ({
take: async (limit: number) => matched.slice(0, limit),
first: async () => matched[0] ?? null,
}),
};
},
};
}),
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
};
return { db, requests };
}
function createRequest(overrides?: Partial<RescanRequest>): RescanRequest {
return {
_id: "rescanRequests:existing",
targetKind: "skill",
skillId: "skills:1",
skillVersionId: "skillVersions:latest",
targetVersion: "1.2.3",
requestedByUserId: "users:owner",
ownerUserId: "users:owner",
status: "completed",
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
};
}
beforeEach(() => {
vi.mocked(requireUser).mockReset();
vi.mocked(requireUser).mockResolvedValue({
userId: "users:owner",
user: { _id: "users:owner", role: "user" },
} as never);
});
describe("rescan requests", () => {
it("returns owner-visible state for the latest skill version", async () => {
const { db } = createDb({
requests: [
createRequest({ _id: "rescanRequests:1", status: "completed", createdAt: 1 }),
createRequest({ _id: "rescanRequests:2", status: "failed", createdAt: 2 }),
],
});
const result = await getSkillRescanStateHandler({ db } as never, {
skillId: "skills:1",
});
expect(result).toMatchObject({
targetKind: "skill",
targetVersion: "1.2.3",
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
requestCount: 2,
remainingRequests: 1,
canRequest: true,
});
});
it("creates a skill rescan request and schedules dispatch", async () => {
const { db, requests } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
const result = await requestSkillRescanHandler({ db, scheduler } as never, {
skillId: "skills:1",
});
expect(result).toMatchObject({
requestId: "rescanRequests:1",
remainingRequests: 2,
});
expect(requests[0]).toMatchObject({
targetKind: "skill",
skillId: "skills:1",
skillVersionId: "skillVersions:latest",
status: "in_progress",
targetVersion: "1.2.3",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
requestId: "rescanRequests:1",
skillId: "skills:1",
versionId: "skillVersions:latest",
}),
);
});
it("creates a plugin rescan request against the latest release", async () => {
const { db, requests } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await requestPackageRescanHandler({ db, scheduler } as never, {
packageId: "packages:1",
});
expect(requests[0]).toMatchObject({
targetKind: "plugin",
packageId: "packages:1",
packageReleaseId: "packageReleases:latest",
status: "in_progress",
targetVersion: "2.0.0",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
requestId: "rescanRequests:1",
releaseId: "packageReleases:latest",
}),
);
});
it("rejects duplicate in-progress requests for the same release", async () => {
const { db } = createDb({
requests: [createRequest({ status: "in_progress" })],
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("already in progress");
});
it("enforces the per-release rescan cap", async () => {
const { db } = createDb({
requests: Array.from({ length: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE }, (_, index) =>
createRequest({
_id: `rescanRequests:${index}`,
status: "completed",
createdAt: index,
}),
),
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Rescan request limit reached");
});
it("rejects non-owners", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb();
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Forbidden");
});
it("lets org admins request owner rescans", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb({
ownerPublisherId: "publishers:org",
membershipRole: "admin",
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
});
it("rejects publisher-only org members", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb({
ownerPublisherId: "publishers:org",
membershipRole: "publisher",
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Forbidden");
});
it("lets admins request owner rescans", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const { db } = createDb();
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
});
it("rejects missing or soft-deleted skill targets", async () => {
const softDeletedSkill = createDb({ skillSoftDeletedAt: 123 });
await expect(
requestSkillRescanHandler(
{ db: softDeletedSkill.db, scheduler: { runAfter: vi.fn() } } as never,
{ skillId: "skills:1" },
),
).rejects.toThrow("Skill not found");
const softDeletedVersion = createDb({ skillVersionSoftDeletedAt: 123 });
await expect(
requestSkillRescanHandler(
{ db: softDeletedVersion.db, scheduler: { runAfter: vi.fn() } } as never,
{ skillId: "skills:1" },
),
).rejects.toThrow("Latest skill version not found");
});
it("rejects missing or soft-deleted plugin targets", async () => {
const softDeletedPackage = createDb({ packageSoftDeletedAt: 123 });
await expect(
requestPackageRescanHandler(
{ db: softDeletedPackage.db, scheduler: { runAfter: vi.fn() } } as never,
{ packageId: "packages:1" },
),
).rejects.toThrow("Plugin not found");
const softDeletedRelease = createDb({ packageReleaseSoftDeletedAt: 123 });
await expect(
requestPackageRescanHandler(
{ db: softDeletedRelease.db, scheduler: { runAfter: vi.fn() } } as never,
{ packageId: "packages:1" },
),
).rejects.toThrow("Latest plugin release not found");
});
it("dispatches skill rescans through each existing scanner without completing early", async () => {
const runAction = vi.fn(async () => undefined);
const runMutation = vi.fn(async () => undefined);
await dispatchSkillRescanHandler({ runAction, runMutation } as never, {
requestId: "rescanRequests:1",
skillId: "skills:1",
versionId: "skillVersions:latest",
});
expect(runAction).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ skillId: "skills:1", versionId: "skillVersions:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({ versionId: "skillVersions:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.objectContaining({ versionId: "skillVersions:latest" }),
);
expect(runMutation).not.toHaveBeenCalled();
});
it("dispatches plugin rescans through each existing scanner without completing early", async () => {
const runAction = vi.fn(async () => undefined);
const runMutation = vi.fn(async () => undefined);
await dispatchPackageRescanHandler({ runAction, runMutation } as never, {
requestId: "rescanRequests:1",
releaseId: "packageReleases:latest",
});
expect(runAction).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runMutation).not.toHaveBeenCalled();
});
it("completes in-progress rescans when all scanner results are fresh", async () => {
const { db, requests } = createDb({
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
});
await finalizeInProgressRescanRequestsForTarget(
{ db } as never,
{ kind: "skill", artifactId: "skillVersions:latest" as never },
{
staticScan: { status: "clean", checkedAt: 101 },
vtAnalysis: { status: "clean", checkedAt: 102 },
llmAnalysis: { status: "benign", checkedAt: 103 },
},
);
expect(requests[0]).toMatchObject({ status: "completed" });
expect(requests[0].completedAt).toEqual(expect.any(Number));
});
it("keeps in-progress rescans open while VT only has old results", async () => {
const { db, requests } = createDb({
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
});
await finalizeInProgressRescanRequestsForTarget(
{ db } as never,
{ kind: "skill", artifactId: "skillVersions:latest" as never },
{
staticScan: { status: "clean", checkedAt: 101 },
vtAnalysis: { status: "clean", checkedAt: 99 },
llmAnalysis: { status: "benign", checkedAt: 103 },
},
);
expect(requests[0]).toMatchObject({ status: "in_progress" });
});
});
+47 -18
View File
@@ -12,6 +12,23 @@ const manualModerationOverride = v.object({
updatedAt: v.number(),
});
const vtEngineStatsValidator = v.object({
malicious: v.optional(v.number()),
suspicious: v.optional(v.number()),
undetected: v.optional(v.number()),
harmless: v.optional(v.number()),
});
const vtAnalysisValidator = v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
scanner: v.optional(v.string()),
engineStats: v.optional(vtEngineStatsValidator),
checkedAt: v.number(),
});
const users = defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -429,15 +446,7 @@ const skillVersions = defineTable({
createdAt: v.number(),
softDeletedAt: v.optional(v.number()),
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(
v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
checkedAt: v.number(),
}),
),
vtAnalysis: v.optional(vtAnalysisValidator),
llmAnalysis: v.optional(
v.object({
status: v.string(),
@@ -704,15 +713,7 @@ const packageReleases = defineTable({
capabilities: packageCapabilitiesValidator,
verification: packageVerificationValidator,
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(
v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
checkedAt: v.number(),
}),
),
vtAnalysis: v.optional(vtAnalysisValidator),
llmAnalysis: v.optional(
v.object({
status: v.string(),
@@ -1199,6 +1200,33 @@ const vtScanLogs = defineTable({
createdAt: v.number(),
}).index("by_type_date", ["type", "createdAt"]);
const rescanRequests = defineTable({
targetKind: v.union(v.literal("skill"), v.literal("plugin")),
skillId: v.optional(v.id("skills")),
skillVersionId: v.optional(v.id("skillVersions")),
packageId: v.optional(v.id("packages")),
packageReleaseId: v.optional(v.id("packageReleases")),
targetVersion: v.string(),
requestedByUserId: v.id("users"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
status: v.union(v.literal("in_progress"), v.literal("completed"), v.literal("failed")),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
.index("by_package_release_status", [
"targetKind",
"packageReleaseId",
"status",
"createdAt",
])
.index("by_requester", ["requestedByUserId", "createdAt"]);
const apiTokens = defineTable({
userId: v.id("users"),
label: v.string(),
@@ -1360,6 +1388,7 @@ export default defineSchema({
soulStars,
auditLogs,
vtScanLogs,
rescanRequests,
apiTokens,
rateLimits,
downloadDedupes,
+157 -25
View File
@@ -11,44 +11,62 @@ type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
function makeSkill(overrides: Record<string, unknown> = {}) {
return {
_id: "skills:skill",
_creationTime: 1,
slug: "demo-skill",
displayName: "Demo Skill",
summary: "Demo skill",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: [],
badges: undefined,
statsDownloads: 7,
statsStars: 3,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
moderationVerdict: "clean",
isSuspicious: false,
...overrides,
};
}
const listHandler = (
list as unknown as WrappedHandler<
{ ownerPublisherId?: string; ownerUserId?: string; limit?: number },
Array<{ slug: string }>
Array<{ slug: string; stats: { downloads: number; stars: number } }>
>
)._handler;
describe("skills.list", () => {
it("includes legacy personal skills when listing a personal publisher", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const legacySkill = {
const legacySkill = makeSkill({
_id: "skills:legacy",
_creationTime: 1,
slug: "legacy-skill",
displayName: "Legacy Skill",
summary: "Pre-backfill skill",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
badges: undefined,
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
};
});
const ctx = {
db: {
@@ -121,4 +139,118 @@ describe("skills.list", () => {
expect(result).toEqual([expect.objectContaining({ slug: "legacy-skill" })]);
});
it("includes non-public flagged skills for the owning user dashboard", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const blockedSkill = makeSkill({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationReason: "scanner.vt.malicious",
moderationVerdict: "malicious",
isSuspicious: true,
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
_creationTime: 1,
handle: "owner",
displayName: "Owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([blockedSkill]),
})),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await listHandler(
ctx as never,
{ ownerUserId: "users:owner", limit: 10 } as never,
);
expect(result).toEqual([
expect.objectContaining({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationVerdict: "malicious",
stats: expect.objectContaining({ downloads: 7, stars: 3 }),
}),
]);
});
it("does not expose non-public flagged skills to non-owner list callers", async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null);
const blockedSkill = makeSkill({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationReason: "scanner.vt.malicious",
moderationVerdict: "malicious",
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
_creationTime: 1,
handle: "owner",
displayName: "Owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([blockedSkill]),
})),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await listHandler(
ctx as never,
{ ownerUserId: "users:owner", limit: 10 } as never,
);
expect(result).toEqual([]);
});
});
+72
View File
@@ -57,6 +57,16 @@ function makeCtx(params: { skill: Record<string, unknown>; version?: Record<stri
};
}
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn(async () => []),
})),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
});
const get = vi.fn(async (id: string) => {
@@ -426,4 +436,66 @@ describe("skills manual overrides", () => {
}),
);
});
it("clears legacy suspicious state when LLM corroborates clean VT Code Insight-only suspicious", async () => {
const now = 1_700_000_400_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const skill = {
_id: "skills:1",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:9",
softDeletedAt: undefined,
moderationStatus: "hidden",
moderationReason: "scanner.vt.suspicious",
moderationVerdict: "suspicious",
moderationFlags: ["flagged.suspicious"],
};
const version = {
_id: "skillVersions:9",
skillId: "skills:1",
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "",
engineVersion: "v2.1.1",
checkedAt: now - 200,
},
vtAnalysis: {
status: "suspicious",
scanner: "code_insight",
engineStats: {
malicious: 0,
suspicious: 0,
harmless: 12,
undetected: 54,
},
checkedAt: now - 100,
},
llmAnalysis: undefined,
};
const { ctx, patch } = makeCtx({ skill, version });
await updateVersionLlmAnalysisInternalHandler(ctx, {
versionId: "skillVersions:9",
llmAnalysis: {
status: "clean",
checkedAt: now,
},
});
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
moderationStatus: "active",
moderationReason: "scanner.aggregate.clean",
moderationFlags: undefined,
moderationVerdict: "clean",
moderationReasonCodes: undefined,
isSuspicious: false,
}),
);
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {
getActiveSkillBatchForStaticScanBackfillInternal,
getPendingScanSkillsInternal,
} from "./skills";
import { MODERATION_ENGINE_VERSION } from "./lib/moderationReasonCodes";
type PendingScanResult = Array<{
skillId: string;
@@ -279,7 +280,7 @@ describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
"skillVersions:current-static",
{
_id: "skillVersions:current-static",
staticScan: { engineVersion: "v2.4.1" },
staticScan: { engineVersion: MODERATION_ENGINE_VERSION },
},
],
[
+86
View File
@@ -1266,6 +1266,92 @@ describe("skills anti-spam guards", () => {
);
});
it("vt suspicious escalation clears legacy quarantine for uncorroborated Code Insight", async () => {
const patch = vi.fn(async () => {});
const version = {
_id: "skillVersions:1",
skillId: "skills:1",
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "",
engineVersion: "v2.1.1",
checkedAt: Date.now(),
},
vtAnalysis: {
status: "suspicious",
scanner: "code_insight",
engineStats: {
malicious: 0,
suspicious: 0,
harmless: 12,
undetected: 54,
},
},
llmAnalysis: { status: "clean" },
};
const skill = {
_id: "skills:1",
slug: "doc-only",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:1",
moderationStatus: "hidden",
moderationFlags: ["flagged.suspicious"],
moderationReason: "scanner.vt.suspicious",
};
const owner = {
_id: "users:owner",
role: "user",
deletedAt: undefined,
};
const db = {
get: vi.fn(async (id: string) => {
if (id === "skills:1") return skill;
if (id === "users:owner") return owner;
return null;
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
if (table === "skillVersions") {
return {
withIndex: () => ({
unique: async () => version,
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert: vi.fn(),
normalizeId: vi.fn(),
};
await escalateByVtHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
{
sha256hash: "h".repeat(64),
status: "suspicious",
} as never,
);
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
moderationStatus: "active",
moderationFlags: undefined,
moderationReason: "scanner.vt.clean",
moderationVerdict: "clean",
moderationReasonCodes: undefined,
isSuspicious: false,
}),
);
});
it("ignores vt escalation for non-latest versions", async () => {
const patch = vi.fn(async () => {});
const version = {
+394 -133
View File
@@ -15,7 +15,14 @@ import {
mutation,
query,
} from "./functions";
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
getOptionalActiveAuthUserIdFromAction,
requireUser,
requireUserFromAction,
} from "./lib/access";
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
import { scheduleNextBatchIfNeeded } from "./lib/batching";
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
@@ -56,6 +63,7 @@ import {
toPublicUser,
} from "./lib/public";
import {
assertCanManageOwnedResource,
ensurePersonalPublisherForUser,
getOwnerPublisher,
requirePublisherRole,
@@ -80,7 +88,6 @@ import {
publishVersionForUser,
queueHighlightedWebhook,
} from "./lib/skillPublish";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
import {
@@ -89,7 +96,16 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import {
assertCanRequestRescan,
buildRescanState,
errorMessage,
finalizeInProgressRescanRequestsForTarget,
} from "./model/rescans/policy";
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -128,8 +144,26 @@ const USER_MODERATION_REASON = "user.moderation";
const SKILL_CATALOG_CURSOR_PREFIX = "skillcat:";
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
const vtEngineStatsValidator = v.object({
malicious: v.optional(v.number()),
suspicious: v.optional(v.number()),
undetected: v.optional(v.number()),
harmless: v.optional(v.number()),
});
const vtAnalysisValidator = v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
scanner: v.optional(v.string()),
engineStats: v.optional(vtEngineStatsValidator),
checkedAt: v.number(),
});
function buildStructuredModerationPatch(params: {
staticScan?: Doc<"skillVersions">["staticScan"];
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
vtStatus?: string;
llmStatus?: string;
sourceVersionId?: Id<"skillVersions">;
@@ -145,6 +179,7 @@ function buildStructuredModerationPatch(params: {
> {
const snapshot = buildModerationSnapshot({
staticScan: params.staticScan,
vtAnalysis: params.vtAnalysis,
vtStatus: params.vtStatus,
llmStatus: params.llmStatus,
sourceVersionId: params.sourceVersionId,
@@ -188,6 +223,9 @@ function resolveScannerModerationReason(params: {
const vtStatus = normalizeAnalysisStatus(params.vtStatus);
const llmStatus = normalizeAnalysisStatus(params.llmStatus);
if (params.verdict === "clean" && (vtStatus === "suspicious" || llmStatus === "suspicious")) {
return "scanner.aggregate.clean";
}
if (vtStatus === "malicious") return "scanner.vt.malicious";
if (llmStatus === "malicious") return "scanner.llm.malicious";
if (vtStatus === "suspicious") return "scanner.vt.suspicious";
@@ -210,6 +248,7 @@ function buildScannerModerationPatchFromVersion(params: {
}): SkillModerationPatch {
const structuredPatch = buildStructuredModerationPatch({
staticScan: params.version.staticScan,
vtAnalysis: params.version.vtAnalysis,
vtStatus: params.version.vtAnalysis?.status,
llmStatus: params.version.llmAnalysis?.status,
sourceVersionId: params.version._id,
@@ -484,20 +523,14 @@ async function syncSkillModerationFromLatestVersion(
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
}
function buildConflictingSkillUrl(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: SkillOwnerRef) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
const ownerParam = owner.handle?.trim() || String(owner._id);
if (!ownerParam) return null;
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
}
function buildSlugTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
if (!owner || owner.deletedAt || owner.deactivatedAt) {
return (
"This slug is locked to a deleted or banned account. " +
@@ -510,10 +543,7 @@ function buildSlugTakenErrorMessage(
return `${base} Existing skill: ${url}`;
}
function buildAliasTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
const base = "Slug redirects to an existing skill. Choose a different slug.";
const url = buildConflictingSkillUrl(skill, owner);
if (!url) return base;
@@ -1115,6 +1145,40 @@ type ManagementSkillEntry = {
owner: Doc<"users"> | null;
};
type DashboardSkillListItem = {
_id: Id<"skills">;
_creationTime: number;
slug: string;
displayName: string;
summary?: string;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
canonicalSkillId?: Id<"skills">;
forkOf?: Doc<"skills">["forkOf"];
latestVersionId?: Id<"skillVersions">;
tags: Doc<"skills">["tags"];
capabilityTags?: string[];
badges: Doc<"skills">["badges"];
stats: Doc<"skills">["stats"];
moderationStatus?: Doc<"skills">["moderationStatus"];
moderationReason?: string;
moderationVerdict?: Doc<"skills">["moderationVerdict"];
moderationFlags?: string[];
isSuspicious?: boolean;
pendingReview?: true;
qualityDecision?: NonNullable<Doc<"skills">["quality"]>["decision"];
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
createdAt: number;
updatedAt: number;
};
type BadgeKind = Doc<"skillBadges">["kind"];
async function buildPublicSkillEntries(
@@ -1345,6 +1409,66 @@ async function attachBadgesToSkills(ctx: QueryCtx, skills: Doc<"skills">[]) {
}));
}
async function toDashboardSkillListItem(
ctx: QueryCtx,
skill: Doc<"skills"> & { badges?: Doc<"skills">["badges"] },
): Promise<DashboardSkillListItem> {
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const stats = {
...skill.stats,
downloads: readCanonicalStat(skill, "downloads"),
stars: readCanonicalStat(skill, "stars"),
installsCurrent: readCanonicalStat(skill, "installsCurrent"),
installsAllTime: readCanonicalStat(skill, "installsAllTime"),
};
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
capabilityTags: skill.capabilityTags,
badges: skill.badges,
stats,
moderationStatus: skill.moderationStatus,
moderationReason: skill.moderationReason,
moderationVerdict: skill.moderationVerdict,
moderationFlags: skill.moderationFlags,
isSuspicious: skill.isSuspicious,
pendingReview:
skill.moderationReason === "pending.scan" || skill.moderationReason === "pending.scan.stale"
? true
: undefined,
qualityDecision: skill.quality?.decision,
rescanState:
latestVersion && !latestVersion.softDeletedAt
? await buildRescanState(ctx, {
kind: "skill",
artifactId: latestVersion._id,
})
: null,
latestVersion:
latestVersion && !latestVersion.softDeletedAt
? {
version: latestVersion.version,
createdAt: latestVersion.createdAt,
vtStatus: latestVersion.vtAnalysis?.status ?? null,
llmStatus: latestVersion.llmAnalysis?.status ?? null,
staticScanStatus: latestVersion.staticScan?.status ?? null,
}
: null,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
};
}
async function loadHighlightedSkills(ctx: QueryCtx, limit: number) {
const entries = await ctx.db
.query("skillBadges")
@@ -1419,7 +1543,7 @@ export const getBySlug = query({
const skill = resolved.skill;
if (!skill) return null;
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
@@ -2107,7 +2231,7 @@ export const list = query({
}
const ownerPublisherId = args.ownerPublisherId;
if (ownerPublisherId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await ctx.db.get(ownerPublisherId);
const membership =
userId &&
@@ -2144,36 +2268,9 @@ export const list = query({
const withBadges = await attachBadgesToSkills(ctx, filtered);
if (isOwnDashboard) {
return withBadges
.map((skill) => {
const publicSkill = toPublicSkill(skill);
if (publicSkill) return publicSkill;
const isPending =
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
if (isPending) {
const { badges } = skill;
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
pendingReview: true as const,
};
}
return null;
})
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
return await Promise.all(
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
);
}
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
@@ -2183,7 +2280,7 @@ export const list = query({
}
const ownerUserId = args.ownerUserId;
if (ownerUserId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const isOwnDashboard = Boolean(userId && userId === ownerUserId);
const entries = await ctx.db
.query("skills")
@@ -2194,38 +2291,9 @@ export const list = query({
const withBadges = await attachBadgesToSkills(ctx, filtered);
if (isOwnDashboard) {
// For owner's own dashboard, include pending skills
return withBadges
.map((skill) => {
const publicSkill = toPublicSkill(skill);
if (publicSkill) return publicSkill;
// Include pending skills for owner
const isPending =
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
if (isPending) {
// Use computed badges from attachBadgesToSkills, not stored skill.badges
const { badges } = skill;
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
pendingReview: true as const,
};
}
return null;
})
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
return await Promise.all(
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
);
}
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
@@ -2799,7 +2867,10 @@ export const listPublicPageV4 = query({
let scanInclusive = isFirstPage;
let hasMore = false;
let nextCursor: string | null = null;
let remainingRows = Math.max(numItems, Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12));
let remainingRows = Math.max(
numItems,
Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12),
);
for (let pageCount = 0; pageCount < MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES; pageCount += 1) {
if (remainingRows <= 0) break;
@@ -3932,11 +4003,18 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
},
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 };
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 updatedVersion = { ...version, staticScan: args.staticScan };
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: version._id },
updatedVersion,
);
const skill = await ctx.db.get(args.skillId);
if (!skill) return { ok: true as const, skipped: "missing" as const };
@@ -3946,7 +4024,6 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
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,
@@ -3978,37 +4055,39 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
},
});
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
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,
});
},
},
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: {
@@ -4018,10 +4097,13 @@ export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction>
},
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,
});
const batch = await ctx.runQuery(
internal.skills.getActiveSkillBatchForStaticScanBackfillInternal,
{
cursor: args.cursor,
batchSize,
},
);
let rescanned = args.rescanned ?? 0;
for (const skill of batch.skills) {
@@ -4061,6 +4143,156 @@ export const backfillSkillStaticScans: ReturnType<typeof action> = action({
},
});
async function markSkillRescanRequest(
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
requestId: Id<"rescanRequests">,
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(
internal.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
return {
targetKind: "skill" as const,
targetVersion: target.version.version,
skillVersionId: target.version._id,
...(await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
})),
};
},
});
export const requestRescan = mutation({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "skill",
artifactId: target.version._id,
});
const requestId = await insertSkillRescanRequest(ctx, user, target);
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
requestId,
skillId: target.skill._id,
versionId: target.version._id,
});
return {
requestId,
...(await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
})),
};
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
slug: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug.trim().toLowerCase());
const skill = resolved.skill;
if (!skill) throw new ConvexError("Skill not found");
const target = await getLatestSkillRescanTarget(ctx, skill._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "skill",
artifactId: target.version._id,
});
const requestId = await insertSkillRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
requestId,
skillId: target.skill._id,
versionId: target.version._id,
});
const state = await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
});
return {
ok: true,
targetKind: "skill" as const,
name: target.skill.slug,
version: target.version.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchSkillRescanInternal: ReturnType<typeof internalAction> = internalAction({
args: {
requestId: v.id("rescanRequests"),
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
},
handler: async (ctx, args) => {
try {
await ctx.runAction(internal.skills.scanSkillVersionStaticallyInternal, {
skillId: args.skillId,
versionId: args.versionId,
});
await ctx.runAction(internal.vt.scanWithVirusTotal, {
versionId: args.versionId,
});
await ctx.runAction(internal.llmEval.evaluateWithLlm, {
versionId: args.versionId,
});
} catch (error) {
await markSkillRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
throw error;
}
},
});
/**
* Emergency escalation by skillId for legacy rows without sha256hash.
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
@@ -4085,6 +4317,7 @@ export const escalateSkillByIdInternal = internalMutation({
const llmStatus = reasonMatch?.[1] === "llm" ? reasonMatch[2] : version?.llmAnalysis?.status;
const snapshot = buildModerationSnapshot({
staticScan: version?.staticScan,
vtAnalysis: version?.vtAnalysis,
vtStatus,
llmStatus,
sourceVersionId: version?._id,
@@ -4572,15 +4805,7 @@ export const updateVersionScanResultsInternal = internalMutation({
args: {
versionId: v.id("skillVersions"),
sha256hash: v.optional(v.string()),
vtAnalysis: v.optional(
v.object({
status: v.string(),
verdict: v.optional(v.string()),
analysis: v.optional(v.string()),
source: v.optional(v.string()),
checkedAt: v.number(),
}),
),
vtAnalysis: v.optional(vtAnalysisValidator),
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
@@ -4596,6 +4821,11 @@ export const updateVersionScanResultsInternal = internalMutation({
if (Object.keys(patch).length > 0) {
await ctx.db.patch(args.versionId, patch);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: args.versionId },
{ ...version, ...patch },
);
}
},
});
@@ -4629,6 +4859,11 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
if (!version) return;
const nextVersion = { ...version, llmAnalysis: args.llmAnalysis };
await ctx.db.patch(args.versionId, { llmAnalysis: args.llmAnalysis });
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: version._id },
nextVersion,
);
const skill = await ctx.db.get(version.skillId);
if (!skill || skill.latestVersionId !== version._id) return;
@@ -4694,11 +4929,6 @@ export const approveSkillByHashInternal = internalMutation({
const now = Date.now();
const qualityLocked = skill.moderationReason === "quality.low" && !isMalicious;
const nextModerationReason = qualityLocked
? "quality.low"
: bypassSuspicious
? `scanner.${args.scanner}.clean`
: `scanner.${args.scanner}.${args.status}`;
const nextModerationNotes = qualityLocked
? (skill.moderationNotes ??
"Quality gate quarantine is still active. Manual moderation review required.")
@@ -4706,6 +4936,7 @@ export const approveSkillByHashInternal = internalMutation({
const scanner = args.scanner.trim().toLowerCase();
const snapshot = buildModerationSnapshot({
staticScan: version.staticScan,
vtAnalysis: version.vtAnalysis,
vtStatus: scanner === "vt" ? args.status : version.vtAnalysis?.status,
llmStatus: scanner === "llm" ? args.status : version.llmAnalysis?.status,
sourceVersionId: version._id,
@@ -4716,6 +4947,16 @@ export const approveSkillByHashInternal = internalMutation({
: snapshot.reasonCodes;
const nextVerdict = verdictFromCodes(nextReasonCodes);
const nextLegacyFlags = legacyFlagsFromVerdict(nextVerdict);
if (nextVerdict === "clean" && !alreadyBlocked) {
newFlags = undefined;
}
const nextModerationReason = qualityLocked
? "quality.low"
: bypassSuspicious
? `scanner.${args.scanner}.clean`
: nextVerdict === "clean"
? "scanner.aggregate.clean"
: `scanner.${args.scanner}.${args.status}`;
const nextModerationStatus =
nextVerdict === "malicious" || qualityLocked ? "hidden" : "active";
@@ -4801,9 +5042,9 @@ export const escalateByVtInternal = internalMutation({
newFlags = ["flagged.suspicious"];
}
const nextModerationFlags = newFlags.length ? newFlags : undefined;
const snapshot = buildModerationSnapshot({
staticScan: version.staticScan,
vtAnalysis: version.vtAnalysis,
vtStatus: args.status,
llmStatus: version.llmAnalysis?.status,
sourceVersionId: version._id,
@@ -4813,6 +5054,13 @@ export const escalateByVtInternal = internalMutation({
? snapshot.reasonCodes.filter((code) => !code.startsWith("suspicious."))
: snapshot.reasonCodes;
const nextVerdict = verdictFromCodes(nextReasonCodes);
const nextLegacyFlags = legacyFlagsFromVerdict(nextVerdict);
const nextModerationFlags =
nextVerdict === "clean" && !alreadyBlocked
? undefined
: newFlags.length
? newFlags
: nextLegacyFlags;
const now = Date.now();
const basePatch: SkillModerationPatch = {
moderationFlags: nextModerationFlags,
@@ -4829,11 +5077,21 @@ export const escalateByVtInternal = internalMutation({
basePatch.moderationReason = normalizeScannerSuspiciousReason(
skill.moderationReason as string | undefined,
);
} else if (nextVerdict === "clean" && !alreadyBlocked) {
const existingReason = skill.moderationReason as string | undefined;
if (existingReason?.startsWith("scanner.") && existingReason.endsWith(".suspicious")) {
basePatch.moderationReason = normalizeScannerSuspiciousReason(existingReason);
}
}
// Only hide for malicious — suspicious stays visible with a flag
if (isMalicious) {
basePatch.moderationStatus = "hidden";
} else if (nextVerdict === "clean" && !alreadyBlocked) {
basePatch.moderationStatus = "active";
basePatch.hiddenAt = undefined;
basePatch.hiddenBy = undefined;
basePatch.lastReviewedAt = undefined;
}
basePatch.isSuspicious = computeIsSuspicious({
@@ -4975,7 +5233,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
})) as Doc<"skills"> | null;
if (!skill) return false;
const authUserId = await getAuthUserId(ctx);
const authUserId = await getOptionalActiveAuthUserIdFromAction(ctx);
if (authUserId) {
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
return true;
@@ -5446,7 +5704,10 @@ export const changeOwner = mutation({
lastReviewedAt: now,
updatedAt: now,
});
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
await adjustUserSkillStatsForSkillChange(ctx, skill, {
...skill,
ownerUserId: args.ownerUserId,
});
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
for (const embedding of embeddings) {
+3 -2
View File
@@ -1,12 +1,13 @@
import { v } from "convex/values";
import { mutation, query } from "./functions";
import { requireUser } from "./lib/access";
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicSoul } from "./lib/public";
export const isStarred = query({
args: { soulId: v.id("souls") },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return false;
const existing = await ctx.db
.query("soulStars")
.withIndex("by_soul_user", (q) => q.eq("soulId", args.soulId).eq("userId", userId))
+85
View File
@@ -0,0 +1,85 @@
/* @vitest-environment node */
import { getAuthUserId } from "@convex-dev/auth/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isStarred } from "./stars";
import { isStarred as isSoulStarred } from "./soulStars";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
}));
function unwrapHandler(wrapped: unknown) {
const handler = (wrapped as { _handler?: unknown })._handler;
if (typeof handler !== "function") {
throw new Error("Expected Convex test wrapper to expose _handler");
}
return handler;
}
const isStarredHandler = unwrapHandler(isStarred) as (
ctx: unknown,
args: { skillId: string },
) => Promise<boolean>;
const isSoulStarredHandler = unwrapHandler(isSoulStarred) as (
ctx: unknown,
args: { soulId: string },
) => Promise<boolean>;
function makeCtx(options: { user?: Record<string, unknown> | null; existingStar?: unknown }) {
return {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:viewer") return options.user ?? { _id: "users:viewer" };
return null;
}),
query: vi.fn(() => ({
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(options.existingStar ?? null),
})),
})),
},
};
}
beforeEach(() => {
vi.mocked(getAuthUserId).mockReset();
});
describe("stars queries", () => {
it("returns false instead of throwing when skill star auth is stale", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
await expect(
isStarredHandler(makeCtx({ user: null }), { skillId: "skills:demo" }),
).resolves.toBe(false);
});
it("returns false instead of throwing when soul star auth is stale", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
await expect(
isSoulStarredHandler(makeCtx({ user: null }), { soulId: "souls:demo" }),
).resolves.toBe(false);
});
it("still reports existing stars for active users", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
await expect(
isStarredHandler(makeCtx({ existingStar: { _id: "stars:demo" } }), {
skillId: "skills:demo",
}),
).resolves.toBe(true);
});
it("still reports existing soul stars for active users", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
await expect(
isSoulStarredHandler(makeCtx({ existingStar: { _id: "soulStars:demo" } }), {
soulId: "souls:demo",
}),
).resolves.toBe(true);
});
});
+3 -2
View File
@@ -1,13 +1,14 @@
import { v } from "convex/values";
import { internalMutation, mutation, query } from "./functions";
import { requireUser } from "./lib/access";
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicSkill } from "./lib/public";
import { insertStatEvent } from "./skillStatEvents";
export const isStarred = query({
args: { skillId: v.id("skills") },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return false;
const existing = await ctx.db
.query("stars")
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
+15 -5
View File
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -445,7 +452,9 @@ async function queryUsersForPublicList(
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.withIndex("by_active_handle", (q) =>
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
)
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
@@ -899,7 +908,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+2
View File
@@ -92,6 +92,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill versions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
@@ -133,6 +134,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill files", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
+20
View File
@@ -92,6 +92,26 @@ describe("vt activation fallback", () => {
});
describe("vt AV engine fallback verdicts", () => {
it("strips unsupported VT stat keys before caching", () => {
expect(
__test.normalizeVtEngineStats({
"confirmed-timeout": 0,
failure: 2,
harmless: 0,
malicious: 0,
suspicious: 0,
timeout: 0,
"type-unsupported": 10,
undetected: 64,
} as never),
).toEqual({
harmless: 0,
malicious: 0,
suspicious: 0,
undetected: 64,
});
});
it("maps engine verdicts in severity order", () => {
expect(
__test.statusFromAvStats({
+43
View File
@@ -166,6 +166,16 @@ type PackageReleaseScanDoc = Pick<
>;
type PackageScanDoc = Pick<Doc<"packages">, "family" | "isOfficial">;
function normalizeVtEngineStats(stats?: VTAnalysisStats | null) {
if (!stats) return undefined;
return {
malicious: stats.malicious,
suspicious: stats.suspicious,
undetected: stats.undetected,
harmless: stats.harmless,
};
}
function buildPackageUndetectedFallbackAnalysis(
release: PackageReleaseScanDoc,
pkg: PackageScanDoc,
@@ -203,11 +213,14 @@ function buildPackageScanAnalysisFromVtResult(
);
if (aiResult) {
const verdict = normalizeVerdict(aiResult.verdict);
const stats = vtResult.data.attributes.last_analysis_stats;
return {
status: verdictToStatus(verdict),
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
scanner: "code_insight",
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
};
}
@@ -511,6 +524,7 @@ export const scanWithVirusTotal = internalAction({
);
if (aiResult) {
const stats = existingFile.data.attributes.last_analysis_stats;
// File exists and has AI analysis - use the verdict
const verdict = normalizeVerdict(aiResult.verdict);
const status = verdictToStatus(verdict);
@@ -526,6 +540,8 @@ export const scanWithVirusTotal = internalAction({
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
scanner: "code_insight",
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
@@ -766,6 +782,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "stale", checkedAt: Date.now() },
});
}
return;
}
@@ -790,6 +811,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "stale", checkedAt: Date.now() },
});
}
} catch (error) {
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
@@ -803,6 +829,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "error", checkedAt: Date.now() },
});
}
}
},
@@ -914,6 +945,7 @@ export const pollPendingScans = internalAction({
vtAnalysis: {
status,
source,
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
@@ -952,6 +984,7 @@ export const pollPendingScans = internalAction({
// We have a verdict - update the skill
const verdict = normalizeVerdict(aiResult.verdict);
const status = verdictToStatus(verdict);
const stats = vtResult.data.attributes.last_analysis_stats;
console.log(
`[vt:pollPendingScans] Hash ${sha256hash} verdict: ${verdict} -> status: ${status}`,
@@ -965,6 +998,8 @@ export const pollPendingScans = internalAction({
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
scanner: "code_insight",
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
@@ -1046,6 +1081,7 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise<boolea
}
export const __test = {
normalizeVtEngineStats,
statusFromAvStats,
shouldActivateWhenVtUnavailable,
};
@@ -1249,6 +1285,7 @@ export const rescanActiveSkills = internalAction({
vtAnalysis: {
status,
source,
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
@@ -1278,6 +1315,7 @@ export const rescanActiveSkills = internalAction({
const verdict = normalizeVerdict(aiResult.verdict);
const status = verdictToStatus(verdict);
const stats = vtResult.data.attributes.last_analysis_stats;
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
versionId,
@@ -1286,6 +1324,8 @@ export const rescanActiveSkills = internalAction({
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
scanner: "code_insight",
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
@@ -1576,6 +1616,7 @@ export const backfillActiveSkillsVTCache = internalAction({
// Update the version with VT analysis
const verdict = normalizeVerdict(aiResult.verdict);
const status = verdictToStatus(verdict);
const stats = vtResult.data.attributes.last_analysis_stats;
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
versionId,
@@ -1585,6 +1626,8 @@ export const backfillActiveSkillsVTCache = internalAction({
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
scanner: "code_insight",
engineStats: normalizeVtEngineStats(stats),
checkedAt: Date.now(),
},
});
+138
View File
@@ -33,6 +33,14 @@ function mustGetToken() {
return null;
}
function getAdminToken() {
return process.env.CLAWHUB_E2E_ADMIN_TOKEN?.trim() || null;
}
function getUserToken() {
return process.env.CLAWHUB_E2E_USER_TOKEN?.trim() || null;
}
function getRegistry() {
return (
process.env.CLAWHUB_REGISTRY?.trim() ||
@@ -73,7 +81,19 @@ function allowLiveMutations() {
return value === "1" || value?.toLowerCase() === "true";
}
function shouldSeedRoleHelpTokens() {
const value = process.env.CLAWHUB_E2E_SEED_CLI_ROLE_HELP?.trim();
return value === "1" || value?.toLowerCase() === "true";
}
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
const itIfAdminAndUserTokens =
getAdminToken() && getUserToken() || shouldSeedRoleHelpTokens() ? it : it.skip;
type RoleHelpTokens = {
adminToken: string;
userToken: string;
};
async function makeTempConfig(registry: string, token: string | null) {
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
@@ -86,6 +106,67 @@ async function makeTempConfig(registry: string, token: string | null) {
return { dir, path };
}
async function resolveRoleHelpTokens(registry: string): Promise<RoleHelpTokens> {
const adminToken = getAdminToken();
const userToken = getUserToken();
if (adminToken && userToken) return { adminToken, userToken };
if (!shouldSeedRoleHelpTokens()) {
throw new Error(
"Missing CLAWHUB_E2E_ADMIN_TOKEN/CLAWHUB_E2E_USER_TOKEN or CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1",
);
}
if (!isLocalRegistry(registry)) {
throw new Error("CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1 only works against local registries");
}
const result = spawnSync(
"bunx",
["convex", "run", "--no-push", "devSeed:seedCliRoleHelpFixtures"],
{
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
},
);
if (result.status !== 0) {
throw new Error(`Failed to seed role help fixtures:\n${result.stderr || result.stdout}`);
}
const parsed = JSON.parse(extractLastJsonObject(result.stdout)) as {
admin?: { token?: unknown };
user?: { token?: unknown };
};
if (typeof parsed.admin?.token !== "string" || typeof parsed.user?.token !== "string") {
throw new Error("Role help fixture seed did not return admin and user tokens");
}
return { adminToken: parsed.admin.token, userToken: parsed.user.token };
}
function isLocalRegistry(registry: string) {
try {
const hostname = new URL(registry).hostname;
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
} catch {
return false;
}
}
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
JSON.parse(candidate);
return candidate;
} catch {
// Keep scanning for the actual JSON payload if Convex printed status lines first.
}
}
throw new Error(`No JSON object in convex run output:\n${output}`);
}
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
@@ -197,6 +278,63 @@ describe("clawhub e2e", () => {
}
});
itIfAdminAndUserTokens("shows staff CLI commands only in admin help", async () => {
const registry = getRegistry();
const site = getSite();
const { adminToken, userToken } = await resolveRoleHelpTokens(registry);
async function expectRole(token: string, expectedRole: "admin" | "user") {
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
const response = await fetchWithTimeout(whoamiUrl.toString(), {
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
expect(response.ok).toBe(true);
const whoami = parseArk(
ApiV1WhoamiResponseSchema,
(await response.json()) as unknown,
"Whoami",
);
expect(whoami.user.role).toBe(expectedRole);
}
await expectRole(adminToken, "admin");
await expectRole(userToken, "user");
const adminCfg = await makeTempConfig(registry, adminToken);
const userCfg = await makeTempConfig(registry, userToken);
try {
const baseEnv = { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" };
const adminResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: adminCfg.path },
encoding: "utf8",
},
);
const userResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: userCfg.path },
encoding: "utf8",
},
);
expect(adminResult.status).toBe(0);
expect(adminResult.stdout).toContain("ban-user");
expect(adminResult.stdout).toContain("set-role");
expect(userResult.status).toBe(0);
expect(userResult.stdout).not.toContain("ban-user");
expect(userResult.stdout).not.toContain("set-role");
} finally {
await rm(adminCfg.dir, { recursive: true, force: true });
await rm(userCfg.dir, { recursive: true, force: true });
}
});
it("sync dry-run finds skills from an explicit root", async () => {
const registry = getRegistry();
const site = getSite();
+2 -7
View File
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
const navLabels = ["Skills", "Plugins", "Search"];
const navLabels = ["Skills", "Plugins"];
test("skills loads without error", async ({ page }) => {
const errors = trackRuntimeErrors(page);
@@ -13,7 +13,7 @@ test("skills loads without error", async ({ page }) => {
test("souls loads without error", async ({ page }) => {
const errors = trackRuntimeErrors(page);
await page.goto("/souls", { waitUntil: "domcontentloaded" });
await expect(page.locator("h1", { hasText: "Souls" })).toBeVisible();
await expect(page.locator("h1", { hasText: "SOUL.md discovery is on deck" })).toBeVisible();
await expectHealthyPage(page, errors);
});
@@ -35,11 +35,6 @@ test("header menu routes render", async ({ page }) => {
await expect(page).toHaveURL(/\/plugins(\?|$)/);
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
}
if (label === "Search") {
await expect(page).toHaveURL(/\/skills(\?|$)/);
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
}
}
await expectHealthyPage(page, errors);
+6
View File
@@ -39,10 +39,15 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@@ -64,6 +69,7 @@
"class-variance-authority": "^0.7.1",
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"convex": "^1.34.1",
"convex-helpers": "^0.1.114",
"fflate": "^0.8.2",
+29 -2
View File
@@ -2,6 +2,7 @@
import { stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Command } from "commander";
import { shouldShowAdminCommandsInHelp } from "./cli/adminHelp.js";
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
@@ -23,6 +24,7 @@ import {
cmdSetPackageTrustedPublisher,
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
import {
cmdExplore,
cmdInstall,
@@ -47,6 +49,9 @@ import type { GlobalOpts } from "./cli/types.js";
import { fail } from "./cli/ui.js";
import { readGlobalConfig } from "./config.js";
const showAdminCommandsInHelp = await shouldShowAdminCommandsInHelp();
const adminCommandOptions = showAdminCommandsInHelp ? undefined : { hidden: true };
const program = new Command()
.name("clawhub")
.description(
@@ -451,6 +456,17 @@ trustedPublisherCmd
await cmdDeletePackageTrustedPublisher(opts, name, options);
});
packageCmd
.command("rescan")
.description("Request a security rescan for the latest published package release")
.argument("<name>", "Package name")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanPackage(opts, name, options, isInputAllowed());
});
skill
.command("rename")
.description("Rename a published skill and keep the old slug as a redirect")
@@ -473,8 +489,19 @@ skill
await cmdMergeSkill(opts, sourceSlug, targetSlug, options, isInputAllowed());
});
skill
.command("rescan")
.description("Request a security rescan for the latest published skill version")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanSkill(opts, slug, options, isInputAllowed());
});
program
.command("ban-user")
.command("ban-user", adminCommandOptions)
.description("Ban a user and delete owned skills (moderator/admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.option("--id", "Treat argument as user id")
@@ -487,7 +514,7 @@ program
});
program
.command("set-role")
.command("set-role", adminCommandOptions)
.description("Change a user role (admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.argument("<role>", "user | moderator | admin")
@@ -0,0 +1,87 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
const mockReadGlobalConfig = vi.fn(
async () => null as { registry?: string; token?: string } | null,
);
vi.mock("../config.js", () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
}));
const mockApiRequest = vi.fn();
vi.mock("../http.js", () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
}));
const { isHelpRequest, shouldShowAdminCommandsInHelp } = await import("./adminHelp");
afterEach(() => {
vi.clearAllMocks();
});
describe("admin help gating", () => {
it("treats root help-like invocations as help requests", () => {
expect(isHelpRequest(["node", "clawhub", "--help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "search", "weather"])).toBe(false);
});
it("does not hide commands for normal command execution", async () => {
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "ban-user", "demo"],
}),
).resolves.toBe(true);
expect(mockReadGlobalConfig).not.toHaveBeenCalled();
});
it("hides admin commands from help when logged out", async () => {
mockReadGlobalConfig.mockResolvedValueOnce(null);
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
expect(mockApiRequest).not.toHaveBeenCalled();
});
it("shows admin commands in help for stored admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_admin",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "admin" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(true);
expect(mockApiRequest).toHaveBeenCalledWith(
"https://registry.example",
expect.objectContaining({ path: "/api/v1/whoami", token: "clh_admin" }),
expect.anything(),
);
});
it("hides admin commands in help for non-admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_user",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "moderator" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { readGlobalConfig } from "../config.js";
import { apiRequest } from "../http.js";
import { ApiRoutes, ApiV1WhoamiResponseSchema } from "../schema/index.js";
import { DEFAULT_REGISTRY } from "./registry.js";
type AdminHelpDeps = {
argv?: string[];
env?: NodeJS.ProcessEnv;
};
export function isHelpRequest(argv: string[] = process.argv) {
const args = argv.slice(2);
return args.length === 0 || args[0] === "help" || args.includes("--help") || args.includes("-h");
}
export async function shouldShowAdminCommandsInHelp(deps: AdminHelpDeps = {}) {
if (!isHelpRequest(deps.argv)) return true;
const env = deps.env ?? process.env;
const cfg = await readGlobalConfig();
const token = cfg?.token?.trim();
if (!token) return false;
const registry =
readFlagValue(deps.argv ?? process.argv, "--registry")?.trim() ||
env.CLAWHUB_REGISTRY?.trim() ||
env.CLAWDHUB_REGISTRY?.trim() ||
cfg?.registry?.trim() ||
DEFAULT_REGISTRY;
try {
const whoami = await apiRequest(
registry,
{ method: "GET", path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
);
return whoami.user.role === "admin";
} catch {
return false;
}
}
function readFlagValue(argv: string[], flag: string) {
const args = argv.slice(2);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) return args[index + 1];
if (arg?.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
}
return undefined;
}
@@ -0,0 +1,91 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../registry.js", () => registryMocks.moduleFactory());
vi.mock("../../http.js", () => httpMocks.moduleFactory());
vi.mock("../ui.js", () => uiMocks.moduleFactory());
const { cmdRescanPackage, cmdRescanSkill } = await import("./rescan");
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
const response = {
ok: true,
targetKind: "skill",
name: "demo",
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
afterEach(() => {
vi.clearAllMocks();
mockLog.mockClear();
});
describe("rescan commands", () => {
it("requires --yes when input is disabled", async () => {
await expect(cmdRescanSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
await expect(cmdRescanPackage(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("posts skill rescans to the skill rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "Demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/rescan" }),
expect.anything(),
);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Requested skill rescan for demo@1.2.3 (2/3 remaining)",
);
});
it("posts package rescans to the package rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
...response,
targetKind: "package",
name: "@scope/demo",
});
await cmdRescanPackage(makeGlobalOpts(), "@scope/demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: "POST",
path: "/api/v1/packages/%40scope%2Fdemo/rescan",
}),
expect.anything(),
);
});
it("prints JSON output", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "demo", { yes: true, json: true }, false);
expect(uiMocks.spinner.stop).toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
});
});
@@ -0,0 +1,91 @@
import { apiRequest } from "../../http.js";
import {
ApiRoutes,
ApiV1RescanResponseSchema,
parseArk,
type ApiV1RescanResponse,
} from "../../schema/index.js";
import { requireAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import type { GlobalOpts } from "../types.js";
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
type RescanTargetKind = "skill" | "package";
type RescanOptions = {
yes?: boolean;
json?: boolean;
};
export async function cmdRescanSkill(
opts: GlobalOpts,
slugArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase();
if (!slug) fail("Slug required");
return requestRescan(opts, "skill", slug, options, inputAllowed);
}
export async function cmdRescanPackage(
opts: GlobalOpts,
nameArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const name = nameArg.trim();
if (!name) fail("Package name required");
return requestRescan(opts, "package", name, options, inputAllowed);
}
async function requestRescan(
opts: GlobalOpts,
targetKind: RescanTargetKind,
name: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const allowPrompt = isInteractive() && inputAllowed !== false;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Request latest ${targetKind} rescan for ${name}?`);
if (!ok) return undefined;
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner(`Requesting ${targetKind} rescan for ${name}`);
try {
const result = await apiRequest(
registry,
{
method: "POST",
path: buildRescanPath(targetKind, name),
token,
},
ApiV1RescanResponseSchema,
);
const parsed = parseArk(ApiV1RescanResponseSchema, result, "Rescan response");
if (options.json) {
spinner.stop();
console.log(JSON.stringify(parsed, null, 2));
} else {
spinner.succeed(formatRescanSuccess(parsed));
}
return parsed;
} catch (error) {
spinner.fail(formatError(error));
throw error;
}
}
function buildRescanPath(targetKind: RescanTargetKind, name: string) {
const root = targetKind === "skill" ? ApiRoutes.skills : ApiRoutes.packages;
return `${root}/${encodeURIComponent(name)}/rescan`;
}
function formatRescanSuccess(result: ApiV1RescanResponse) {
const label = result.targetKind === "skill" ? "skill" : "package";
return `OK. Requested ${label} rescan for ${result.name}@${result.version} (${result.remainingRequests}/${result.maxRequests} remaining)`;
}
+13
View File
@@ -134,6 +134,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -273,6 +274,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+1
View File
@@ -134,6 +134,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
};
}, {}>;
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+1
View File
@@ -110,6 +110,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
export const ApiV1UserSearchResponseSchema = type({
File diff suppressed because one or more lines are too long
+13
View File
@@ -135,6 +135,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -282,6 +283,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+11 -6
View File
@@ -124,7 +124,7 @@ describe("Header", () => {
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
@@ -132,22 +132,27 @@ describe("Header", () => {
render(<Header />);
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
);
expect(setModeMock).toHaveBeenCalledWith("dark");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows Home above Skills in the mobile menu", () => {
+97 -9
View File
@@ -15,17 +15,17 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
(error: unknown) =>
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
);
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
readme: string | null;
rateLimited:
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
rateLimited: {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
};
let paramsMock = { name: "demo-plugin" };
@@ -59,6 +59,14 @@ vi.mock("@tanstack/react-router", () => ({
useParams: () => paramsMock,
useLoaderData: () => loaderDataMock,
}),
useRouterState: ({
select,
}: {
select?: (state: { location: { pathname: string } }) => string;
}) =>
select
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
: `/plugins/${paramsMock.name}`,
Link: ({
children,
to,
@@ -73,6 +81,15 @@ vi.mock("@tanstack/react-router", () => ({
),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../lib/packageApi", () => ({
fetchPackageDetail: vi.fn(),
fetchPackageReadme: vi.fn(),
@@ -86,7 +103,13 @@ vi.mock("../lib/packageApi", () => ({
}));
vi.mock("../components/MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string; className?: string; highlight?: boolean }) => <div>{children}</div>,
MarkdownPreview: ({
children,
}: {
children: string;
className?: string;
highlight?: boolean;
}) => <div>{children}</div>,
}));
async function loadRoute() {
@@ -128,6 +151,14 @@ describe("plugin detail route", () => {
rateLimited: null,
};
isRateLimitedPackageApiErrorMock.mockClear();
useQueryMock.mockReset();
useQueryMock.mockReturnValue(undefined);
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
});
it("hides download actions when the plugin has no latest release", async () => {
@@ -188,9 +219,66 @@ describe("plugin detail route", () => {
render(<Component />);
expect(screen.getByText("Security Scan")).toBeTruthy();
expect(screen.getByText("Security Scans")).toBeTruthy();
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /VirusTotal.*Benign/i }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security/virustotal",
);
expect(
screen.getByRole("link", { name: /Static analysis.*Benign/i }).getAttribute("href"),
).toBe("/plugins/demo-plugin/security/static-analysis");
});
it("shows owner-only plugin rescan state in the security summary", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:1" },
});
useQueryMock.mockReturnValue({
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
});
loaderDataMock = {
detail: loaderDataMock.detail,
version: {
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
},
version: {
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [],
compatibility: null,
capabilities: null,
verification: null,
sha256hash: "a".repeat(64),
vtAnalysis: null,
llmAnalysis: null,
staticScan: null,
},
},
readme: null,
rateLimited: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
+113 -15
View File
@@ -71,7 +71,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -222,18 +222,105 @@ describe("SkillDetailPage", () => {
/>,
);
const installHeading = await screen.findByRole("heading", { name: "Install with OpenClaw" });
const scanDisclaimer = screen.getByText(
/Like a lobster shell, security has layers — review code before you run it\./i,
await screen.findByRole("heading", { name: "Install" });
const securityHeading = screen.getByRole("heading", { name: "Security Scans" });
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(securityHeading).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
});
it("shows an owner rescan action in the security summary for owned skills", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: ownerId, role: "user" },
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args && !("limit" in args)) {
return {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
};
}
if (args && typeof args === "object" && "limit" in args) return [];
return undefined;
});
render(
<SkillDetailPage
slug="weather"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
files: [],
sha256hash: "abc123",
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
}}
/>,
);
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.getByText("npx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(
installHeading.compareDocumentPosition(scanDisclaimer) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -349,7 +436,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -506,7 +593,7 @@ describe("SkillDetailPage", () => {
expect(screen.getByText(/Report skill/i)).toBeTruthy();
});
it("shows owner tools for the skill owner", async () => {
it("links owner tools from the detail page and renders them on settings", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -514,7 +601,11 @@ describe("SkillDetailPage", () => {
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "ownerUserId" in args) {
if (
args &&
typeof args === "object" &&
("ownerUserId" in args || "ownerPublisherId" in args)
) {
return [
{ _id: "skills:1", slug: "weather", displayName: "Weather" },
{ _id: "skills:2", slug: "weather-pro", displayName: "Weather Pro" },
@@ -547,7 +638,14 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
const { unmount } = render(<SkillDetailPage slug="weather" />);
const settingsLink = await screen.findByRole("link", { name: /settings/i });
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
expect(screen.queryByText(/Owner tools/i)).toBeNull();
unmount();
render(<SkillDetailPage slug="weather" mode="settings" />);
expect(await screen.findByText(/Owner tools/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /Rename and redirect/i })).toBeTruthy();
+61
View File
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
type DetailPageShellProps = {
children: ReactNode;
className?: string;
};
type DetailHeroProps = {
main: ReactNode;
sidebar?: ReactNode;
children?: ReactNode;
className?: string;
topClassName?: string;
mainClassName?: string;
sidebarClassName?: string;
};
type DetailBodyProps = {
children: ReactNode;
className?: string;
mainClassName?: string;
};
export function DetailPageShell({ children, className }: DetailPageShellProps) {
return <div className={cn("skill-detail-stack", className)}>{children}</div>;
}
export function DetailHero({
main,
sidebar,
children,
className,
topClassName,
mainClassName,
sidebarClassName,
}: DetailHeroProps) {
return (
<div className={cn("skill-hero", className)}>
<div className={cn("skill-hero-top", topClassName)}>
<div className="skill-hero-layout">
<div className={cn("skill-hero-main", mainClassName)}>
{main}
{children ? <div className="skill-hero-main-extra">{children}</div> : null}
</div>
{sidebar ? (
<aside className={cn("skill-hero-sidebar", sidebarClassName)}>{sidebar}</aside>
) : null}
</div>
</div>
</div>
);
}
export function DetailBody({ children, className, mainClassName }: DetailBodyProps) {
return (
<div className={cn("detail-layout", className)}>
<div className={cn("detail-main", mainClassName)}>{children}</div>
</div>
);
}
@@ -0,0 +1,38 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
describe("DetailSecuritySummary", () => {
it("shows a disabled spinner button while a rescan is in progress", () => {
render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
rescanState={{
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: false,
inProgressRequest: {
_id: "rescanRequests:1",
targetKind: "skill",
targetVersion: "1.0.0",
status: "in_progress",
createdAt: 1,
updatedAt: 1,
},
latestRequest: null,
}}
onRequestRescan={vi.fn()}
/>,
);
const button = screen.getByRole("button", { name: "Scanning" });
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.getAttribute("title")).toBe("A rescan is already in progress.");
expect(button.querySelector(".animate-spin")?.className).toContain(
"[animation-duration:2.4s]",
);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { useState } from "react";
import {
getScanStatusInfo,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge, type BadgeProps } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type RescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
status: "in_progress" | "completed" | "failed";
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export type DetailRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: RescanRequest | null;
latestRequest: RescanRequest | null;
};
type DetailSecuritySummaryProps = {
scannerBasePath: string;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
rescanState?: DetailRescanState | null;
onRequestRescan?: (() => Promise<void>) | null;
};
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
if (staticScan?.status) return staticScan.status;
return "pending";
}
function badgeVariantForScanStatus(status: string): BadgeProps["variant"] {
const normalized = status.toLowerCase();
if (normalized === "clean" || normalized === "benign") return "success";
if (normalized === "suspicious") return "warning";
if (normalized === "malicious" || normalized === "error") return "destructive";
if (normalized === "pending" || normalized === "queued" || normalized === "loading") {
return "pending";
}
return "compact";
}
function ScannerRow({ href, label, status }: { href: string; label: string; status: string }) {
const info = getScanStatusInfo(status);
return (
<a
href={href}
className="flex min-w-0 items-center justify-between gap-3 rounded-[var(--radius-sm)] px-1 py-2 text-sm !no-underline hover:bg-[color:var(--surface-muted)] hover:!no-underline"
>
<span className="flex min-w-0 items-center gap-2 font-semibold text-[color:var(--ink)]">
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant={badgeVariantForScanStatus(status)}>{info.label}</Badge>
</span>
</a>
);
}
function rescanDisabledReason(state: DetailRescanState | null | undefined) {
if (!state) return null;
if (state.inProgressRequest) return "A rescan is already in progress.";
if (state.remainingRequests <= 0) {
return `Rescan limit reached (${state.requestCount}/${state.maxRequests}).`;
}
if (!state.canRequest) return "This release is not eligible for another rescan.";
return null;
}
export function DetailSecuritySummary({
scannerBasePath,
vtAnalysis,
llmAnalysis,
staticScan,
rescanState,
onRequestRescan,
}: DetailSecuritySummaryProps) {
const [isRequestingRescan, setIsRequestingRescan] = useState(false);
const vtStatus = vtAnalysis?.verdict ?? vtAnalysis?.status ?? "pending";
const llmStatus = llmAnalysis?.verdict ?? llmAnalysis?.status ?? "pending";
const staticStatus = statusFromStaticScan(staticScan);
const rescanButtonDisabledReason = rescanDisabledReason(rescanState);
const isScanInProgress = Boolean(rescanState?.inProgressRequest);
const rescanButtonLabel = isScanInProgress
? "Scanning"
: isRequestingRescan
? "Requesting..."
: "Rescan";
async function handleRequestRescan() {
if (!onRequestRescan || rescanButtonDisabledReason || isRequestingRescan) return;
setIsRequestingRescan(true);
try {
await onRequestRescan();
} finally {
setIsRequestingRescan(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
Security Scans
{rescanState && onRequestRescan ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-center sm:ml-auto sm:w-auto"
loading={isRequestingRescan || isScanInProgress}
disabled={Boolean(rescanButtonDisabledReason)}
title={rescanButtonDisabledReason ?? "Request a fresh scan"}
onClick={() => void handleRequestRescan()}
>
{rescanButtonLabel}
</Button>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<ScannerRow href={`${scannerBasePath}/virustotal`} label="VirusTotal" status={vtStatus} />
<ScannerRow href={`${scannerBasePath}/openclaw`} label="ClawScan" status={llmStatus} />
<ScannerRow
href={`${scannerBasePath}/static-analysis`}
label="Static analysis"
status={staticStatus}
/>
</div>
</CardContent>
</Card>
);
}
+65 -142
View File
@@ -1,19 +1,17 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Button } from "./ui/button";
@@ -32,7 +30,6 @@ import {
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
@@ -40,13 +37,10 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
@@ -64,7 +58,6 @@ export default function Header() {
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
@@ -72,24 +65,11 @@ export default function Header() {
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
applyTheme(next, theme);
setMode(next);
};
const handleNavSearch = (e: React.FormEvent) => {
@@ -166,48 +146,19 @@ export default function Header() {
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<div className="mobile-nav-section-title">Theme</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setThemeMode(nextThemeMode);
setMobileMenuOpen(false);
}}
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
{mode === "system" ? "System theme" : `${mode} theme`}
</button>
</div>
</SheetContent>
@@ -237,6 +188,32 @@ export default function Header() {
/>
</form>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix),
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</nav>
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
@@ -246,41 +223,16 @@ export default function Header() {
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-cycle-group" aria-label="Theme controls">
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
<div className="theme-toggle">
<button
type="button"
className="theme-cycle-button"
onClick={() => setThemeMode(nextThemeMode)}
aria-label={`Toggle theme. Current: ${mode}`}
title={`Theme: ${mode}`}
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
@@ -296,6 +248,9 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
@@ -336,9 +291,7 @@ export default function Header() {
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
Sign In
</Button>
</>
)}
@@ -360,52 +313,6 @@ export default function Header() {
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
</header>
);
@@ -424,6 +331,22 @@ function getThemeModeIcon(mode: "system" | "light" | "dark") {
return Moon;
case "system":
default:
return Monitor;
return Sun;
}
}
function getResolvedThemeMode(): "light" | "dark" {
if (typeof document !== "undefined") {
const resolved = document.documentElement.dataset.themeResolved;
if (resolved === "light" || resolved === "dark") return resolved;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
}
function getNextThemeMode(mode: "system" | "light" | "dark"): "light" | "dark" {
const resolved = mode === "system" ? getResolvedThemeMode() : mode;
return resolved === "dark" ? "light" : "dark";
}
+3 -1
View File
@@ -37,11 +37,13 @@ export function InstallCopyButton({
label = "Copy",
ariaLabel,
className,
showLabel = true,
}: {
text: string;
label?: string;
ariaLabel?: string;
className?: string;
showLabel?: boolean;
}) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const resetTimeoutRef = useRef<number | null>(null);
@@ -94,7 +96,7 @@ export function InstallCopyButton({
) : (
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span aria-live="polite">{buttonLabel}</span>
{showLabel ? <span aria-live="polite">{buttonLabel}</span> : null}
</Button>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { ArrowLeft, Clock, ExternalLink, Fingerprint } from "lucide-react";
import type { ReactNode } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import {
getScanStatusInfo,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
export type ScannerSlug = "virustotal" | "openclaw" | "static-analysis";
type OwnerRef = {
_id?: string;
handle?: string | null;
};
type EntityRef = {
kind: "skill" | "plugin";
title: string;
name: string;
version?: string | null;
owner?: OwnerRef | null;
ownerUserId?: Id<"users"> | null;
ownerPublisherId?: Id<"publishers"> | null;
detailPath: string;
};
type SecurityScannerPageProps = {
scanner: ScannerSlug;
entity: EntityRef;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
source?: Record<string, unknown> | null;
};
const SCANNER_LABELS: Record<ScannerSlug, string> = {
virustotal: "VirusTotal",
openclaw: "ClawScan",
"static-analysis": "Static analysis",
};
const SCANNER_SUMMARIES: Record<ScannerSlug, string> = {
virustotal: "External malware reputation and Code Insight signals for this exact artifact hash.",
openclaw: "ClawHub's context-aware review of the artifact, metadata, and declared behavior.",
"static-analysis": "Deterministic local checks for risky code patterns and metadata mismatches.",
};
function formatTime(value?: number | null) {
if (!value) return "Not checked yet";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatValue(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.length ? value.map(formatValue).filter(Boolean).join(", ") : null;
return JSON.stringify(value);
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
if (children === null || children === undefined || children === "") return null;
return (
<div className="grid gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid-cols-[180px_1fr] sm:gap-4">
<dt className="text-sm font-semibold text-[color:var(--ink-soft)]">{label}</dt>
<dd className="min-w-0 break-words text-sm text-[color:var(--ink)]">{children}</dd>
</div>
);
}
function getScannerStatus(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "pending";
if (props.scanner === "openclaw") return props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "pending";
return props.staticScan?.status ?? "pending";
}
function getCheckedAt(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return props.vtAnalysis?.checkedAt ?? null;
if (props.scanner === "openclaw") return props.llmAnalysis?.checkedAt ?? null;
return props.staticScan?.checkedAt ?? null;
}
export function SecurityScannerPage(props: SecurityScannerPageProps) {
const label = SCANNER_LABELS[props.scanner];
const status = getScannerStatus(props);
const statusInfo = getScanStatusInfo(status);
const checkedAt = getCheckedAt(props);
const vtUrl = props.sha256hash ? `https://www.virustotal.com/gui/file/${props.sha256hash}` : null;
const sourceRepo = formatValue(props.source?.repository ?? props.source?.repo ?? props.source?.url);
const sourceCommit = formatValue(props.source?.commit ?? props.source?.sha);
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col gap-3">
<Button asChild variant="ghost" size="sm" className="w-fit">
<a href={props.entity.detailPath}>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
Back to {props.entity.kind}
</a>
</Button>
<div className="flex flex-col gap-3">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge>{props.entity.kind === "skill" ? "Skill" : "Plugin"}</Badge>
{props.entity.version ? <Badge variant="compact">v{props.entity.version}</Badge> : null}
</div>
<h1 className="m-0 break-words font-display text-3xl font-bold text-[color:var(--ink)]">
{label} security
</h1>
<p className="mt-2 max-w-3xl text-sm text-[color:var(--ink-soft)]">
{props.entity.title} · {SCANNER_SUMMARIES[props.scanner]}
</p>
</div>
</div>
</div>
<div className="security-scanner-layout">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Scanner verdict</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<span className={`scan-result-status ${statusInfo.className}`}>
{statusInfo.label}
</span>
<span className="inline-flex items-center gap-1 text-xs text-[color:var(--ink-soft)]">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{formatTime(checkedAt)}
</span>
</div>
<dl className="mt-2 flex flex-col gap-3">
{props.scanner === "virustotal" ? (
<>
<DetailRow label="Hash">
{props.sha256hash ? (
<span className="inline-flex max-w-full items-center gap-2 break-all font-mono text-xs">
<Fingerprint className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{props.sha256hash}
</span>
) : (
"No artifact hash recorded."
)}
</DetailRow>
<DetailRow label="Source">{props.vtAnalysis?.source ?? "File reputation"}</DetailRow>
<DetailRow label="Verdict">{props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "Pending"}</DetailRow>
<DetailRow label="Code Insight">{props.vtAnalysis?.analysis ?? null}</DetailRow>
<DetailRow label="External report">
{vtUrl ? (
<a
href={vtUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
View on VirusTotal
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : (
"Unavailable until an artifact hash is recorded."
)}
</DetailRow>
</>
) : null}
{props.scanner === "openclaw" ? (
<>
<DetailRow label="Verdict">{props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "Pending"}</DetailRow>
<DetailRow label="Confidence">{props.llmAnalysis?.confidence ?? "Not reported"}</DetailRow>
<DetailRow label="Model">{props.llmAnalysis?.model ?? "Not reported"}</DetailRow>
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet."}</DetailRow>
<DetailRow label="Guidance">{props.llmAnalysis?.guidance ?? null}</DetailRow>
<DetailRow label="Findings">
{props.llmAnalysis?.findings ? (
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-xs">
{props.llmAnalysis.findings}
</pre>
) : null}
</DetailRow>
</>
) : null}
{props.scanner === "static-analysis" ? (
<>
<DetailRow label="Summary">
{props.staticScan?.summary ?? "No static analysis result has been recorded yet."}
</DetailRow>
<DetailRow label="Reason codes">
{props.staticScan?.reasonCodes?.length ? (
<div className="flex flex-wrap gap-1.5">
{props.staticScan.reasonCodes.map((code) => (
<Badge key={code} variant="compact">
{code}
</Badge>
))}
</div>
) : (
"None"
)}
</DetailRow>
<DetailRow label="Engine">{props.staticScan?.engineVersion ?? "Not reported"}</DetailRow>
</>
) : null}
<DetailRow label="Source repository">{sourceRepo}</DetailRow>
<DetailRow label="Source commit">
{sourceCommit ? <span className="font-mono text-xs">{sourceCommit}</span> : null}
</DetailRow>
</dl>
</CardContent>
</Card>
{props.scanner === "openclaw" && props.llmAnalysis?.dimensions?.length ? (
<Card>
<CardHeader>
<CardTitle>Review Dimensions</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3">
{props.llmAnalysis.dimensions.map((dimension) => (
<DetailRow key={dimension.name} label={dimension.label}>
<div className="flex flex-col gap-1">
<Badge variant="compact" className="w-fit">
{dimension.rating}
</Badge>
<span>{dimension.detail}</span>
</div>
</DetailRow>
))}
</dl>
</CardContent>
</Card>
) : null}
{props.scanner === "static-analysis" && props.staticScan?.findings?.length ? (
<Card>
<CardHeader>
<CardTitle>Evidence</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{props.staticScan.findings.map((finding, index) => (
<div
key={`${finding.code}-${finding.file}-${finding.line}-${index}`}
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge variant="compact">{finding.severity}</Badge>
<span className="break-all font-mono text-xs text-[color:var(--ink-soft)]">
{finding.file}:{finding.line}
</span>
</div>
<div className="text-sm font-semibold text-[color:var(--ink)]">
{finding.message}
</div>
<pre className="mt-2 whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[color:var(--surface)] p-2 font-mono text-xs text-[color:var(--ink-soft)]">
{finding.evidence || finding.code}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
<aside className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Artifact</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3">
<DetailRow label="Package">{props.entity.name}</DetailRow>
<DetailRow label="Version">{props.entity.version ?? "Latest"}</DetailRow>
<DetailRow label="Hash">
{props.sha256hash ? <span className="break-all font-mono text-xs">{props.sha256hash}</span> : "Not recorded"}
</DetailRow>
</dl>
</CardContent>
</Card>
</aside>
</div>
</div>
</main>
);
}
+6 -6
View File
@@ -42,8 +42,8 @@ describe("SignInButton", () => {
it("starts GitHub sign-in with the current relative URL by default", async () => {
signInMock.mockResolvedValue({ signingIn: true });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(signInMock).toHaveBeenCalledWith("github", {
@@ -57,8 +57,8 @@ describe("SignInButton", () => {
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
signInMock.mockResolvedValue({ signingIn: false });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
@@ -70,8 +70,8 @@ describe("SignInButton", () => {
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
+3 -2
View File
@@ -12,14 +12,16 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
export function SignInButton({
redirectTo,
children = "Sign in with GitHub",
children = "Sign In",
...props
}: SignInButtonProps) {
const { signIn } = useAuthActions();
return (
<Button
{...props}
type="button"
variant="primary"
onClick={() => {
clearAuthError();
const next = redirectTo ?? getCurrentRelativeUrl();
@@ -35,7 +37,6 @@ export function SignInButton({
);
});
}}
{...props}
>
{children}
</Button>
+228 -95
View File
@@ -1,17 +1,21 @@
import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import type { ComponentProps } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getUserFacingConvexError } from "../lib/convexError";
import { canManageSkill, isModerator } from "../lib/roles";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { DetailBody, DetailPageShell } from "./DetailPageShell";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,16 +26,22 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type SkillDetailPageProps = {
slug: string;
canonicalOwner?: string;
redirectToCanonical?: boolean;
initialData?: SkillPageInitialData | null;
mode?: "detail" | "settings";
};
type SkillFile = Doc<"skillVersions">["files"][number];
const SHOW_SKILL_COMMENTS = false;
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
@@ -65,6 +75,7 @@ export function SkillDetailPage({
canonicalOwner,
redirectToCanonical,
initialData,
mode = "detail",
}: SkillDetailPageProps) {
const navigate = useNavigate();
const { isAuthenticated, me } = useAuthStatus();
@@ -83,6 +94,7 @@ export function SkillDetailPage({
const reportSkill = useMutation(api.skills.report);
const updateTags = useMutation(api.skills.updateTags);
const deleteTags = useMutation(api.skills.deleteTags);
const requestRescan = useMutation(api.skills.requestRescan);
const getReadme = useAction(api.skills.getReadme);
const myPublishers = useQuery(api.publishers.listMine) as
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
@@ -145,6 +157,11 @@ export function SkillDetailPage({
: { ownerUserId: skill.ownerUserId, limit: 100 }
: "skip",
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
const canViewOwnerRescanState = isOwner || me?.role === "admin";
const rescanState = useQuery(
api.skills.getRescanState,
canViewOwnerRescanState && skill ? { skillId: skill._id } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
const ownerHandle = owner?.handle ?? null;
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
@@ -237,7 +254,10 @@ export function SkillDetailPage({
useEffect(() => {
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
if (
latestVersion &&
!(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))
) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
@@ -325,19 +345,36 @@ export function SkillDetailPage({
}
};
const submitRescanRequest = async () => {
if (!skill) return;
try {
await requestRescan({ skillId: skill._id });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
<main className="section detail-page-section" aria-busy="true">
<div role="status" aria-label="Loading skill details">
<SkillDetailSkeleton />
</div>
</main>
);
}
if (result === null || !skill) {
return (
<main className="section">
<main className="section detail-page-section">
<Card>Skill not found.</Card>
</main>
);
@@ -353,10 +390,25 @@ export function SkillDetailPage({
latestTagVersionId === null
? []
: tagEntries.filter(([, versionId]) => versionId !== latestTagVersionId);
const securitySummary = latestVersion ? (
<DetailSecuritySummary
scannerBasePath={`/${encodeURIComponent(
ownerParam ?? ownerHandle ?? "unknown",
)}/${encodeURIComponent(skill.slug)}/security`}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={canViewOwnerRescanState ? submitRescanRequest : null}
/>
) : null;
const detailPath = `/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(skill.slug)}`;
const settingsHref = canManage ? `${detailPath}/settings` : null;
return (
<main className="section">
<div className="skill-detail-stack">
<main className="section detail-page-section">
<DetailPageShell>
<SkillHeader
skill={skill}
owner={owner}
@@ -384,102 +436,183 @@ export function SkillDetailPage({
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={currentTagEntries}
historicalTagEntries={historicalTagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
sidebarContent={securitySummary}
settingsHref={settingsHref}
>
{mode === "detail" ? (
<>
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
</Card>
) : null}
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">{configExample}</pre>
</Card>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={currentTagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
<Card className="skill-tag-card">
<CardHeader>
<CardTitle>Version tags</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-tag-row">
{currentTagEntries.length === 0 ? (
<span className="section-subtitle m-0">No tags yet.</span>
) : (
currentTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
submitTag();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => setTagName(event.target.value)}
placeholder="latest..."
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) =>
setTagVersionId(event.target.value as Id<"skillVersions">)
}
>
{(versions ?? []).map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">Update Tag</Button>
</form>
) : null}
</CardContent>
</Card>
{SHOW_SKILL_COMMENTS ? (
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">Comments</h2>
<p className="section-subtitle mt-3 mb-0">Loading comments...</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
) : null}
</>
) : null}
</SkillHeader>
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<ClientOnly
fallback={
{mode === "settings" ? (
<DetailBody>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : (
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<h2 className="section-title text-[1.2rem] m-0">Settings unavailable</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
Only the skill owner can manage these settings.
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
</div>
)}
</DetailBody>
) : null}
</DetailPageShell>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
+154 -260
View File
@@ -1,14 +1,16 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import { Package } from "lucide-react";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
import type { ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { timeAgo } from "../lib/timeAgo";
import { DetailHero } from "./DetailPageShell";
import { SkillInstallCard } from "./SkillInstallCard";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
@@ -63,18 +65,11 @@ type SkillHeaderProps = {
hasPluginBundle: boolean;
configRequirements: ClawdisSkillMetadata["config"] | undefined;
cliHelp: string | undefined;
tagEntries: Array<[string, Id<"skillVersions">]>;
historicalTagEntries: Array<[string, Id<"skillVersions">]>;
versionById: Map<Id<"skillVersions">, Doc<"skillVersions">>;
tagName: string;
onTagNameChange: (value: string) => void;
tagVersionId: Id<"skillVersions"> | "";
onTagVersionChange: (value: Id<"skillVersions"> | "") => void;
onTagSubmit: () => void;
onTagDelete: (tag: string) => void;
tagVersions: Doc<"skillVersions">[];
clawdis: ClawdisSkillMetadata | undefined;
osLabels: string[];
sidebarContent?: ReactNode;
settingsHref?: string | null;
children?: ReactNode;
};
export function SkillHeader({
@@ -104,28 +99,13 @@ export function SkillHeader({
hasPluginBundle,
configRequirements,
cliHelp,
tagEntries,
historicalTagEntries,
versionById,
tagName,
onTagNameChange,
tagVersionId,
onTagVersionChange,
onTagSubmit,
onTagDelete,
tagVersions,
clawdis,
osLabels,
sidebarContent,
settingsHref,
children,
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
Boolean(modInfo?.overrideActive) &&
!modInfo?.isMalwareBlocked &&
!modInfo?.isSuspicious;
const overrideScanMessage = suppressScanResults
? "Security findings were reviewed by staff and cleared for public use."
: null;
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
return (
@@ -141,16 +121,6 @@ export function SkillHeader({
</p>
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
scan results below.
</p>
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
@@ -190,145 +160,161 @@ export function SkillHeader({
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<DetailHero
topClassName={hasPluginBundle ? "has-plugin" : undefined}
main={
<>
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{skill.displayName}</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isStaff || settingsHref ? (
<div className="skill-title-actions">
{isStaff ? (
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={settingsHref}>
<Settings size={14} aria-hidden="true" />
Settings
</a>
</Button>
) : null}
</div>
) : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat">
<Star size={14} aria-hidden="true" /> {formattedStats.stars}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Download size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Package size={14} aria-hidden="true" /> {skill.stats.versions ?? 0} versions
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" /> {formattedStats.installsAllTime}{" "}
all-time
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Calendar size={14} aria-hidden="true" /> Updated {timeAgo(skill.updatedAt)}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Scale size={14} aria-hidden="true" /> {PLATFORM_SKILL_LICENSE}
</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
{isAuthenticated ? (
</>
}
sidebar={
<>
{isAuthenticated ? (
<div className="skill-actions">
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
>
<span aria-hidden="true"></span>
<Star size={16} aria-hidden="true" />
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</div>
</div>
<SkillInstallSurface
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
llmAnalysis={latestVersion?.llmAnalysis as LlmAnalysis | undefined}
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
</div>
) : null}
{sidebarContent}
<SkillCommandLineCard
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
<p className="scan-disclaimer">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
</>
}
>
{children}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
@@ -368,99 +354,7 @@ export function SkillHeader({
</div>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</div>
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest…"
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
{tagVersions.map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">
Update Tag
</Button>
</form>
) : null}
</div>
</DetailHero>
</>
);
}
+27 -8
View File
@@ -3,7 +3,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { SkillCommandLineCard, SkillInstallSurface } from "./SkillInstallSurface";
const writeTextMock = vi.fn();
@@ -57,7 +57,7 @@ describe("SkillInstallSurface", () => {
);
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "CLI Commands" })).toBeNull();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
@@ -72,9 +72,9 @@ describe("SkillInstallSurface", () => {
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
});
it("switches the ClawHub command and copies the visible CLI command", async () => {
it("defaults to CLI install and can copy the compact prompt tab", async () => {
render(
<SkillInstallSurface
<SkillCommandLineCard
slug="weather"
displayName="Weather"
ownerHandle="steipete"
@@ -82,13 +82,32 @@ describe("SkillInstallSurface", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"false",
);
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
expect(writeTextMock).toHaveBeenCalledWith("openclaw skills install weather");
});
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("After install, inspect the skill metadata"),
);
});
});
});
+113 -108
View File
@@ -5,10 +5,8 @@ import type { Id } from "../../convex/_generated/dataModel";
import { copyText, InstallCopyButton } from "./InstallCopyButton";
import {
buildSkillInstallTarget,
formatClawHubInstallCommand,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
type SkillPackageManager,
type SkillPromptMode,
} from "./skillDetailUtils";
import { Button } from "./ui/button";
@@ -19,8 +17,6 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
const PROMPT_OPTIONS: Array<{
description: string;
label: string;
@@ -56,7 +52,6 @@ export function SkillInstallSurface({
clawdis,
}: SkillInstallSurfaceProps) {
const headingId = useId();
const [packageManager, setPackageManager] = useState<SkillPackageManager>("npm");
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
const [promptCopyState, setPromptCopyState] = useState<PromptCopyState>("idle");
const promptResetTimeoutRef = useRef<number | null>(null);
@@ -81,10 +76,9 @@ export function SkillInstallSurface({
}, 2000);
};
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const selectedPrompt =
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(slug);
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
const promptPreview = formatOpenClawPrompt({
mode: promptMode,
skillName: displayName,
@@ -130,109 +124,120 @@ export function SkillInstallSurface({
Install
</h2>
<div className="skill-install-grid">
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
</p>
</div>
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
</p>
</div>
<div className="skill-install-actions">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="skill-install-prompt-trigger">
<span>Copy Prompt</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="skill-install-menu">
{PROMPT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
<div className="skill-install-menu-copy">
<span className="skill-install-menu-label">{option.label}</span>
<span className="skill-install-menu-description">{option.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<div className="skill-install-preview-meta">
<span className="skill-install-preview-label">Prompt Preview</span>
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
</div>
<pre className="skill-install-prompt-preview">
<code translate="no">{promptPreview}</code>
</pre>
</article>
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">Command Line</p>
<h3 className="skill-install-panel-title">CLI Commands</h3>
<p className="skill-install-panel-copy">
Use the direct CLI path if you want to install manually and keep every step visible.
</p>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">OpenClaw CLI</p>
<p className="skill-install-command-caption">Bare skill slug</p>
</div>
<InstallCopyButton
text={openClawCommand}
ariaLabel="Copy OpenClaw CLI command"
/>
</div>
<pre className="skill-install-command">
<code translate="no">{openClawCommand}</code>
</pre>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">ClawHub CLI</p>
<p className="skill-install-command-caption">Package manager switcher</p>
</div>
<InstallCopyButton
text={clawHubCommand}
ariaLabel="Copy ClawHub CLI command"
/>
</div>
<div className="install-switcher-toggle" aria-label="ClawHub install command">
{PACKAGE_MANAGERS.map((entry) => (
<button
key={entry}
type="button"
aria-label={`Use ${entry} for ClawHub install command`}
aria-pressed={packageManager === entry}
className={`install-switcher-pill${packageManager === entry ? " is-active" : ""}`}
onClick={() => setPackageManager(entry)}
>
{entry}
</button>
<div className="skill-install-actions">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="skill-install-prompt-trigger">
<span>Copy Prompt</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="skill-install-menu">
{PROMPT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
<div className="skill-install-menu-copy">
<span className="skill-install-menu-label">{option.label}</span>
<span className="skill-install-menu-description">{option.description}</span>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<pre className="skill-install-command">
<code translate="no">{clawHubCommand}</code>
</pre>
</div>
</article>
</div>
<div className="skill-install-preview-meta">
<span className="skill-install-preview-label">Prompt Preview</span>
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
</div>
<pre className="skill-install-prompt-preview">
<code translate="no">{promptPreview}</code>
</pre>
</article>
</section>
);
}
export function SkillCommandLineCard({
slug,
displayName,
ownerHandle,
ownerId,
clawdis,
}: SkillInstallSurfaceProps) {
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
const openClawCommand = formatOpenClawInstallCommand(slug);
const promptPreview = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: displayName,
slug,
ownerHandle,
ownerId,
clawdis,
});
return (
<article className="skill-install-command-card">
<div className="skill-install-command-header">
<h3 className="skill-install-panel-title">Install</h3>
<div className="install-switcher-toggle" role="tablist" aria-label="Install option">
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "cli"}
className={`install-switcher-pill${activeInstallTab === "cli" ? " is-active" : ""}`}
onClick={() => setActiveInstallTab("cli")}
>
CLI
</button>
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "prompt"}
className={`install-switcher-pill${
activeInstallTab === "prompt" ? " is-active" : ""
}`}
onClick={() => setActiveInstallTab("prompt")}
>
Prompt
</button>
</div>
</div>
<div className="skill-install-command-wrap">
<pre
className={`skill-install-command${
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
}`}
>
<code translate="no">
{activeInstallTab === "prompt" ? promptPreview : openClawCommand}
</code>
</pre>
<InstallCopyButton
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
ariaLabel={
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
showLabel={false}
/>
</div>
</article>
);
}
-118
View File
@@ -1,118 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
) : null}
</div>
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</div>
);
}
+61 -40
View File
@@ -1,3 +1,4 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { Badge } from "./ui/badge";
@@ -53,10 +54,11 @@ type SecurityScanResultsProps = {
llmAnalysis?: LlmAnalysis | null;
staticFindings?: StaticFinding[];
capabilityTags?: string[] | null;
scannerBasePath?: string | null;
variant?: "panel" | "badge";
};
function VirusTotalIcon({ className }: { className?: string }) {
export function VirusTotalIcon({ className }: { className?: string }) {
return (
<svg
className={className}
@@ -76,36 +78,11 @@ function VirusTotalIcon({ className }: { className?: string }) {
);
}
function OpenClawIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
aria-label="OpenClaw"
>
<title>OpenClaw</title>
<path
d="M12 2C8.5 2 5.5 4 4 7c-2 4-1 8 2 11 1.5 1.5 3.5 2.5 6 2.5s4.5-1 6-2.5c3-3 4-7 2-11-1.5-3-4.5-5-8-5z"
fill="currentColor"
opacity="0.2"
/>
<path
d="M9 8c1-2 3-3 5-2s3 3 2 5l-3 4-2-1 3-4c.5-1 0-2-1-2.5S11 7 10.5 8L8 12l-2-1 3-4z"
fill="currentColor"
/>
<path
d="M15 8c-1-2-3-3-5-2s-3 3-2 5l3 4 2-1-3-4c-.5-1 0-2 1-2.5S14 7 14.5 8L17 12l2-1-4-3z"
fill="currentColor"
opacity="0.6"
/>
</svg>
);
export function ClawScanIcon({ className }: { className?: string }) {
return <ShieldCheck className={className} aria-label="ClawScan" />;
}
function getScanStatusInfo(status: string) {
export function getScanStatusInfo(status: string) {
switch (status.toLowerCase()) {
case "benign":
case "clean":
@@ -250,7 +227,7 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "benign",
label: "Confirmed safe by external scanners",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and OpenClaw confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and ClawScan confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
};
}
const hasCritical = findings.some((f) => f.severity === "critical");
@@ -258,13 +235,13 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "suspicious",
label: "Patterns worth reviewing",
text: "These patterns may indicate risky behavior. Check the VirusTotal and OpenClaw results above for context-aware analysis before installing.",
text: "These patterns may indicate risky behavior. Check the VirusTotal and ClawScan results above for context-aware analysis before installing.",
};
}
return {
className: "benign",
label: "About static analysis",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and OpenClaw results above for context-aware analysis.",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and ClawScan results above for context-aware analysis.",
};
}
@@ -334,6 +311,7 @@ export function SecurityScanResults({
llmAnalysis,
staticFindings,
capabilityTags,
scannerBasePath,
variant = "panel",
}: SecurityScanResultsProps) {
const visibleCapabilityTags = (capabilityTags ?? []).filter(Boolean);
@@ -369,12 +347,30 @@ export function SecurityScanResults({
</a>
) : null}
{scannerBasePath ? (
<a
href={`${scannerBasePath}/virustotal`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
{llmStatusInfo ? (
<div className="version-scan-badge">
<OpenClawIcon className="version-scan-icon version-scan-icon-oc" />
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
<span className={llmStatusInfo.className}>{llmStatusInfo.label}</span>
{scannerBasePath ? (
<a
href={`${scannerBasePath}/openclaw`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
</>
@@ -420,6 +416,11 @@ export function SecurityScanResults({
View report
</a>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/virustotal`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{isCodeInsight && aiAnalysis && (vtStatus === "malicious" || vtStatus === "suspicious") ? (
@@ -431,8 +432,8 @@ export function SecurityScanResults({
{llmStatusInfo && llmAnalysis ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<OpenClawIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">OpenClaw</span>
<ClawScanIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">ClawScan</span>
</div>
<div className={`scan-result-status ${llmStatusInfo.className}`}>
{llmStatusInfo.label}
@@ -440,6 +441,11 @@ export function SecurityScanResults({
{llmAnalysis.confidence ? (
<span className="scan-result-confidence">{llmAnalysis.confidence} confidence</span>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/openclaw`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{llmAnalysis &&
@@ -449,11 +455,26 @@ export function SecurityScanResults({
<LlmAnalysisDetail analysis={llmAnalysis} />
) : null}
{staticFindings && staticFindings.length > 0 ? (
<StaticAnalysisDetail
findings={staticFindings}
vtStatus={vtStatus}
llmStatus={llmVerdict}
/>
<>
{scannerBasePath ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<span className="scan-result-scanner-name">Static analysis</span>
</div>
<div className="scan-result-status scan-status-suspicious">
{staticFindings.length} finding{staticFindings.length === 1 ? "" : "s"}
</div>
<a href={`${scannerBasePath}/static-analysis`} className="scan-result-link">
Details
</a>
</div>
) : null}
<StaticAnalysisDetail
findings={staticFindings}
vtStatus={vtStatus}
llmStatus={llmVerdict}
/>
</>
) : null}
</div>
</div>
+24 -25
View File
@@ -2,38 +2,37 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
<div className="flex gap-3">
<Skeleton className="h-[44px] w-32 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-36 rounded-[var(--radius-pill)]" />
<main className="section">
<div className="dashboard-header">
<div className="grid gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
</div>
{/* Stats row */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<div className="dashboard-owner-grid">
{["skills", "plugins"].map((section) => (
<div
key={i}
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5"
key={section}
className="dashboard-owner-panel flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5"
>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-16" />
<div className="dashboard-section-header">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-[34px] w-28 rounded-[var(--r-btn)]" />
</div>
<div className="dashboard-list">
{Array.from({ length: section === "skills" ? 2 : 3 }, (_, index) => (
<div key={index} className="dashboard-list-row">
<Skeleton className="h-5 w-48 max-w-full" />
<Skeleton className="h-5 w-96 max-w-full" />
<Skeleton className="h-8 w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-8 w-8 rounded-[var(--r-btn)]" />
</div>
))}
</div>
</div>
))}
</div>
{/* Publisher tabs */}
<Skeleton className="mb-6 h-[44px] w-64 rounded-[var(--radius-pill)]" />
{/* Table skeleton */}
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-[var(--radius-sm)]" />
))}
</div>
</div>
</main>
);
}
@@ -2,71 +2,64 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_340px]">
{/* Main column */}
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-16 rounded-[var(--radius-pill)]" />
<div className="skill-detail-stack">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<Skeleton className="h-4 w-48" />
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-full max-w-[360px]" />
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
</div>
<Skeleton className="h-5 w-full max-w-[680px]" />
<Skeleton className="h-5 w-3/4 max-w-[520px]" />
</div>
<Skeleton className="h-5 w-full max-w-lg" />
{/* Meta row */}
<div className="flex items-center gap-4">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
{/* README skeleton */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-6">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-2">
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-28 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-5">
{/* Install card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full rounded-[var(--radius-sm)]" />
<Skeleton className="h-10 w-full rounded-[var(--radius-pill)]" />
</div>
{/* Stats card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-16" />
<div className="w-full space-y-3 lg:max-w-[360px]">
<Skeleton className="h-12 w-full rounded-[var(--r-sm)]" />
<Skeleton className="h-12 w-full rounded-[var(--r-pill)]" />
<div className="grid grid-cols-3 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
</div>
{/* Security card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full" />
</div>
</div>
<div className="detail-layout">
<div className="detail-main">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="mb-4 flex flex-wrap gap-2">
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-20 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
</div>
<div className="space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="mt-5 h-5 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="mb-3 h-6 w-28" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
+122
View File
@@ -0,0 +1,122 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,520px)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 text-[color:var(--ink)] shadow-dialog outline-none",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1.5", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("font-display text-lg font-bold text-[color:var(--ink)]", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-[color:var(--ink-soft)]", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant: "primary" }), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+52 -30
View File
@@ -1,12 +1,55 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
const buttonVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
"!no-underline hover:!no-underline",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
{
variants: {
variant: {
default:
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--surface-muted)]",
primary:
"border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
secondary:
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
destructive:
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
ghost:
"border border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)]",
outline:
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
link: "h-auto border border-transparent bg-transparent p-0 text-[color:var(--accent-deep)] underline-offset-4 hover:underline disabled:opacity-60",
},
size: {
default: "min-h-[44px] rounded-[var(--r-btn)] px-4 py-[11px] text-sm",
xs: "min-h-[30px] rounded-[var(--r-btn)] px-2.5 py-1 text-xs",
sm: "min-h-[34px] rounded-[var(--r-btn)] px-3 py-1.5 text-xs",
lg: "min-h-[52px] rounded-[var(--r-btn)] px-6 py-3 text-base",
icon: "h-[44px] w-[44px] rounded-[var(--r-btn)] p-0",
"icon-xs": "h-7 w-7 rounded-[var(--r-btn)] p-0",
"icon-sm": "h-8 w-8 rounded-[var(--r-btn)] p-0",
"icon-lg": "h-12 w-12 rounded-[var(--r-btn)] p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
/** Render as the child element (e.g. a Link) instead of a <button>. */
asChild?: boolean;
variant?: "default" | "primary" | "destructive" | "ghost" | "outline";
size?: "default" | "sm" | "lg" | "icon";
loading?: boolean;
}
@@ -28,36 +71,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return (
<Comp
ref={ref}
className={cn(
// Base styles matching .btn
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
// Hover lift
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
// Variant styles
variant === "default" &&
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
variant === "primary" &&
"border border-accent bg-accent/10 text-[color:var(--ink)]",
variant === "destructive" &&
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
variant === "ghost" &&
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
variant === "outline" &&
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
// Size styles
size === "default" && "min-h-[44px] rounded-[var(--r-btn)] px-4 py-[11px] text-sm",
size === "sm" && "min-h-[34px] rounded-[var(--r-btn)] px-3 py-1.5 text-xs",
size === "lg" && "min-h-[52px] rounded-[var(--r-btn)] px-6 py-3 text-base",
size === "icon" && "h-[44px] w-[44px] rounded-[var(--r-btn)] p-0",
className,
)}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || loading}
{...props}
>
{loading && (
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current" />
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current [animation-duration:2.4s]" />
)}
<Slottable>{children}</Slottable>
</Comp>
@@ -66,4 +88,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button };
export { Button, buttonVariants };
+29
View File
@@ -0,0 +1,29 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
data-slot="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-[var(--radius-sm)] border border-[color:var(--border-ui)] bg-input-bg text-accent-fg shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+140
View File
@@ -0,0 +1,140 @@
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Dialog, DialogContent } from "./dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-[var(--radius-md)] bg-[color:var(--surface)] text-[color:var(--ink)]",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: React.ComponentProps<typeof Dialog>) => (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-[color:var(--line)] px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 text-[color:var(--ink-soft)]" />
<CommandPrimitive.Input
ref={ref}
data-slot="command-input"
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-input-placeholder disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
data-slot="command-empty"
className="py-6 text-center text-sm text-[color:var(--ink-soft)]"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-[color:var(--ink)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.12em] [&_[cmdk-group-heading]]:text-[color:var(--ink-soft)]",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
data-slot="command-separator"
className={cn("-mx-1 h-px bg-[color:var(--line)]", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
data-slot="command-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2 py-2 text-sm font-medium outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-[color:var(--surface-muted)] data-[selected=true]:text-[color:var(--ink)] data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
data-slot="command-shortcut"
className={cn("ml-auto text-xs tracking-widest text-[color:var(--ink-soft)]", className)}
{...props}
/>
);
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+23 -7
View File
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -28,12 +29,15 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
// Matches .report-dialog
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
@@ -44,10 +48,18 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-[var(--radius-sm)] p-1 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]/35">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="absolute top-4 right-4 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
@@ -59,7 +71,11 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center justify-end gap-2 pt-2", className)} {...props} />
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
+145 -5
View File
@@ -1,4 +1,5 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
@@ -12,13 +13,18 @@ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 8, ...props }, ref) => (
>(({ className, sideOffset = 8, align = "start", ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
align={align}
data-slot="dropdown-menu-content"
className={cn(
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[180px] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -29,12 +35,18 @@ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}
>(({ className, inset, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8 data-[variant=destructive]:text-status-error-fg",
className,
)}
{...props}
@@ -42,26 +54,154 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean;
}
>(({ className, children, checked, inset, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean;
}
>(({ className, children, inset, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-3 py-1.5 text-xs font-bold uppercase tracking-[0.12em] text-[color:var(--ink-soft)] data-[inset=true]:pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
data-slot="dropdown-menu-separator"
className={cn("my-1 h-px bg-[color:var(--line)]", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-[color:var(--ink-soft)]", className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[state=open]:bg-[color:var(--surface-muted)] data-[inset=true]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[180px] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuRadioGroup,
};
+31
View File
@@ -0,0 +1,31 @@
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import * as React from "react";
import { cn } from "../../lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 8, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-[var(--radix-hover-card-content-transform-origin)] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 text-[color:var(--ink)] shadow-[var(--shadow)] outline-none",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+32
View File
@@ -0,0 +1,32 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "../../lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 8, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-[var(--radix-popover-content-transform-origin)] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 text-[color:var(--ink)] shadow-[var(--shadow)] outline-none",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+42
View File
@@ -0,0 +1,42 @@
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
ref={ref}
data-slot="radio-group"
className={cn("grid gap-2", className)}
{...props}
/>
));
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(
"aspect-square h-4 w-4 rounded-full border border-[color:var(--border-ui)] bg-input-bg text-accent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex items-center justify-center"
>
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
));
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+11 -4
View File
@@ -9,13 +9,18 @@ const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, children, size = "default", ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
// Matches form input token styling
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
"data-[size=sm]:min-h-8 data-[size=sm]:px-2.5 data-[size=sm]:py-1 data-[size=sm]:text-xs",
"border-input-border bg-input-bg",
"placeholder:text-input-placeholder",
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
@@ -63,12 +68,13 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = "popper", align = "center", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
@@ -77,6 +83,7 @@ const SelectContent = React.forwardRef<
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
+17 -5
View File
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
@@ -27,16 +28,19 @@ SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, side = "right", ...props }, ref) => (
>(({ className, children, side = "right", showCloseButton = true, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-80 flex flex-col gap-4 border-[color:var(--line)] bg-[color:var(--bg)] p-6 shadow-[var(--shadow)] transition-transform duration-300 ease-out",
side === "right" &&
@@ -51,10 +55,18 @@ const SheetContent = React.forwardRef<
)}
{...props}
>
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-[var(--radius-sm)] p-1 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]/35">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="absolute top-4 right-4 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
{children}
</DialogPrimitive.Content>
</SheetPortal>
+4 -2
View File
@@ -8,21 +8,23 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
className
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
"transition-transform duration-200 ease-out",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
+61 -15
View File
@@ -1,35 +1,81 @@
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils";
const toggleGroupItemVariants = cva(
"inline-flex shrink-0 items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-fg [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "",
outline:
"border border-[color:var(--border-ui)] data-[state=on]:border-[color:var(--accent)]",
},
size: {
default: "h-[30px] min-w-[30px] px-2 text-sm",
sm: "h-7 min-w-7 px-1.5 text-xs",
lg: "h-9 min-w-9 px-3 text-sm",
icon: "h-[30px] w-[30px] p-0",
},
},
defaultVariants: {
variant: "default",
size: "icon",
},
},
);
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
variant: "default",
size: "icon",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant = "default", size = "icon", children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
className,
)}
{...props}
/>
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
className,
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
data-slot="toggle-group-item"
data-variant={context.variant ?? variant}
data-size={context.size ?? size}
className={cn(
toggleGroupItemVariants({
variant: variant ?? context.variant,
size: size ?? context.size,
className,
}),
)}
{...props}
/>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
export { ToggleGroup, ToggleGroupItem, toggleGroupItemVariants };
+1 -60
View File
@@ -53,10 +53,6 @@ const SOULS_SEARCH = {
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
@@ -100,55 +96,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
@@ -199,17 +146,11 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Community",
items: [
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -388,7 +388,7 @@ export async function fetchPackageReadme(name: string, version?: string | null):
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 404 || response.status === 423) {
if (response.status === 403 || response.status === 404 || response.status === 423) {
return null;
}
throw await createPackageApiError(response);
+95 -10
View File
@@ -35,6 +35,9 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
const UploadRoute = UploadRouteImport.update({
id: '/upload',
@@ -166,6 +169,23 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
path: '/$owner/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsNameSecurityScannerRoute =
PluginsNameSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => PluginsNameRoute,
} as any)
const OwnerSlugSecurityScannerRoute =
OwnerSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => OwnerSlugRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -180,12 +200,12 @@ export interface FileRoutesByFullPath {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -194,6 +214,9 @@ export interface FileRoutesByFullPath {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -208,12 +231,12 @@ export interface FileRoutesByTo {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -222,6 +245,9 @@ export interface FileRoutesByTo {
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -237,12 +263,12 @@ export interface FileRoutesById {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -251,6 +277,9 @@ export interface FileRoutesById {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -281,6 +310,9 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -309,6 +341,9 @@ export interface FileRouteTypes {
| '/skills'
| '/souls'
| '/users'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
id:
| '__root__'
| '/'
@@ -337,6 +372,9 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -352,12 +390,12 @@ export interface RootRouteChildren {
SettingsRoute: typeof SettingsRoute
StarsRoute: typeof StarsRoute
UploadRoute: typeof UploadRoute
OwnerSlugRoute: typeof OwnerSlugRoute
OwnerSlugRoute: typeof OwnerSlugRouteWithChildren
CliAuthRoute: typeof CliAuthRoute
OrgsHandleRoute: typeof OrgsHandleRoute
PackagesNameRoute: typeof PackagesNameRoute
PackagesNewRoute: typeof PackagesNewRoute
PluginsNameRoute: typeof PluginsNameRoute
PluginsNameRoute: typeof PluginsNameRouteWithChildren
PluginsNewRoute: typeof PluginsNewRoute
SoulsSlugRoute: typeof SoulsSlugRoute
UHandleRoute: typeof UHandleRoute
@@ -552,9 +590,56 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
fullPath: '/$owner/$slug/settings'
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$name/security/$scanner': {
id: '/plugins/$name/security/$scanner'
path: '/security/$scanner'
fullPath: '/plugins/$name/security/$scanner'
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
parentRoute: typeof PluginsNameRoute
}
'/$owner/$slug/security/$scanner': {
id: '/$owner/$slug/security/$scanner'
path: '/security/$scanner'
fullPath: '/$owner/$slug/security/$scanner'
preLoaderRoute: typeof OwnerSlugSecurityScannerRouteImport
parentRoute: typeof OwnerSlugRoute
}
}
}
interface OwnerSlugRouteChildren {
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
}
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
}
const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
OwnerSlugRouteChildren,
)
interface PluginsNameRouteChildren {
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
}
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
}
const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
PluginsNameRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
@@ -568,12 +653,12 @@ const rootRouteChildren: RootRouteChildren = {
SettingsRoute: SettingsRoute,
StarsRoute: StarsRoute,
UploadRoute: UploadRoute,
OwnerSlugRoute: OwnerSlugRoute,
OwnerSlugRoute: OwnerSlugRouteWithChildren,
CliAuthRoute: CliAuthRoute,
OrgsHandleRoute: OrgsHandleRoute,
PackagesNameRoute: PackagesNameRoute,
PackagesNewRoute: PackagesNewRoute,
PluginsNameRoute: PluginsNameRoute,
PluginsNameRoute: PluginsNameRouteWithChildren,
PluginsNewRoute: PluginsNewRoute,
SoulsSlugRoute: SoulsSlugRoute,
UHandleRoute: UHandleRoute,
+14 -1
View File
@@ -1,4 +1,10 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
createFileRoute,
notFound,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
@@ -72,5 +78,12 @@ export const Route = createFileRoute("/$owner/$slug")({
function OwnerSkill() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (
pathname.includes(`/${encodeURIComponent(slug)}/security/`) ||
pathname.endsWith(`/${encodeURIComponent(slug)}/settings`)
) {
return <Outlet />;
}
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
}
@@ -0,0 +1,121 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import { buildSkillMeta } from "../../../../lib/og";
import { fetchSkillPageData } from "../../../../lib/skillPage";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "openclaw", "static-analysis"]);
function parseScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
parseScanner(params.scanner);
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/security/$scanner",
params: { owner: canonicalOwner, slug: canonicalSlug, scanner: params.scanner },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const scanner = parseScanner(params.scanner);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "ClawScan"
: "Static analysis";
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `${scannerLabel} security · ${meta.title}` },
{
name: "description",
content: `${scannerLabel} security details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSecurityScannerRoute,
});
function SkillSecurityScannerRoute() {
const { owner, slug, scanner } = Route.useParams();
const { initialData } = Route.useLoaderData();
const liveResult = useQuery(api.skills.getBySlug, { slug });
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
if (result === undefined) {
return (
<main className="section">
<div className="card">Loading security details...</div>
</main>
);
}
if (!skill || !latestVersion) {
return (
<main className="section">
<div className="card">Security details are unavailable for this skill.</div>
</main>
);
}
const ownerSegment = result?.owner?.handle ?? result?.owner?._id ?? owner;
return (
<SecurityScannerPage
scanner={parseScanner(scanner)}
entity={{
kind: "skill",
title: skill.displayName,
name: slug,
version: latestVersion.version,
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
/>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../../components/SkillDetailPage";
import { buildSkillMeta } from "../../../lib/og";
import { fetchSkillPageData } from "../../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug/settings")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/settings",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Settings · ${meta.title}` },
{
name: "description",
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSettingsRoute,
});
function SkillSettingsRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
return (
<SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} mode="settings" />
);
}
+1 -6
View File
@@ -149,12 +149,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
</button>
</p>
) : null}
<SignInButton
variant="primary"
disabled={isLoading}
>
Sign in with GitHub
</SignInButton>
<SignInButton disabled={isLoading} />
</CardContent>
</Card>
</Container>
+293
View File
@@ -0,0 +1,293 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import { TooltipProvider } from "../components/ui/tooltip";
import { Dashboard } from "./dashboard";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
useMutation: (...args: unknown[]) => mocks.useMutation(...args),
}));
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => mocks.toastSuccess(...args),
error: (...args: unknown[]) => mocks.toastError(...args),
},
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: unknown) => config,
Link: ({
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children: React.ReactNode }) => (
<a href="/test" {...props}>
{children}
</a>
),
}));
type TestSkill = {
_id: Id<"skills">;
_creationTime: number;
slug: string;
displayName: string;
summary: string;
ownerUserId: Id<"users">;
ownerPublisherId: Id<"publishers">;
tags: {};
badges: {};
stats: {
downloads: number;
stars: number;
versions: number;
};
moderationVerdict?: "suspicious" | "malicious";
moderationFlags?: string[];
isSuspicious?: boolean;
createdAt: number;
updatedAt: number;
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: null | { _id: string; status: "in_progress"; targetVersion: string };
latestRequest: null | { _id: string; status: "completed" | "failed"; targetVersion: string };
};
};
type TestPackage = {
_id: Id<"packages">;
name: string;
displayName: string;
family: "code-plugin";
channel: "community";
isOfficial: false;
runtimeId: string | null;
sourceRepo: string | null;
summary: string;
latestVersion: string;
stats: {
downloads: number;
installs: number;
stars: number;
versions: number;
};
verification: null;
scanStatus: "suspicious" | "malicious";
latestRelease: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: TestSkill["rescanState"];
};
const me = {
_id: "users:local" as Id<"users">,
handle: "local",
name: "Local Dev",
displayName: "Local Dev",
};
const publishers = [
{
publisher: {
_id: "publishers:local" as Id<"publishers">,
handle: "local",
displayName: "Local",
kind: "user" as const,
},
role: "owner" as const,
},
];
function createSkill(overrides?: Partial<TestSkill>): TestSkill {
return {
_id: "skills:below-cap" as Id<"skills">,
_creationTime: 1,
slug: "local-flagged-skill",
displayName: "Local Flagged Skill",
summary: "Flagged skill fixture.",
ownerUserId: me._id,
ownerPublisherId: publishers[0].publisher._id,
tags: {},
badges: {},
stats: { downloads: 0, stars: 0, versions: 1 },
moderationVerdict: "suspicious",
moderationFlags: ["flagged.suspicious"],
isSuspicious: true,
createdAt: 1,
updatedAt: 1,
latestVersion: {
version: "1.0.0",
createdAt: 1,
vtStatus: "suspicious",
llmStatus: "suspicious",
staticScanStatus: "suspicious",
},
rescanState: {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function createPackage(overrides?: Partial<TestPackage>): TestPackage {
return {
_id: "packages:at-cap" as Id<"packages">,
name: "local-flagged-runtime-plugin",
displayName: "Local Flagged Runtime Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: null,
sourceRepo: null,
summary: "Flagged plugin fixture.",
latestVersion: "1.0.0",
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
verification: null,
scanStatus: "malicious",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "malicious",
llmStatus: "malicious",
staticScanStatus: "malicious",
},
rescanState: {
maxRequests: 3,
requestCount: 3,
remainingRequests: 0,
canRequest: false,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function arrangeDashboard({
skills = [],
packages = [],
}: {
skills?: TestSkill[];
packages?: TestPackage[];
}) {
let unscopedQueryCount = 0;
let scopedQueryCount = 0;
mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args === undefined) {
unscopedQueryCount += 1;
return unscopedQueryCount % 2 === 1 ? me : publishers;
}
scopedQueryCount += 1;
return scopedQueryCount % 2 === 1 ? skills : packages;
});
}
function renderDashboard() {
return render(
<TooltipProvider>
<Dashboard />
</TooltipProvider>,
);
}
describe("Dashboard minimal rows", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useMutation.mockReset();
mocks.useMutation.mockReturnValue(vi.fn().mockResolvedValue({}));
mocks.toastSuccess.mockReset();
mocks.toastError.mockReset();
});
it("renders entry links, summaries, and aggregate statuses only", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.getByRole("link", { name: "Local Flagged Skill" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Local Flagged Runtime Plugin" })).toBeTruthy();
expect(screen.getByText("Flagged skill fixture.")).toBeTruthy();
expect(screen.getByText("Flagged plugin fixture.")).toBeTruthy();
expect(screen.getByText("Suspicious")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByRole("button", { name: "Suspicious status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Blocked status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Open actions for Local Flagged Skill" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open actions for Local Flagged Runtime Plugin" }),
).toBeTruthy();
});
it("renders a skeleton while auth state is loading", () => {
mocks.useQuery.mockReturnValue(undefined);
renderDashboard();
expect(screen.queryByText("Sign in to access your dashboard.")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("does not render row-level actions", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByRole("button", { name: /request rescan/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new version/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
});
it("does not render column titles, scanner details, or plugin metadata chips", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByText("Skill")).toBeNull();
expect(screen.queryByText("Plugin")).toBeNull();
expect(screen.queryByText("Summary")).toBeNull();
expect(screen.queryByText("Status")).toBeNull();
expect(screen.queryByText(/^VT:/)).toBeNull();
expect(screen.queryByText(/^LLM:/)).toBeNull();
expect(screen.queryByText(/^ClawScan:/)).toBeNull();
expect(screen.queryByText(/^Static/)).toBeNull();
expect(screen.queryByText(/public surfaces warn or suppress it/i)).toBeNull();
expect(screen.queryByText(/automated security checks found malicious content/i)).toBeNull();
expect(screen.queryByText("Code Plugin")).toBeNull();
expect(screen.queryByText("community")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
expect(screen.queryByText("Limit reached (3/3)")).toBeNull();
});
});
+385 -290
View File
@@ -1,27 +1,22 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import {
AlertTriangle,
ArrowDownToLine,
CheckCircle2,
Clock,
GitBranch,
Package,
Plug,
ShieldCheck,
Star,
Upload,
} from "lucide-react";
import { useMutation, useQuery } from "convex/react";
import { Clock, Info, MoreVertical, Plus, RotateCw, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import semver from "semver";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { DashboardSkeleton } from "../components/skeletons/DashboardSkeleton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
import { getUserFacingConvexError } from "../lib/convexError";
const emptyPluginPublishSearch = {
ownerHandle: undefined,
@@ -32,7 +27,41 @@ const emptyPluginPublishSearch = {
sourceRepo: undefined,
} as const;
type DashboardSkill = PublicSkill & { pendingReview?: boolean };
type DashboardSkill = Pick<
Doc<"skills">,
| "_id"
| "_creationTime"
| "slug"
| "displayName"
| "summary"
| "ownerUserId"
| "ownerPublisherId"
| "canonicalSkillId"
| "forkOf"
| "latestVersionId"
| "tags"
| "capabilityTags"
| "badges"
| "stats"
| "moderationStatus"
| "moderationReason"
| "moderationVerdict"
| "moderationFlags"
| "isSuspicious"
| "createdAt"
| "updatedAt"
> & {
pendingReview?: boolean;
qualityDecision?: "pass" | "quarantine" | "reject";
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardPackage = {
_id: string;
@@ -63,13 +92,35 @@ type DashboardPackage = {
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: DashboardRescanRequest | null;
latestRequest: DashboardRescanRequest | null;
};
type DashboardRescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
requestedByUserId: string;
status: "in_progress" | "completed" | "failed";
error?: string;
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export const Route = createFileRoute("/dashboard")({
component: Dashboard,
});
function Dashboard() {
export function Dashboard() {
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
const publishers = useQuery(api.publishers.listMine) as
| Array<{
@@ -107,13 +158,18 @@ function Dashboard() {
useEffect(() => {
if (selectedPublisherId) return;
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher._id) {
setSelectedPublisherId(personal.publisher._id);
}
}, [publishers, selectedPublisherId]);
if (!me) {
if (me === undefined) {
return <DashboardSkeleton />;
}
if (me === null) {
return (
<main className="section">
<Card>Sign in to access your dashboard.</Card>
@@ -136,7 +192,8 @@ function Dashboard() {
Welcome to ClawHub
</h1>
<p className="empty-state-body">
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
You're signed in as @{ownerHandle}. Get started by publishing your first skill or
plugin.
</p>
<div className="flex gap-3 justify-center">
<Button asChild variant="primary">
@@ -170,262 +227,193 @@ function Dashboard() {
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title m-0">
Publisher Dashboard
</h1>
<h1 className="section-title m-0">Dashboard</h1>
<p className="section-subtitle m-0">
Manage your published skills and plugins.
View your published skills and plugins.
</p>
</div>
<div className="flex gap-2 flex-wrap">
{publishers && publishers.length > 0 ? (
<select
className="input"
value={selectedPublisherId}
onChange={(event) => setSelectedPublisherId(event.target.value)}
>
{publishers.map((entry) => (
<option key={entry.publisher._id} value={entry.publisher._id}>
@{entry.publisher.handle} · {entry.role}
</option>
))}
</select>
) : null}
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
<Button asChild>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
</div>
<Card className="dashboard-owner-panel">
<div className="dashboard-owner-grid">
<div className="dashboard-owner-grid">
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
<h2 className="dashboard-collection-title">Skills</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Skill
</Link>
</Button>
</div>
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
<strong>No skills yet.</strong> Publish your first skill to share it with the
community.
</div>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</Card>
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
<h2 className="dashboard-collection-title">Plugins</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Plugin
</Link>
</Button>
</div>
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
distribute it.
</div>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</div>
</Card>
</Card>
</div>
</main>
);
}
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
const status = skillDashboardStatus(skill);
const detailParams = { owner: ownerHandle ?? "unknown", slug: skill.slug };
const settingsHref = `/${encodeURIComponent(detailParams.owner)}/${encodeURIComponent(
skill.slug,
)}/settings`;
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
params={detailParams}
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
<span className="dashboard-list-id">/{skill.slug}</span>
{skill.pendingReview ? (
<Badge variant="pending">
<Clock className="h-3 w-3" aria-hidden="true" />
Pending checks
</Badge>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
</span>
</div>
</div>
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
{skill.pendingReview ? (
<>
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />
VT pending
</span>
<span className="dashboard-inline-status-note">
Hidden until verification checks finish.
</span>
</>
) : (
<span className="dashboard-inline-status-note">Visible</span>
)}
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
>
View
</Link>
</Button>
<StatusChipWithTooltip status={status} />
</div>
<RowMenu
kind="skill"
targetId={skill._id}
targetLabel={skill.displayName}
settingsHref={settingsHref}
statusLabel={status.label}
rescanState={skill.rescanState ?? null}
/>
</div>
);
}
function scanStatusLabel(status: string | null | undefined) {
switch (status) {
case "pending":
return "Pending scan";
case "clean":
return "Scan clean";
case "suspicious":
return "Suspicious";
case "malicious":
return "Blocked";
case "not-run":
return "Scan not run";
default:
return null;
}
}
function releaseStatusLabel(
label: string,
status: string | null | undefined,
emptyLabel = "not started",
) {
return `${label}: ${status?.trim() ? status : emptyLabel}`;
}
function PackageStatusTag({
label,
tone,
function StatusChipWithTooltip({
status,
}: {
label: string;
tone: "default" | "pending" | "warning" | "danger" | "success";
status: {
key?: string;
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
};
}) {
const variant =
tone === "pending"
? "pending"
: tone === "warning"
? "warning"
: tone === "danger"
? "destructive"
: tone === "success"
? "success"
: "default";
return <Badge variant={variant}>{label}</Badge>;
const showInfo = status.label !== "Visible";
return (
<Badge variant={status.variant} className="dashboard-status-chip">
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
{status.label}
{showInfo ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="dashboard-status-info"
aria-label={`${status.label} status reason`}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="end">
{status.description}
</TooltipContent>
</Tooltip>
) : null}
</Badge>
);
}
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
const scanTone =
pkg.scanStatus === "pending"
? "pending"
: pkg.scanStatus === "suspicious"
? "warning"
: pkg.scanStatus === "malicious"
? "danger"
: pkg.scanStatus === "clean"
? "success"
: "default";
const staticTone =
pkg.latestRelease?.staticScanStatus === "suspicious"
? "warning"
: pkg.latestRelease?.staticScanStatus === "malicious"
? "danger"
: pkg.latestRelease?.staticScanStatus === "clean"
? "success"
: "default";
function packageDashboardStatus(pkg: DashboardPackage): {
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const releaseStatuses = new Set([
pkg.latestRelease?.vtStatus,
pkg.latestRelease?.llmStatus,
pkg.latestRelease?.staticScanStatus,
]);
if (pkg.scanStatus === "malicious" || releaseStatuses.has("malicious")) {
return {
label: "Blocked",
description: "Security checks found malicious content.",
variant: "destructive",
};
}
if (pkg.scanStatus === "suspicious" || releaseStatuses.has("suspicious")) {
return {
label: "Suspicious",
description: "Security checks flagged this plugin for review.",
variant: "warning",
};
}
if (pkg.scanStatus === "pending" || pkg.pendingReview) {
return {
label: "Pending checks",
description: "Security verification is still running.",
variant: "pending",
};
}
if (pkg.scanStatus === "clean") {
return {
label: "Visible",
description: "Available on public catalog surfaces.",
variant: "success",
};
}
return {
label: "Unknown",
description: "Open the plugin for the latest release and security details.",
variant: "default",
};
}
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
const status = packageDashboardStatus(pkg);
return (
<div className="dashboard-list-row">
@@ -434,89 +422,196 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
{pkg.displayName}
</Link>
<span className="dashboard-list-id">{pkg.name}</span>
</div>
<div className="dashboard-inline-tags">
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
<PackageStatusTag label={pkg.channel} tone="default" />
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
{pkg.verification?.tier ? (
<PackageStatusTag label={pkg.verification.tier} tone="default" />
) : null}
{pkg.latestRelease?.staticScanStatus ? (
<PackageStatusTag
label={`Static ${pkg.latestRelease.staticScanStatus}`}
tone={staticTone}
/>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
</span>
<span>
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
</span>
{pkg.runtimeId ? (
<span>
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
</span>
) : null}
{sourceLabel ? (
<span>
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
</span>
) : null}
</div>
</div>
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />{" "}
{releaseStatusLabel(
"VT",
pkg.latestRelease?.vtStatus,
pkg.scanStatus === "pending" ? "pending" : "unknown",
)}
</span>
<span className="dashboard-inline-status-item">
<CheckCircle2 size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
</span>
<span className="dashboard-inline-status-item">
<AlertTriangle size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
</span>
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
View
</Link>
</Button>
<StatusChipWithTooltip status={status} />
</div>
<RowMenu
kind="plugin"
targetId={pkg._id}
targetLabel={pkg.displayName}
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
statusLabel={status.label}
rescanState={pkg.rescanState ?? null}
/>
</div>
);
}
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
if (statusLabel === "Visible") return false;
if (!state) return true;
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
}
function RowMenu({
kind,
targetId,
targetLabel,
settingsHref,
statusLabel,
rescanState,
}: {
kind: "skill" | "plugin";
targetId: string;
targetLabel: string;
settingsHref: string;
statusLabel: string;
rescanState: DashboardRescanState | null;
}) {
const requestSkillRescan = useMutation(api.skills.requestRescan);
const requestPluginRescan = useMutation(api.packages.requestRescan);
const [isRequesting, setIsRequesting] = useState(false);
const isScanInProgress = Boolean(rescanState?.inProgressRequest);
const showRescan = canShowDashboardRescan(statusLabel, rescanState);
const showRescanItem = showRescan || isScanInProgress;
const rescanLabel = isScanInProgress
? "Scan in progress"
: isRequesting
? "Requesting..."
: "Request rescan";
async function requestRescan() {
if (!showRescan || isRequesting) return;
setIsRequesting(true);
try {
if (kind === "skill") {
await requestSkillRescan({ skillId: targetId as Doc<"skills">["_id"] });
} else {
await requestPluginRescan({ packageId: targetId as Doc<"packages">["_id"] });
}
toast.success(`Rescan requested for ${targetLabel}.`);
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
} finally {
setIsRequesting(false);
}
}
return (
<div className="dashboard-row-menu">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${targetLabel}`}
>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="dashboard-row-menu-content">
<DropdownMenuItem asChild>
<a href={settingsHref}>
<Settings className="h-4 w-4" aria-hidden="true" />
Settings
</a>
</DropdownMenuItem>
{showRescanItem ? (
<DropdownMenuItem
disabled={isRequesting || isScanInProgress}
onSelect={() => void requestRescan()}
>
<RotateCw
className={
isRequesting || isScanInProgress
? "h-4 w-4 animate-spin [animation-duration:2.4s]"
: "h-4 w-4"
}
aria-hidden="true"
/>
{rescanLabel}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function skillDashboardStatus(skill: DashboardSkill): {
key: "visible" | "pending" | "suspicious" | "blocked" | "hidden" | "removed" | "quality";
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const flags = skill.moderationFlags ?? [];
const reason = skill.moderationReason ?? "";
const versionStatuses = new Set([
skill.latestVersion?.vtStatus,
skill.latestVersion?.llmStatus,
skill.latestVersion?.staticScanStatus,
]);
if (skill.moderationStatus === "removed") {
return {
key: "removed",
label: "Removed",
description: "Removed from public inventory by moderation.",
variant: "destructive",
};
}
if (
flags.includes("blocked.malware") ||
skill.moderationVerdict === "malicious" ||
versionStatuses.has("malicious")
) {
return {
key: "blocked",
label: "Blocked",
description:
"Unavailable publicly because automated security checks found malicious content.",
variant: "destructive",
};
}
if (skill.pendingReview || reason === "pending.scan" || reason === "pending.scan.stale") {
return {
key: "pending",
label: "Pending checks",
description: "Hidden until security verification checks finish.",
variant: "pending",
};
}
if (
skill.qualityDecision === "quarantine" ||
skill.qualityDecision === "reject" ||
reason === "quality.low"
) {
return {
key: "quality",
label: "Quality held",
description: "Unavailable while quality review is holding this release.",
variant: "warning",
};
}
if (
skill.isSuspicious ||
flags.includes("flagged.suspicious") ||
skill.moderationVerdict === "suspicious" ||
versionStatuses.has("suspicious")
) {
return {
key: "suspicious",
label: "Suspicious",
description:
"Visible to you, but public surfaces warn or suppress it because it was flagged.",
variant: "warning",
};
}
if (skill.moderationStatus === "hidden") {
return {
key: "hidden",
label: "Hidden",
description: "Hidden from public catalog surfaces.",
variant: "warning",
};
}
return {
key: "visible",
label: "Visible",
description: "Available on public catalog surfaces.",
variant: "success",
};
}
+1 -1
View File
@@ -227,7 +227,7 @@ export function ImportGitHub() {
description="You need to be signed in to import skills from GitHub."
>
{!isLoading ? (
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
) : null}
</EmptyState>
</Container>
+343 -331
View File
@@ -1,13 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
import { useState } from "react";
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { AlertTriangle, ExternalLink, Download } from "lucide-react";
import type { ComponentProps } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import { DetailSecuritySummary } from "../../components/DetailSecuritySummary";
import { EmptyState } from "../../components/EmptyState";
import { InstallCopyButton } from "../../components/InstallCopyButton";
import { Container } from "../../components/layout/Container";
import { MarkdownPreview } from "../../components/MarkdownPreview";
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { getUserFacingConvexError } from "../../lib/convexError";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import {
fetchPackageDetail,
@@ -19,13 +26,12 @@ import {
type PackageVersionDetail,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
import { useAuthStatus } from "../../lib/useAuthStatus";
type PluginDetailRateLimitState =
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
type PluginDetailRateLimitState = {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -143,63 +149,6 @@ function VerifiedBadge() {
);
}
function fallbackCopy(text: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const ok = document.execCommand("copy");
return ok;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
function CopyButton({ text }: { text: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<Button
variant="outline"
size="sm"
className="w-full shrink-0 sm:w-auto"
onClick={() => {
if (navigator.clipboard?.writeText) {
void navigator.clipboard
.writeText(text)
.then(() => {
setState("copied");
setTimeout(() => setState("idle"), 2000);
})
.catch(() => {
if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
});
} else if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
}}
aria-label="Copy to clipboard"
>
{state === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{state === "copied" ? "Copied" : state === "failed" ? "Failed" : "Copy"}
</Button>
);
}
const CAPABILITY_LABELS: Record<string, string> = {
executesCode: "Executes code",
runtimeId: "Runtime ID",
@@ -234,6 +183,17 @@ function isEmptyObject(obj: unknown): boolean {
function PluginDetailRoute() {
const { name } = Route.useParams();
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
const pathname = useRouterState({ select: (state) => state.location.pathname });
const { isAuthenticated } = useAuthStatus();
const requestPluginRescan = useMutation(api.packages.requestRescan);
const rescanState = useQuery(
api.packages.getOwnerRescanStateByName,
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
if (pathname.includes("/security/")) {
return <Outlet />;
}
if (rateLimited?.scope === "detail") {
return (
@@ -271,6 +231,11 @@ function PluginDetailRoute() {
const pkg = detail.package;
const owner = detail.owner;
const latestRelease = version?.version ?? null;
const isDownloadBlocked =
pkg.verification?.scanStatus === "malicious" ||
latestRelease?.verification?.scanStatus === "malicious" ||
latestRelease?.vtAnalysis?.status === "malicious" ||
latestRelease?.vtAnalysis?.verdict === "malicious";
const installSnippet =
pkg.family === "code-plugin"
? `openclaw plugins install clawhub:${pkg.name}`
@@ -281,6 +246,26 @@ function PluginDetailRoute() {
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
const verification = latestRelease?.verification ?? pkg.verification;
const requestRescan = async () => {
const packageId = (pkg as { _id?: Id<"packages"> })._id;
if (!packageId) {
toast.error("Could not request a rescan for this plugin.");
return;
}
try {
await requestPluginRescan({ packageId });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
const capEntries = capabilities
? Object.entries(capabilities).filter(
@@ -294,12 +279,61 @@ function PluginDetailRoute() {
: [];
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
<main className="section detail-page-section">
<DetailPageShell>
<DetailHero
main={
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{pkg.displayName}</h1>
{pkg.latestVersion ? (
<span className="plugin-version-badge">v{pkg.latestVersion}</span>
) : null}
{pkg.latestVersion && !isDownloadBlocked ? (
<div className="skill-title-actions">
<Button asChild variant="outline" size="sm" className="no-underline">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download
</a>
</Button>
</div>
) : null}
{isDownloadBlocked ? (
<div className="skill-title-actions">
<Badge variant="destructive">Download blocked</Badge>
</div>
) : null}
</div>
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
<Badge>{familyLabel(pkg.family)}</Badge>
{verification?.tier ? (
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
@@ -312,273 +346,251 @@ function PluginDetailRoute() {
<VerifiedBadge />
</Badge>
) : null}
</div>
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
v{pkg.latestVersion}
</span>
) : null}
</h1>
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="break-all font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="opacity-40">&middot;</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
</div>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent" className="mt-3 self-start">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
</div>
</div>
{/* Latest Release */}
{pkg.latestVersion ? (
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
</Card>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Security Scan */}
{latestRelease ? (
<Card>
<CardContent>
<SecurityScanResults
sha256hash={latestRelease.sha256hash ?? undefined}
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
staticFindings={latestRelease.staticScan?.findings ?? []}
</div>
}
sidebar={
<>
{latestRelease ? (
<DetailSecuritySummary
scannerBasePath={`/plugins/${encodeURIComponent(name)}/security`}
sha256hash={latestRelease.sha256hash ?? null}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
staticScan={latestRelease.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={rescanState ? requestRescan : null}
/>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Readme */}
) : null}
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-install-command-wrap">
<pre className="skill-install-command">
<code>{installSnippet}</code>
</pre>
<InstallCopyButton
text={installSnippet}
ariaLabel="Copy plugin install command"
showLabel={false}
className="skill-install-command-inline-button"
/>
</div>
</CardContent>
</Card>
</>
}
>
{readme ? (
<Card>
<Card className="tab-card">
<CardHeader>
<CardTitle>README</CardTitle>
</CardHeader>
<CardContent>
<MarkdownPreview>{readme}</MarkdownPreview>
</CardContent>
</Card>
) : null}
</div>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
</DetailHero>
</DetailPageShell>
</main>
);
}
@@ -0,0 +1,140 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import {
fetchPackageDetail,
fetchPackageVersion,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../../../lib/packageApi";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "openclaw", "static-analysis"]);
type PluginSecurityLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
resolvedName: string;
rateLimited: boolean;
};
function parseScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export const Route = createFileRoute("/plugins/$name/security/$scanner")({
beforeLoad: ({ params }) => {
parseScanner(params.scanner);
},
loader: async ({ params }): Promise<PluginSecurityLoaderData> => {
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
: [requestedName, `@openclaw/${requestedName}`];
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
try {
const candidateDetail = await fetchPackageDetail(candidateName);
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
break;
}
detail = candidateDetail;
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
if (!detail.package?.latestVersion) {
return { detail, version: null, resolvedName, rateLimited: false };
}
try {
const version = await fetchPackageVersion(resolvedName, detail.package.latestVersion);
return { detail, version, resolvedName, rateLimited: false };
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
},
head: ({ params, loaderData }) => {
const scanner = parseScanner(params.scanner);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "ClawScan"
: "Static analysis";
return {
meta: [
{
title: `${scannerLabel} security · ${
loaderData?.detail.package?.displayName ?? params.name
}`,
},
{
name: "description",
content: `${scannerLabel} security details for ${
loaderData?.detail.package?.displayName ?? params.name
}.`,
},
],
};
},
component: PluginSecurityScannerRoute,
});
function PluginSecurityScannerRoute() {
const { name, scanner } = Route.useParams();
const { detail, version, resolvedName, rateLimited } = Route.useLoaderData();
const pkg = detail.package;
const release = version?.version ?? null;
if (rateLimited) {
return (
<main className="section">
<div className="card">Plugin security details are temporarily unavailable.</div>
</main>
);
}
if (!pkg || !release) {
return (
<main className="section">
<div className="card">Security details are unavailable for this plugin.</div>
</main>
);
}
return (
<SecurityScannerPage
scanner={parseScanner(scanner)}
entity={{
kind: "plugin",
title: pkg.displayName,
name: resolvedName,
version: release.version,
owner: detail.owner ?? null,
ownerUserId: null,
ownerPublisherId: null,
detailPath: `/plugins/${encodeURIComponent(name)}`,
}}
sha256hash={release.sha256hash ?? null}
vtAnalysis={release.vtAnalysis ?? null}
llmAnalysis={release.llmAnalysis ?? null}
staticScan={release.staticScan ?? null}
/>
);
}
+1 -1
View File
@@ -349,7 +349,7 @@ export function Upload() {
title={`Sign in to publish a ${contentLabel}`}
description="You need to be signed in to publish skills on ClawHub."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</Container>
</main>
+1 -1
View File
@@ -165,7 +165,7 @@ export function Settings() {
<Card>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access settings.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</CardContent>
</Card>
</Container>
+1 -1
View File
@@ -32,7 +32,7 @@ function Stars() {
title="Sign in to see your highlights"
description="Star skills for quick access later."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</div>
</main>
+730 -423
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"],
"convex": ["./node_modules/convex"],
"convex/*": ["./node_modules/convex/*"]
},