mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1bad4ac1 | ||
|
|
8f7c21ac02 | ||
|
|
999131e2e7 | ||
|
|
571cc70c7a | ||
|
|
3ead985f82 | ||
|
|
1117aa4340 | ||
|
|
9ce5e702b5 | ||
|
|
c632b697bb | ||
|
|
d2525b179a | ||
|
|
8ebae39140 | ||
|
|
821494b9dd | ||
|
|
94413a60fb | ||
|
|
9cbf98297c | ||
|
|
94ded18dec | ||
|
|
9897850074 | ||
|
|
159118dc59 | ||
|
|
843f19e640 | ||
|
|
4b882b2f43 | ||
|
|
634667c2c8 | ||
|
|
bd30b182d7 | ||
|
|
2d719feef5 | ||
|
|
d3e1059266 | ||
|
|
1a968d8243 | ||
|
|
59667cc1a5 | ||
|
|
5b0dd96530 | ||
|
|
8e3858a31d | ||
|
|
fe4acc8e10 | ||
|
|
f2e6e87756 | ||
|
|
3de69919de | ||
|
|
7629433248 | ||
|
|
b82ad43eae | ||
|
|
b7ee5c8e62 | ||
|
|
1c8543ade6 | ||
|
|
7cd0ef362d | ||
|
|
9cf9e12bdd | ||
|
|
112e25e28c | ||
|
|
d786374b77 | ||
|
|
3fbe27560e | ||
|
|
33539b6df3 | ||
|
|
354f12a033 | ||
|
|
b0ea80df6c |
@@ -16,5 +16,16 @@ AUTH_GITHUB_SECRET=
|
||||
JWT_PRIVATE_KEY=
|
||||
JWKS=
|
||||
|
||||
# Local dev personas
|
||||
DEV_AUTH_ENABLED=
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT=
|
||||
DEV_AUTH_SITE_URL=
|
||||
DEV_AUTH_SECRET=
|
||||
|
||||
# Embeddings
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Transactional email
|
||||
RESEND_API_KEY=
|
||||
CLAWHUB_SECURITY_EMAIL=security@notifications.openclaw.ai
|
||||
CLAWHUB_SECURITY_EMAIL_FROM=ClawHub Security <noreply@notifications.openclaw.ai>
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
|
||||
@@ -88,13 +88,13 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-light/${{ matrix.category }}"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: ClawHub Scheduled Live Checks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 5 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
github-repo:
|
||||
description: GitHub skills repo to use for the source-backed canary
|
||||
required: false
|
||||
default: openclaw/agent-skills
|
||||
github-skill:
|
||||
description: Skill slug to verify from the GitHub skills repo
|
||||
required: false
|
||||
default: handoff
|
||||
|
||||
concurrency:
|
||||
group: clawhub-scheduled-live-checks-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
jobs:
|
||||
github-backed-skills:
|
||||
name: GitHub-backed skills canary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run GitHub-backed skills live canary
|
||||
env:
|
||||
CLAWHUB_LIVE_GITHUB_CANARY: "1"
|
||||
CLAWHUB_LIVE_GITHUB_REPO: ${{ inputs.github-repo || 'openclaw/agent-skills' }}
|
||||
CLAWHUB_LIVE_GITHUB_SKILL: ${{ inputs.github-skill || 'handoff' }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bunx vitest run convex/githubSkillSync.live.test.ts
|
||||
|
||||
open-failure-issue:
|
||||
name: Open failure issue
|
||||
needs: github-backed-skills
|
||||
if: ${{ always() && needs.github-backed-skills.result == 'failure' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open or update failure issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
WORKFLOW_NAME: ${{ github.workflow }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
marker_token="clawhub-scheduled-live-checks-failure"
|
||||
marker="<!-- $marker_token -->"
|
||||
title="ClawHub scheduled live checks failing"
|
||||
issue_number="$(gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state open \
|
||||
--search "$marker_token in:body" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')"
|
||||
|
||||
body_file="$(mktemp)"
|
||||
cat > "$body_file" <<EOF
|
||||
$marker
|
||||
The scheduled ClawHub live checks failed.
|
||||
|
||||
Workflow: $WORKFLOW_NAME
|
||||
Run: $RUN_URL
|
||||
EOF
|
||||
|
||||
if [[ -n "$issue_number" ]]; then
|
||||
gh issue comment "$issue_number" --repo "$GITHUB_REPOSITORY" --body-file "$body_file"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
id: trufflehog
|
||||
# Use a concrete released ref that resolves in upstream action registry.
|
||||
# v3 (major tag) is not published by trufflesecurity/trufflehog.
|
||||
uses: trufflesecurity/trufflehog@v3.95.3
|
||||
uses: trufflesecurity/trufflehog@v3.95.5
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ steps.scan_range.outputs.base }}
|
||||
|
||||
@@ -3,6 +3,7 @@ node_modules
|
||||
.bun-build
|
||||
*.bun-build
|
||||
.artifacts/
|
||||
artifacts/
|
||||
.cache/
|
||||
.data/
|
||||
bin/docs-list
|
||||
|
||||
@@ -51,6 +51,7 @@ Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbo
|
||||
- Tests live in `src/**` and `convex/lib/**`.
|
||||
- Coverage threshold: 80% global (lines/functions/branches/statements).
|
||||
- Example: `convex/lib/skills.test.ts`.
|
||||
- For local UI state testing, prefer creating realistic backend state through seed logic plus a DevPersonaFab entry for the associated test user. Avoid one-off manual DB edits when the state is likely to be reused, such as org membership, official publisher access, moderation holds, or publishing permissions.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
@@ -60,6 +61,7 @@ Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbo
|
||||
- Before commit/PR handoff for non-trivial code changes, use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already happened, the change is trivial/docs-only, or the user opts out.
|
||||
- Before opening a PR for source or test changes, run the targeted tests for the touched behavior and `bun run ci:unit` (`VITE_CONVEX_URL=https://example.invalid bun run coverage`) unless the change is docs/config-only or the user explicitly asks to rely on CI. For runtime, build, or package changes, also run the matching broader gate when it covers the touched surface: `bun run ci:types-build`, `bun run ci:packages`, `bun run ci:e2e-http`, or `bun run ci:playwright-smoke`.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Screenshot proof MUST come from a real running ClawHub instance in a real browser. Do not use generated HTML mockups, synthetic terminal cards, or manually composed images as proof. For route/status/backend visibility bugs, run ClawHub locally with the relevant Convex code and fixture state, capture the actual browser page, and state the local URL and fixture used.
|
||||
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.20.0 - 2026-06-06
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/API: replace local `clawhub scan` uploads with stored submitted-version scan report downloads, including owner-authorized `clawhub scan download <name> --version <version>` support for blocked skill and plugin submissions.
|
||||
|
||||
## 0.19.2 - 2026-06-05
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: accept the legacy `clawhub skill verify --json` flag as a hidden compatibility no-op while continuing to print JSON by default.
|
||||
|
||||
## 0.19.1 - 2026-06-05
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: install source-backed GitHub skills from the deployed `/api/v1/skills/:slug/install` resolver so `clawhub install` works for skills without hosted ClawHub versions.
|
||||
|
||||
## 0.19.0 - 2026-06-03
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.4",
|
||||
"@convex-dev/auth": "0.0.92",
|
||||
"@convex-dev/auth": "0.0.93",
|
||||
"@fontsource/bricolage-grotesque": "5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
"@fontsource/manrope": "5.2.8",
|
||||
@@ -22,29 +22,30 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@resvg/resvg-wasm": "2.6.2",
|
||||
"@shikijs/rehype": "4.1.0",
|
||||
"@tanstack/react-router": "1.170.8",
|
||||
"@tanstack/react-start": "1.168.13",
|
||||
"@shikijs/rehype": "4.2.0",
|
||||
"@tanstack/react-router": "1.170.12",
|
||||
"@tanstack/react-start": "1.168.21",
|
||||
"@vercel/analytics": "2.0.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clawhub-schema": "workspace:0.0.2",
|
||||
"clsx": "2.1.1",
|
||||
"convex": "1.39.1",
|
||||
"convex": "1.40.0",
|
||||
"convex-helpers": "0.1.118",
|
||||
"fflate": "0.8.3",
|
||||
"h3": "2.0.1-rc.22",
|
||||
"ignore": "7.0.5",
|
||||
"lucide-react": "1.16.0",
|
||||
"lucide-react": "1.17.0",
|
||||
"mime": "4.1.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-markdown": "10.1.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"semver": "7.8.1",
|
||||
"shiki": "4.1.0",
|
||||
"resend": "6.12.4",
|
||||
"semver": "7.8.2",
|
||||
"shiki": "4.2.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -62,42 +63,42 @@
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"jsdom": "29.1.1",
|
||||
"nitro": "3.0.260429-beta",
|
||||
"only-allow": "1.2.2",
|
||||
"oxfmt": "0.51.0",
|
||||
"oxlint": "1.66.0",
|
||||
"oxfmt": "0.53.0",
|
||||
"oxlint": "1.68.0",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "7.26.0",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "4.1.7",
|
||||
"undici": "7.27.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.8",
|
||||
},
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.4.0",
|
||||
"@clack/prompts": "1.5.1",
|
||||
"arktype": "2.2.0",
|
||||
"commander": "14.0.3",
|
||||
"commander": "15.0.0",
|
||||
"fflate": "0.8.3",
|
||||
"ignore": "7.0.5",
|
||||
"json5": "2.2.3",
|
||||
"mime": "4.1.0",
|
||||
"ora": "9.4.0",
|
||||
"p-retry": "8.0.0",
|
||||
"semver": "7.8.1",
|
||||
"undici": "7.26.0",
|
||||
"semver": "7.8.2",
|
||||
"undici": "7.27.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.9.1",
|
||||
@@ -111,17 +112,17 @@
|
||||
"clawhub-mod": "bin/clawhub-mod.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.4.0",
|
||||
"@clack/prompts": "1.5.1",
|
||||
"arktype": "2.2.0",
|
||||
"commander": "14.0.3",
|
||||
"commander": "15.0.0",
|
||||
"fflate": "0.8.3",
|
||||
"ignore": "7.0.5",
|
||||
"json5": "2.2.3",
|
||||
"mime": "4.1.0",
|
||||
"ora": "9.4.0",
|
||||
"p-retry": "8.0.0",
|
||||
"semver": "7.8.1",
|
||||
"undici": "7.26.0",
|
||||
"semver": "7.8.2",
|
||||
"undici": "7.27.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "25.9.1",
|
||||
@@ -160,55 +161,49 @@
|
||||
|
||||
"@auth/core": ["@auth/core@0.37.4", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^5.9.6", "oauth4webapi": "^3.1.1", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-HOXJwXWXQRhbBDHlMU0K/6FT1v+wjtzdKhsNg0ZN7/gne6XPsIrjZ4daMcFnbq0Z/vsAbYBinQhhua0d77v7qw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="],
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="],
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="],
|
||||
"@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="],
|
||||
"@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="],
|
||||
|
||||
"@convex-dev/auth": ["@convex-dev/auth@0.0.92", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-tNRIMTDxi2vrbT+3vz1FgNR1321IfIBDDBy59zul7E1DyzWQKoU0OzgFqWbiVm3o8gn0eQsYTU3UHNRX9kp3wQ=="],
|
||||
"@convex-dev/auth": ["@convex-dev/auth@0.0.93", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-z7g1lxcNz1Yck238i79rCIubT+rCM1V9sDUdUkZfthPmOarDx0uchut/Gy78xvjQTTSy1oW1XHECKxdclmdJQA=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
@@ -218,7 +213,7 @@
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="],
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
@@ -280,7 +275,7 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@faker-js/faker": ["@faker-js/faker@10.4.0", "", {}, "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw=="],
|
||||
|
||||
@@ -376,43 +371,43 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-Ni0sCqg5CIHaLIYFGj+ncbcumylvNC6FE4rfD0KfdmnWHbPJ+zev0qZCXKxy2hFVa0fYRK0yPzf5nzPbkZou7g=="],
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.53.0", "", { "os": "android", "cpu": "arm" }, "sha512-XfVM8AmIovBTKXCt14Op5wbfcoM8418nttd+nhMgM3RAVaJg1MtJc73FyWfUt0oxLyBGVwfniNVUsbV/b3VmPg=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.51.0", "", { "os": "android", "cpu": "arm64" }, "sha512-eu5lAZjuo0KAkp+M24EhDqfOwA8owQ8d7wyBlOUUGRbDLHpU3IRlDHp8Dif+YqGlxs6jra7yS6WQu/NkPhAxeg=="],
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.53.0", "", { "os": "android", "cpu": "arm64" }, "sha512-btHDfXckwdf9zgyAVznfZkf+GVyB0I1m1hlvaOMRx2xoyz3hphfPX97s89J3wfCN8QBETLtk4lQUaeOkrMuQOg=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.51.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6LsUNIdURhhcIfIn8+xsOb61mSTa9msAHTeSGx9Jf4rsP/gN8PGCF+SKWPAQZbND2w/WBkqQ6303jqEEIXzMdQ=="],
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.53.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k2RjMcSTkHjoOlsVGbL35JVzXL+oQco3GHPl/5kjebVF4oHNfE24In8F5isqBh9LBJucycWHKDXdGrCchdWcHQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.51.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-9aUMGmVxdHjYMsEAW1tNRoieTJXlVNDFkRvIR1J7LttJXWjVYCu2ekclLij2KJtxBxSQOYSHd12ME/adVGVbZg=="],
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.53.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-65jIBE2H1l5SSs16fmv6/7b6sAx/WpvnsgDhVWK9qSjNFDUro7MPQ6q5UhpY7kl46yltfR046iAnxy/Bzqbiew=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.51.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mkY1nhZTqYb+NHaAWxOCKISN6FwdrwMNsu17vTUA3wzUV2VJ+Paq15ZokRcsMU/2PUdHO73prxyeJpjXQ3MPpQ=="],
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.53.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-oYe1gkz7U49PCYrS9147d2fJZj8mDI4Di6AvlsU5fu9p+Tq8S7qqOMSZjUiVTLX8bXuSA9Lk/tIxuegVjkNYRA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.51.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wtFwNwE4+YCNuPaWoGDZeGsKvD6D1YSUNBJNn/rJBh7CrDBThFE+TBI5kY7vRW9rIOQRsbW2IpyyL3Du4Zqwiw=="],
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.53.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ailB2vLzGi629tymdAb2VYJyEHref7oqGxP+tRBrtRBxQrb6NV55JMT7xtGZ8uTeG2+Y9zojqW4LhJYxQnz9Pg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.51.0", "", { "os": "linux", "cpu": "arm" }, "sha512-rnOaNx86G7iRKM6lsCIQMux0SMGNC/TEbFR+r7lpruJ12bnrIWgxd5w1PLqOvgR9r8ZJbpK/zfRKctJnh8/Jfg=="],
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.53.0", "", { "os": "linux", "cpu": "arm" }, "sha512-abh4mWBvOvD966sobqF7r103y2yYx7Rb4WGHLOS4+5igGqLbbPxS9aK5+45D6iUY7dWMsk3Muz9a8gUtufvqJA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.51.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jOgDzSqWcICGRjsp4mc08FxKMN8vzP2Kgs4E0d2HUP99F+nJDQKklRV4Zuj+0gcBgjrzx2CbpqaIdUVPepCojA=="],
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.53.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-z73PvuhJ8qA+cDbaiqbtopHglA91U4+y5wn2sTJJrnpB957d5P33FEuyP3DQIFd7ofljmDmfVT4G0CVGHZaJWg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.51.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-KBUCdrH5bwVrAvI9gU/1S55oH6fzXjr++J/oVocdu7bYTks1l7DNNT+rLd/1TDdAEjObGwmfWamn7LC1m8A0DQ=="],
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.53.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.51.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NapfjYsABFqTJ1Dn9Efq6sN5esaHconVKwVLbDGNQLrwpOx/g17mkwErHzU72PutL67nf3wNAkbq122H+zLxag=="],
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.53.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.51.0", "", { "os": "linux", "cpu": "none" }, "sha512-5dlDt1dUZCVi6elIhiK1PWg9wpTzTcIuj0IZnSurvIoMrhOWqqTcc1dSTxcSkNaBZhfsNqRZdINI1zAgbKkJNQ=="],
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.53.0", "", { "os": "linux", "cpu": "none" }, "sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.51.0", "", { "os": "linux", "cpu": "none" }, "sha512-pgdWUJn0S5nulyiVdlFV8DzCUnGXkU99W5PSkkmbaZW+LrZBPxpezun4G0DDHbQaVYuJeCuKsXsGKGo77CkUTQ=="],
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.53.0", "", { "os": "linux", "cpu": "none" }, "sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.51.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2XTFUe97CbDGAI8vjwDfZ1HdakO0XIADyJ24idEg64SC4/K4in/OisXVnrW4NMK7I6TgC7EqRhC0Ln/nKhAemA=="],
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.53.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.51.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kQ1OuCqqt/yyf0ZN9VFxW1/JnlgJgii3Dr7pWf9vNBvrX1hv6g39/+mc5oGRHRGJFZtl3zsGDWR9c5N2B/gwBw=="],
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.53.0", "", { "os": "linux", "cpu": "x64" }, "sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.51.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ARTYqxHF475o96Gbn41hvSWSSRygPlRDXZZgZ9I2scU1y0qiWpCQyZCoefaQa0mwv+wwtZ+luS4YOzsRzM/izg=="],
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.53.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.51.0", "", { "os": "none", "cpu": "arm64" }, "sha512-QiC1XrCl6a6BmqMzduO8hdIRMf1m44hCkt2Q68KWkTvUB/E7fd2iomyNh6KnnRca5w6eBrRAAtLFqTh+xjsjJA=="],
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.53.0", "", { "os": "none", "cpu": "arm64" }, "sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.51.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-NC/hJb9dtU23Zf8L7IVK95xnFjiQ7AfcLO2l5pb69TDEr958qxrtnB2CveeeNSCBFNIkgaTCfd/vHNSoG78l9g=="],
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.53.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yq9sOZoIOJ5xPjO0qOyHJS4CiPuTkB2en9auxZz7Ar2p5RaC7BzLyVVmAA7zz9/L9YnjjY1DwNxN+ivKXimN/A=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.51.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-2C45za4Rj36n8YIbhRL1PQbxmXJYf81WEcAgvj5I4ptRROG+A+81hREEN5bmCHADE1UfYaN312U6tkILoZZy6w=="],
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.53.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-es1fVNZEkBqEcQtBpn19SYFgZF7FawlkCjkT/iImfEAus4gun8fBwB1E9hpV5LcR9B0DBNvRIXhW8BQk3JaE+Q=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-73RqdAuVKQTkjZIDw08JaDHUM4lav5Qu+CaPwg4QbbA7k8o7LEW0p3UsfZ/F8dsO/pwVYh3RzFcanwLRTTahbQ=="],
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.53.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QFmJs2bEu9AO4O6qsmEaZNGi6dFq8N+rT8EHAAnZIq/B9SeJDUbc4DzVxQ48MfDsL7D3sCZzo37zuTuspcURgg=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="],
|
||||
|
||||
@@ -426,43 +421,43 @@
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw=="],
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.68.0", "", { "os": "android", "cpu": "arm" }, "sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg=="],
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.68.0", "", { "os": "android", "cpu": "arm64" }, "sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ=="],
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.68.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A=="],
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.68.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw=="],
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.68.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig=="],
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.68.0", "", { "os": "linux", "cpu": "arm" }, "sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg=="],
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.68.0", "", { "os": "linux", "cpu": "arm" }, "sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g=="],
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.68.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg=="],
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.68.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA=="],
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.68.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag=="],
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.68.0", "", { "os": "linux", "cpu": "none" }, "sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w=="],
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.68.0", "", { "os": "linux", "cpu": "none" }, "sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg=="],
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.68.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww=="],
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.68.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ=="],
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.68.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg=="],
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.68.0", "", { "os": "none", "cpu": "arm64" }, "sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg=="],
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.68.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA=="],
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.68.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ=="],
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.68.0", "", { "os": "win32", "cpu": "x64" }, "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
@@ -546,56 +541,58 @@
|
||||
|
||||
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gCYzGOSkYY6Z034suzd20euvds7lPzMEEla62DJGE/ZAlR4OMBnNbvnBSsIGUCAr52gaWMsloGxP4tVGtN5aCA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JQBD77MNgu+4Z6RAyg69acugdrhhVoWesr3l47zohYZ2YV2fwkWMArkN/2p4l6Ei+Sno7W5q+UsKdVWq5Ens0w=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p/8cXUTK4Sob604e+xxPhVSbDFf29E6J0l/xESM9rdCfn3aDai3nEs6TnMHUsdD5aNlFz0+gDbiGlozLKGa2YA=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KbtOSlVv6fElujiZWMcC3aQYhEwLVVf073RcwlSmpGQvIsKZFUqc0ef4sjUuurRwfbiI6JJXji9DQn+86hawmQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9fZ9i0o0/MQaw7om6Z6TsT7tfCk0jtbEFtC+aPqZL5RNsGWNcHvn6EHgL3dAprjq+AZzPTAQjg2JtpJaMt+6pg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tog7T66i+yFyIuuAnjL6xmW182W/qTBOUt6BtQ6lBIM1Eikh/fSMz4HGgvuCp5uU0zuIVWng7kDYthjCMOHcg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4b7yruLIIj/oZ3GpcLOvxcLCLDMraohn3IhQfN2hBP4w9UekG0DTIajWguJosRGfySf/+h/NwRUiMKoCpxCrqQ=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QRDOVZd0bhQ5jLsUsCC3dUxDWdTSVY9WMznowZgCGOrZfLLgctWpelhUASEiBwsXfat/JwYnVd1EaxMhqyT+UQ=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ypxT+Hq76NFG7woFbNbySnGEajFuYuIXeKz/jfCU+lXUoxfi3zLE6OG/ZQNeK3RpZSYJlAe2bokpsQ046CaieQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IdovCmfROFmpTLahdecTDFL74aLERVYN68F/mLZjfVh6LfoplPfI6deyHNMTcVujbokDV5k05XrFO22zfv+qjg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pcA8xlFp2tyk9T2R6Fi/rPe3bQ1MA+sSMDNUU5Ogu80GHOatkE4P8YCreGAvZErm5Ho2YRXnyvNrWiRncfVysQ=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.1", "", { "os": "none", "cpu": "arm64" }, "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4+fexHayrLCWpriPh4c6dNvL4an34DEZCG7zOM/FD5QNF6h8DT+bDXzyB/kfC8lDJbaFb7jKShtnjDQFXVQEjg=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.1", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-SbL++MNmOw6QamrwIGDMSSfM4ceTzFr+RjbOExJSLLBinScU4WI5OdA413h1qwPw2yH7lVF1+H4svQ+6mSXKTQ=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+xTE6XC7wBgk0VKRXGG+QAnyW5S9b8vfsFpiMjf0waQTmSQSU8onsH/beyZ8X4aXVveJnotiy7VDjLOaW8bTrg=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.1", "", { "os": "win32", "cpu": "x64" }, "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ogji1TQNqH3ACLnYr+1Ns1nyrJ0CO2P585u9Hsh02pXvtFiFpgtgT2b3P4PnCOU86VVCvqtAeCN4OftMT8KU4w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
|
||||
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
|
||||
|
||||
"@shikijs/rehype": ["@shikijs/rehype@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "4.1.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-HQwltCcO2/UiFz44/8whyji4rP1VghLu++MgvQn+lQA8/gvuycGkay8DH8o8VAOvLBDKGOkBEw7cC1Cm33GObQ=="],
|
||||
"@shikijs/rehype": ["@shikijs/rehype@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "4.2.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-ST3EWye/dwF1gWskczJNBnwFtDzEQ9ceytXZtyc/GfwR5V0qJrkoSGZO55O3SAKDDsXkTDcsfwd9pVe7ROlAHg=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
|
||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
|
||||
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
@@ -638,35 +635,35 @@
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.170.8", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.6", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Qw2ju6jjnIsMpuW+VrnHZWHuugqs592PWsnI56sG28qNhg14CgRLahOcNajfuJR9P4MxKGP94WVzmFKSYUz/ig=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.170.12", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.10", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-3QSX1kTBFCHV1pMePdLhcmxl2E7io8W59a5f+jDQs+BZjZuRLUDtOuGFiHUGaUCsYHxv6z2dCqWvwxaU6WJpyA=="],
|
||||
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.168.13", "", { "dependencies": { "@tanstack/react-router": "1.170.8", "@tanstack/react-start-client": "1.168.4", "@tanstack/react-start-rsc": "0.1.13", "@tanstack/react-start-server": "1.167.9", "@tanstack/router-utils": "1.162.1", "@tanstack/start-client-core": "1.170.4", "@tanstack/start-plugin-core": "1.171.6", "@tanstack/start-server-core": "1.169.4", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-E2pHQ92NiND1/HiD5Ax71xFXxiRZ2reOfU5W4BqxUL5plap3p8xSw1c6L8Np1E60vsxknuPCYRZESKkRy/LkOA=="],
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.168.21", "", { "dependencies": { "@tanstack/react-router": "1.170.12", "@tanstack/react-start-client": "1.168.9", "@tanstack/react-start-rsc": "0.1.20", "@tanstack/react-start-server": "1.167.15", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.8", "@tanstack/start-plugin-core": "1.171.13", "@tanstack/start-server-core": "1.169.10", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-vEdTgtH4xaj19XFStSU9MG40JdjGjmro0l9rxvuY2/Ns1spmHbOhIJGoNm/xH7HAYNvGByxny4/QMzay7vZ9Xw=="],
|
||||
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.4", "", { "dependencies": { "@tanstack/react-router": "1.170.8", "@tanstack/router-core": "1.171.6", "@tanstack/start-client-core": "1.170.4" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-PDJ7xEuUKrlBiQz2PrVN9pD2ErmWeFpckYW1WUE8JCAeVi8U7C6rQNTQe4hQxBhycRfRdD53M6UfdWdQODIxyg=="],
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.9", "", { "dependencies": { "@tanstack/react-router": "1.170.12", "@tanstack/router-core": "1.171.10", "@tanstack/start-client-core": "1.170.8" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-VLKTu2rmn7C13g4y9W18mGer5fx0klx4YHxYVKR+da4P2pogjumAMMlRRvTMqoruBCSVGDp9VxprU7MEsomoFQ=="],
|
||||
|
||||
"@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.13", "", { "dependencies": { "@tanstack/react-router": "1.170.8", "@tanstack/react-start-server": "1.167.9", "@tanstack/router-core": "1.171.6", "@tanstack/router-utils": "1.162.1", "@tanstack/start-client-core": "1.170.4", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.6", "@tanstack/start-server-core": "1.169.4", "@tanstack/start-storage-context": "1.167.8", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-nl5pKkxy1RnRxOLjy/c3g/RKdQSQYWzK5iuLlsRaO9TbLuMhQlNAn255xQgVXG56G9xCtDg8/nD0ZycxSlSkWA=="],
|
||||
"@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.20", "", { "dependencies": { "@tanstack/react-router": "1.170.12", "@tanstack/router-core": "1.171.10", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.8", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.13", "@tanstack/start-server-core": "1.169.10", "@tanstack/start-storage-context": "1.167.12", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-0gXLJQEc2ltL0t/+jbyQV1TRmGVkRre9c+pFQ/41pjHmX8Cj9j1EwSrENYkJF0ghFSpeP7T8YzBQcNew6o1o7g=="],
|
||||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.9", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-router": "1.170.8", "@tanstack/router-core": "1.171.6", "@tanstack/start-client-core": "1.170.4", "@tanstack/start-server-core": "1.169.4" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-a1SGeeoIEg411vEN6DThB2Bm5tiYBb0tCC/RaG8BSjRVtsY6kxD9cP1+LOpZwjRSgfdyqtSbe1v78ZDB9z0/uw=="],
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.15", "", { "dependencies": { "@tanstack/react-router": "1.170.12", "@tanstack/router-core": "1.171.10", "@tanstack/start-server-core": "1.169.10" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-HaraKuBsPYj2i9deu7cofpDWv8hXMQrIosZfAq/ARRMjmwAEO/Im+dzrDRFusJxDQ6h/8QqznTYBT0t7KIUQjQ=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.6", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-Ol6DQ+j6rf/rPVELIzo8LHwOQV2KL+zry3b+39kL/GKrt7YId52WJRAFMzuseY4XceSW+PU7sG/Cc1QkwJr0hg=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.171.10", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-EOOvTUBUS2W/DtyqG0A0HW6RLbsbZBVTu3nsWdFpUoAfDxHPjOqRNjz3xwq5KhjMlYuqtHNgaajAd18aebd0ZQ=="],
|
||||
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.167.10", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.6", "@tanstack/router-utils": "1.162.1", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-CjbjWRSo6djLU/C7ncb9IbKUcf4IwpdqhLGngkwKkXaVFXGxEAafA/uhvOCv/UEUVR7NI3tJqqQmxYXGcJPbjw=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.167.14", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.10", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-63KL39u6H8qr8A9KtUs9IH3CE4mYErk1zTAlmdbHJ5qpsV1FrRb7kLjRR0BzzgwqPPfMe52Gr/X6beBOLcQq/g=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.6", "@tanstack/router-generator": "1.167.10", "@tanstack/router-utils": "1.162.1", "@tanstack/virtual-file-routes": "1.162.0", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.8", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-b2eom/8xCWL/OiWxKub8kYsr8p+kvmB/eXwYGqCWG8vilcJo+eQCSyp54nKt0AZ5k/ET1+eINc+4mwL3bVeAgg=="],
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.15", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.10", "@tanstack/router-generator": "1.167.14", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.12", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-Gb+29gWahpi9nqCCYswsADsFJzPLAfWWQ0BfYmY2XUr2pZZYG+RY2w6ZXQFjyvUPvDX6Xm2CqCq9HU0grxASqA=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-62layyTGmclHDQS/eidwKRfN1hhCKwViG7iEBcVmL0MXgcAB3OOucWCEcDDGd9Cu11H6b4QQ5oOo47MWIqwz0A=="],
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="],
|
||||
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.4", "", { "dependencies": { "@tanstack/router-core": "1.171.6", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.8", "seroval": "^1.5.4" } }, "sha512-j/Deupf0zR7P5QObN38xTHufCRZkWTb6a/7aauu8eBmzOzDVggvuEdYHRZWiwJ9HRKbR2/SIJASVKeTtj1OcWw=="],
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.8", "", { "dependencies": { "@tanstack/router-core": "1.171.10", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.12", "seroval": "^1.5.4" } }, "sha512-v1V6TGWdMW7Yu5/11k1ODvKe0lfTDR4iw7QIxv75P73u66mqmqfheKRXlp5tFfb3yR1kX9xS38jjdeVDdnWC1Q=="],
|
||||
|
||||
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="],
|
||||
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.6", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.1", "@tanstack/router-core": "1.171.6", "@tanstack/router-generator": "1.167.10", "@tanstack/router-plugin": "1.168.11", "@tanstack/router-utils": "1.162.1", "@tanstack/start-client-core": "1.170.4", "@tanstack/start-server-core": "1.169.4", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-e0AUN+omib0qLgs0r3zoKRSeHEkwL8qs8skvbl8zgDQXw9zF73K7ZXE7QarSzbqfLAiehVqlv0iPETp8ogUftQ=="],
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.13", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.10", "@tanstack/router-generator": "1.167.14", "@tanstack/router-plugin": "1.168.15", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.10", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-1l71rj8vIUfaazmzZXzaFkfEvS5kYIxm7a8bmPStuejO9+kfWh8oja/mzA4UMfRst+MQwfFYsin8eYoHwqgagg=="],
|
||||
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.4", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/router-core": "1.171.6", "@tanstack/start-client-core": "1.170.4", "@tanstack/start-storage-context": "1.167.8", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" } }, "sha512-iM3HamWRQPROuAb+22frV/+GkqG2a3rL0X14N+Y0Dt5OajrIumPuprOn9ldUXsbdg89RTBf1KoJNDPeYGOqH4g=="],
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.10", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/router-core": "1.171.10", "@tanstack/start-client-core": "1.170.8", "@tanstack/start-storage-context": "1.167.12", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" } }, "sha512-wnTrG3wha06J29x4h1G0GaEhG9/ckzitMwqUJy9aHajweCFlq1RZ2VWDXjpmeHpd4SLunzik7ixdZaOBKBN6tg=="],
|
||||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.8", "", { "dependencies": { "@tanstack/router-core": "1.171.6" } }, "sha512-y9T+bIIp1ihLAXyS2+r+UovSupfu4KydSXpnoeRsw/14/E0huJsX7xB/n6XXOdmDYAaJ2WGOrG9wYjzeIDuBAw=="],
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.12", "", { "dependencies": { "@tanstack/router-core": "1.171.10" } }, "sha512-f5Z5nqCRlDvuHCXVDYEmFX9RAjXrdCK6pjmGUh/U9qeZdBCg4hIqJ17l1YuHQx+Jsi6su5dihTBs1NfvINpL9A=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
|
||||
@@ -698,7 +695,7 @@
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
@@ -714,29 +711,27 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.7", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.7", "vitest": "4.1.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ=="],
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"ansis": ["ansis@4.3.0", "", {}, "sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
@@ -750,23 +745,19 @@
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="],
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
@@ -782,7 +773,7 @@
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
@@ -798,13 +789,13 @@
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convex": ["convex@1.39.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ=="],
|
||||
"convex": ["convex@1.40.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.20.1" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-jChWEB45q+9Ibryc7hg0l6hB1xA4zwE2y6ZhkhGP6oJkqYeiURkMagA2ZQZYMy1/T8PZ9ztoVJJtbL/+Ob851Q=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.118", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5 || ^6.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-07t10n8CZG/YCDzOy5/WDdNNQYL+mP7VU76BLJCZrB2dvJTH7UZJxPqNrhPH+pZbW52joQ91eQHSksdcgOXebQ=="],
|
||||
|
||||
@@ -842,13 +833,13 @@
|
||||
|
||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.354", "", {}, "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="],
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="],
|
||||
"env-runner": ["env-runner@0.1.9", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.15" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
|
||||
@@ -868,11 +859,13 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -880,8 +873,6 @@
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
@@ -890,8 +881,6 @@
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="],
|
||||
@@ -930,7 +919,7 @@
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"httpxy": ["httpxy@0.5.1", "", {}, "sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A=="],
|
||||
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
@@ -940,29 +929,21 @@
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.40", "", {}, "sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ=="],
|
||||
"isbot": ["isbot@5.1.41", "", {}, "sha512-9WFV/Vhh0FEj6CQ7MoHweEL9/vLKPjeoD2I2htbAjX7kbW7VJs3OCpWOVyd+JraNTWVU6/DRx2MZy2KaUNXHcg=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
@@ -976,7 +957,7 @@
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
|
||||
@@ -986,7 +967,7 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="],
|
||||
|
||||
"launch-editor": ["launch-editor@2.13.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.3" } }, "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg=="],
|
||||
"launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
@@ -1016,17 +997,17 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="],
|
||||
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="],
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
@@ -1136,13 +1117,11 @@
|
||||
|
||||
"nitro": ["nitro@3.0.260429-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.5", "db0": "^0.3.4", "env-runner": "^0.1.7", "h3": "^2.0.1-rc.20", "hookable": "^6.1.1", "nf3": "^0.3.16", "ocache": "^0.1.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.17", "srvx": "^0.11.15", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.1.6", "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.60.2", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KweLVCUN5X9v9g+4yxAyRcz3FcOlnjmt9FyrAIWDxJETJmNT7I0JV0clgsONjo2nI0U5gwedXYA3RaNtF5XWzg=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
|
||||
|
||||
"ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="],
|
||||
|
||||
@@ -1162,9 +1141,9 @@
|
||||
|
||||
"oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.51.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.51.0", "@oxfmt/binding-android-arm64": "0.51.0", "@oxfmt/binding-darwin-arm64": "0.51.0", "@oxfmt/binding-darwin-x64": "0.51.0", "@oxfmt/binding-freebsd-x64": "0.51.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.51.0", "@oxfmt/binding-linux-arm-musleabihf": "0.51.0", "@oxfmt/binding-linux-arm64-gnu": "0.51.0", "@oxfmt/binding-linux-arm64-musl": "0.51.0", "@oxfmt/binding-linux-ppc64-gnu": "0.51.0", "@oxfmt/binding-linux-riscv64-gnu": "0.51.0", "@oxfmt/binding-linux-riscv64-musl": "0.51.0", "@oxfmt/binding-linux-s390x-gnu": "0.51.0", "@oxfmt/binding-linux-x64-gnu": "0.51.0", "@oxfmt/binding-linux-x64-musl": "0.51.0", "@oxfmt/binding-openharmony-arm64": "0.51.0", "@oxfmt/binding-win32-arm64-msvc": "0.51.0", "@oxfmt/binding-win32-ia32-msvc": "0.51.0", "@oxfmt/binding-win32-x64-msvc": "0.51.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "optionalPeers": ["svelte"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-l/AoAnaEOV7Q5/Z9kHOMDehVJnCgYN7wRoooWCTUMBMi16BJhLZqd9cmCnwcVFfVlzkt53zK2KLPFNp8vSsoDg=="],
|
||||
"oxfmt": ["oxfmt@0.53.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.53.0", "@oxfmt/binding-android-arm64": "0.53.0", "@oxfmt/binding-darwin-arm64": "0.53.0", "@oxfmt/binding-darwin-x64": "0.53.0", "@oxfmt/binding-freebsd-x64": "0.53.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.53.0", "@oxfmt/binding-linux-arm-musleabihf": "0.53.0", "@oxfmt/binding-linux-arm64-gnu": "0.53.0", "@oxfmt/binding-linux-arm64-musl": "0.53.0", "@oxfmt/binding-linux-ppc64-gnu": "0.53.0", "@oxfmt/binding-linux-riscv64-gnu": "0.53.0", "@oxfmt/binding-linux-riscv64-musl": "0.53.0", "@oxfmt/binding-linux-s390x-gnu": "0.53.0", "@oxfmt/binding-linux-x64-gnu": "0.53.0", "@oxfmt/binding-linux-x64-musl": "0.53.0", "@oxfmt/binding-openharmony-arm64": "0.53.0", "@oxfmt/binding-win32-arm64-msvc": "0.53.0", "@oxfmt/binding-win32-ia32-msvc": "0.53.0", "@oxfmt/binding-win32-x64-msvc": "0.53.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-9cB5glS3Ip6NMuZ+6NYTao9FCWkDhRtPYCtR3QBu/NxHoFbgzzTvi41N4jxz/GqGfuLKspui1qb/LlSu2IbMcw=="],
|
||||
|
||||
"oxlint": ["oxlint@1.66.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.66.0", "@oxlint/binding-android-arm64": "1.66.0", "@oxlint/binding-darwin-arm64": "1.66.0", "@oxlint/binding-darwin-x64": "1.66.0", "@oxlint/binding-freebsd-x64": "1.66.0", "@oxlint/binding-linux-arm-gnueabihf": "1.66.0", "@oxlint/binding-linux-arm-musleabihf": "1.66.0", "@oxlint/binding-linux-arm64-gnu": "1.66.0", "@oxlint/binding-linux-arm64-musl": "1.66.0", "@oxlint/binding-linux-ppc64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-gnu": "1.66.0", "@oxlint/binding-linux-riscv64-musl": "1.66.0", "@oxlint/binding-linux-s390x-gnu": "1.66.0", "@oxlint/binding-linux-x64-gnu": "1.66.0", "@oxlint/binding-linux-x64-musl": "1.66.0", "@oxlint/binding-openharmony-arm64": "1.66.0", "@oxlint/binding-win32-arm64-msvc": "1.66.0", "@oxlint/binding-win32-ia32-msvc": "1.66.0", "@oxlint/binding-win32-x64-msvc": "1.66.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw=="],
|
||||
"oxlint": ["oxlint@1.68.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.68.0", "@oxlint/binding-android-arm64": "1.68.0", "@oxlint/binding-darwin-arm64": "1.68.0", "@oxlint/binding-darwin-x64": "1.68.0", "@oxlint/binding-freebsd-x64": "1.68.0", "@oxlint/binding-linux-arm-gnueabihf": "1.68.0", "@oxlint/binding-linux-arm-musleabihf": "1.68.0", "@oxlint/binding-linux-arm64-gnu": "1.68.0", "@oxlint/binding-linux-arm64-musl": "1.68.0", "@oxlint/binding-linux-ppc64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-gnu": "1.68.0", "@oxlint/binding-linux-riscv64-musl": "1.68.0", "@oxlint/binding-linux-s390x-gnu": "1.68.0", "@oxlint/binding-linux-x64-gnu": "1.68.0", "@oxlint/binding-linux-x64-musl": "1.68.0", "@oxlint/binding-openharmony-arm64": "1.68.0", "@oxlint/binding-win32-arm64-msvc": "1.68.0", "@oxlint/binding-win32-ia32-msvc": "1.68.0", "@oxlint/binding-win32-x64-msvc": "1.68.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="],
|
||||
|
||||
@@ -1186,6 +1165,8 @@
|
||||
|
||||
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
|
||||
|
||||
"postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="],
|
||||
|
||||
"postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="],
|
||||
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
@@ -1196,13 +1177,13 @@
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
@@ -1214,7 +1195,7 @@
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
@@ -1236,9 +1217,11 @@
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resend": ["resend@6.12.4", "", { "dependencies": { "postal-mime": "2.7.4", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="],
|
||||
"rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1246,7 +1229,7 @@
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
"semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
|
||||
|
||||
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
|
||||
|
||||
@@ -1254,9 +1237,9 @@
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
|
||||
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
@@ -1272,10 +1255,12 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
|
||||
"srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
@@ -1304,19 +1289,17 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
"tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
"tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
@@ -1334,7 +1317,7 @@
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
"undici": ["undici@7.27.1", "", {}, "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
@@ -1370,11 +1353,11 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="],
|
||||
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="],
|
||||
"vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
@@ -1486,31 +1469,21 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"jsdom/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"make-dir/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="],
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.134.0", "", {}, "sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"vite/rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="],
|
||||
"vite/rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1532,40 +1505,38 @@
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tanstack/router-plugin/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="],
|
||||
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="],
|
||||
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="],
|
||||
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="],
|
||||
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="],
|
||||
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="],
|
||||
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="],
|
||||
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="],
|
||||
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
|
||||
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="],
|
||||
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+24
@@ -17,7 +17,9 @@ import type * as crons from "../crons.js";
|
||||
import type * as depRegistryScan from "../depRegistryScan.js";
|
||||
import type * as devSeed from "../devSeed.js";
|
||||
import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloadMetrics from "../downloadMetrics.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as emailsNode from "../emailsNode.js";
|
||||
import type * as functions from "../functions.js";
|
||||
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
@@ -26,6 +28,8 @@ import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubRestore from "../githubRestore.js";
|
||||
import type * as githubRestoreMutations from "../githubRestoreMutations.js";
|
||||
import type * as githubSkillSources from "../githubSkillSources.js";
|
||||
import type * as githubSkillSync from "../githubSkillSync.js";
|
||||
import type * as githubSoulBackups from "../githubSoulBackups.js";
|
||||
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
@@ -55,6 +59,8 @@ import type * as lib_commentScamPrompt from "../lib/commentScamPrompt.js";
|
||||
import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
|
||||
import type * as lib_devAuth from "../lib/devAuth.js";
|
||||
import type * as lib_devSeed from "../lib/devSeed.js";
|
||||
import type * as lib_emails from "../lib/emails.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
@@ -65,16 +71,19 @@ import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubRestoreHelpers from "../lib/githubRestoreHelpers.js";
|
||||
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_globalStats from "../lib/globalStats.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_httpUtils from "../lib/httpUtils.js";
|
||||
import type * as lib_installResolver from "../lib/installResolver.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_manualOverrides from "../lib/manualOverrides.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
import type * as lib_moderationEngine from "../lib/moderationEngine.js";
|
||||
import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js";
|
||||
import type * as lib_observabilityEvents from "../lib/observabilityEvents.js";
|
||||
import type * as lib_officialPublishers from "../lib/officialPublishers.js";
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
@@ -85,6 +94,7 @@ import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js";
|
||||
import type * as lib_publisherCatalogDisplay from "../lib/publisherCatalogDisplay.js";
|
||||
import type * as lib_publisherStats from "../lib/publisherStats.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
import type * as lib_reporting from "../lib/reporting.js";
|
||||
@@ -115,9 +125,11 @@ 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 managementDevSeed from "../managementDevSeed.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publisherAbuse from "../publisherAbuse.js";
|
||||
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
@@ -159,7 +171,9 @@ declare const fullApi: ApiFromModules<{
|
||||
depRegistryScan: typeof depRegistryScan;
|
||||
devSeed: typeof devSeed;
|
||||
devSeedExtra: typeof devSeedExtra;
|
||||
downloadMetrics: typeof downloadMetrics;
|
||||
downloads: typeof downloads;
|
||||
emailsNode: typeof emailsNode;
|
||||
functions: typeof functions;
|
||||
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
|
||||
githubBackups: typeof githubBackups;
|
||||
@@ -168,6 +182,8 @@ declare const fullApi: ApiFromModules<{
|
||||
githubImport: typeof githubImport;
|
||||
githubRestore: typeof githubRestore;
|
||||
githubRestoreMutations: typeof githubRestoreMutations;
|
||||
githubSkillSources: typeof githubSkillSources;
|
||||
githubSkillSync: typeof githubSkillSync;
|
||||
githubSoulBackups: typeof githubSoulBackups;
|
||||
githubSoulBackupsNode: typeof githubSoulBackupsNode;
|
||||
http: typeof http;
|
||||
@@ -197,6 +213,8 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/contentTypes": typeof lib_contentTypes;
|
||||
"lib/depRegistryScan": typeof lib_depRegistryScan;
|
||||
"lib/devAuth": typeof lib_devAuth;
|
||||
"lib/devSeed": typeof lib_devSeed;
|
||||
"lib/emails": typeof lib_emails;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
@@ -207,16 +225,19 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
|
||||
"lib/githubSkillSync": typeof lib_githubSkillSync;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/globalStats": typeof lib_globalStats;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/httpUtils": typeof lib_httpUtils;
|
||||
"lib/installResolver": typeof lib_installResolver;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/manualOverrides": typeof lib_manualOverrides;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
"lib/moderationEngine": typeof lib_moderationEngine;
|
||||
"lib/moderationReasonCodes": typeof lib_moderationReasonCodes;
|
||||
"lib/observabilityEvents": typeof lib_observabilityEvents;
|
||||
"lib/officialPublishers": typeof lib_officialPublishers;
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
@@ -227,6 +248,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publisherAbuseScoring": typeof lib_publisherAbuseScoring;
|
||||
"lib/publisherCatalogDisplay": typeof lib_publisherCatalogDisplay;
|
||||
"lib/publisherStats": typeof lib_publisherStats;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
"lib/reporting": typeof lib_reporting;
|
||||
@@ -257,9 +279,11 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
managementDevSeed: typeof managementDevSeed;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publisherAbuse: typeof publisherAbuse;
|
||||
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
|
||||
+2
-2
@@ -128,7 +128,7 @@ describe("handleDeletedUserSignIn", () => {
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes the moderator ban reason in the sign-in error", async () => {
|
||||
it("does not leak the moderator ban reason in the sign-in error", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123, banReason: "Chargeback fraud" },
|
||||
banRecords: [{ action: "user.ban" }],
|
||||
@@ -136,6 +136,6 @@ describe("handleDeletedUserSignIn", () => {
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(`${BANNED_REAUTH_MESSAGE} Reason: Chargeback fraud`);
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-10
@@ -9,19 +9,15 @@ import { isLocalDevAuthEnabled } from "./lib/devAuth";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, please contact security@openclaw.ai and we will review it.";
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
|
||||
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
|
||||
"This account has been permanently deleted and cannot be restored.";
|
||||
|
||||
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember", "abusePublisher"]);
|
||||
|
||||
function getBannedReauthMessage(reason: string | undefined) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason || normalizedReason.toLowerCase() === "malware auto-ban") {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
return `${BANNED_REAUTH_MESSAGE} Reason: ${normalizedReason}`;
|
||||
function getBannedReauthMessage(_reason: string | undefined) {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
|
||||
export async function handleDeletedUserSignIn(
|
||||
@@ -90,11 +86,16 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
ConvexCredentials({
|
||||
id: "dev-persona",
|
||||
authorize: async (credentials, ctx) => {
|
||||
if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled");
|
||||
const devAuthSecret =
|
||||
typeof credentials.devAuthSecret === "string" ? credentials.devAuthSecret : undefined;
|
||||
if (!isLocalDevAuthEnabled(process.env, devAuthSecret)) {
|
||||
throw new Error("Dev auth is disabled");
|
||||
}
|
||||
const persona = typeof credentials.persona === "string" ? credentials.persona : "";
|
||||
if (!DEV_PERSONAS.has(persona)) throw new Error("Unknown dev persona");
|
||||
const userId: Id<"users"> = await ctx.runMutation(internal.users.upsertDevPersonaInternal, {
|
||||
persona: persona as "owner" | "user" | "admin",
|
||||
persona: persona as "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher",
|
||||
devAuthSecret,
|
||||
});
|
||||
return { userId };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const interval = vi.fn();
|
||||
const githubSkillSyncRef = Symbol("github-skill-source-sync");
|
||||
return { interval, githubSkillSyncRef };
|
||||
});
|
||||
|
||||
vi.mock("convex/server", () => ({
|
||||
cronJobs: () => ({
|
||||
interval: mocks.interval,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
githubBackupsNode: { syncGitHubBackupsInternal: Symbol("github-backup-sync") },
|
||||
githubSkillSync: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef },
|
||||
leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") },
|
||||
statsMaintenance: {
|
||||
runSkillStatBackfillInternal: Symbol("skill-stats-backfill"),
|
||||
updateGlobalStatsAction: Symbol("global-stats-update"),
|
||||
},
|
||||
skillStatEvents: { processSkillStatEventsAction: Symbol("skill-stat-events") },
|
||||
packages: {
|
||||
processPackageStatEventsInternal: Symbol("package-stat-events"),
|
||||
backfillPackageReleaseScansInternal: Symbol("package-scan-backfill"),
|
||||
},
|
||||
publisherAbuse: {
|
||||
runPublisherAbuseScoreRunInternal: Symbol("publisher-abuse-score-refresh"),
|
||||
},
|
||||
vt: {
|
||||
pollPendingScans: Symbol("vt-pending-scans"),
|
||||
backfillActiveSkillsVTCache: Symbol("vt-cache-backfill"),
|
||||
},
|
||||
securityScan: {
|
||||
pruneExpiredSkillScanRequestsInternal: Symbol("skill-scan-request-prune"),
|
||||
},
|
||||
downloads: { pruneDownloadDedupesInternal: Symbol("download-dedupe-prune") },
|
||||
downloadMetrics: {
|
||||
pruneDownloadMetricDedupesInternal: Symbol("download-metric-dedupe-prune"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("crons", () => {
|
||||
it("runs GitHub skill source sync every 15 minutes", async () => {
|
||||
await import("./crons");
|
||||
|
||||
expect(mocks.interval).toHaveBeenCalledWith(
|
||||
"github-skill-source-sync",
|
||||
{ minutes: 15 },
|
||||
mocks.githubSkillSyncRef,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,13 @@ crons.interval(
|
||||
{ batchSize: 50, maxBatches: 5 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"github-skill-source-sync",
|
||||
{ minutes: 15 },
|
||||
internal.githubSkillSync.syncGitHubSkillSourcesInternal,
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"trending-leaderboard",
|
||||
{ minutes: 60 },
|
||||
@@ -64,6 +71,20 @@ crons.interval(
|
||||
{ batchSize: 250, maxPages: 5, trigger: "cron" },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"publisher-temporal-abuse-scan",
|
||||
{ hours: 24 },
|
||||
internal.publisherAbuse.runTemporalPublisherAbuseScanInternal,
|
||||
{
|
||||
mode: "current",
|
||||
dryRun: false,
|
||||
candidateLimit: 1000,
|
||||
batchSize: 50,
|
||||
maxPages: 20,
|
||||
trigger: "cron",
|
||||
},
|
||||
);
|
||||
|
||||
crons.interval("vt-pending-scans", { minutes: 5 }, internal.vt.pollPendingScans, {
|
||||
batchSize: 100,
|
||||
});
|
||||
@@ -93,4 +114,11 @@ crons.interval(
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"download-metric-dedupe-prune",
|
||||
{ hours: 24 },
|
||||
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
|
||||
{},
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
currentUserSeedPackageName,
|
||||
currentUserSeedSkillSlug,
|
||||
seedFeaturedPluginPackagesMutation,
|
||||
seedGitHubBackedSkillSourceMutation,
|
||||
seedLocalFixtures,
|
||||
seedLocalModerationFixturesHandler,
|
||||
seedPublicCorpusBatchMutation,
|
||||
seedSkillMutation,
|
||||
} from "./devSeed";
|
||||
|
||||
@@ -18,6 +21,15 @@ const seedSkillMutationHandler = (
|
||||
const seedFeaturedPluginPackagesHandler = (
|
||||
seedFeaturedPluginPackagesMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedGitHubBackedSkillSourceHandler = (
|
||||
seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedLocalFixturesHandler = (
|
||||
seedLocalFixtures as unknown as WrappedHandler<{ reset?: boolean }>
|
||||
)._handler;
|
||||
const seedPublicCorpusBatchHandler = (
|
||||
seedPublicCorpusBatchMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -146,6 +158,33 @@ function seedSkillArgs(storageId: string) {
|
||||
}
|
||||
|
||||
describe("devSeed local fixtures", () => {
|
||||
it("does not preconfigure GitHub-backed source fixtures in the local seed action", async () => {
|
||||
const mutationCalls: Array<{ args: Record<string, unknown> }> = [];
|
||||
let storageCounter = 0;
|
||||
const ctx = {
|
||||
storage: {
|
||||
store: async () => `storage:${++storageCounter}`,
|
||||
},
|
||||
runMutation: async (_ref: unknown, args: Record<string, unknown>) => {
|
||||
mutationCalls.push({ args });
|
||||
return { ok: true, seeded: ["local-moderation-fixtures"], skipped: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await seedLocalFixturesHandler(ctx as never, { reset: true });
|
||||
|
||||
expect(mutationCalls).toHaveLength(1);
|
||||
expect(mutationCalls[0]?.args).toMatchObject({
|
||||
reset: true,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
results: [expect.objectContaining({ slug: "local-moderation-fixtures" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds core skill fixtures for an explicit local user without creating @local", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
@@ -190,6 +229,210 @@ describe("devSeed local fixtures", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not copy publisher ownership onto public corpus skill embeddings", async () => {
|
||||
const { db, tables } = createDb();
|
||||
|
||||
await seedPublicCorpusBatchHandler(
|
||||
createMutationCtx(db) as never,
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
kind: "skill",
|
||||
slug: "corpus-demo",
|
||||
displayName: "Corpus Demo",
|
||||
version: "0.1.0",
|
||||
skillMd: "---\ndescription: Corpus demo\n---\n# Corpus demo",
|
||||
storageId: "storage:corpus-demo",
|
||||
embedding: [0, 1, 2],
|
||||
dummyOwner: {
|
||||
handle: "corpus-owner",
|
||||
displayName: "Corpus Owner",
|
||||
image: "https://example.invalid/avatar.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(tables.skills?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: "corpus-demo",
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
}),
|
||||
);
|
||||
expect(tables.skillEmbeddings?.[0]).not.toHaveProperty("ownerPublisherId");
|
||||
});
|
||||
|
||||
it("seeds a GitHub-backed source and skills without creating mirrored versions", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
handle: "nvidia-dev",
|
||||
displayName: "NVIDIA Dev",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"users">;
|
||||
|
||||
const result = await seedGitHubBackedSkillSourceHandler(
|
||||
createMutationCtx(db) as never,
|
||||
{
|
||||
ownerUserId: userId,
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: "manifest-sha256",
|
||||
displayManifestCommit: "0".repeat(40),
|
||||
displayManifestFetchedAt: 123,
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 456,
|
||||
},
|
||||
{
|
||||
slug: "nemoclaw-user-configure-security",
|
||||
displayName: "NeMoClaw User Configure Security",
|
||||
summary: "Configure NeMoClaw user security.",
|
||||
githubPath: "skills/nemoclaw-user-configure-security",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-nemoclaw",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 789,
|
||||
githubRemovedAt: 900,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
seeded: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
skipped: [],
|
||||
});
|
||||
expect(tables.githubSkillSources).toHaveLength(1);
|
||||
expect(tables.githubSkillSources?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
defaultBranch: "main",
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: "manifest-sha256",
|
||||
displayManifestCommit: "0".repeat(40),
|
||||
displayManifestFetchedAt: 123,
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tables.skills).toHaveLength(2);
|
||||
expect(tables.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: tables.githubSkillSources?.[0]?._id,
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 456,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
stats: expect.objectContaining({ versions: 0 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "nemoclaw-user-configure-security",
|
||||
installKind: "github",
|
||||
githubSourceId: tables.githubSkillSources?.[0]?._id,
|
||||
githubPath: "skills/nemoclaw-user-configure-security",
|
||||
githubCurrentContentHash: "hash-nemoclaw",
|
||||
githubRemovedAt: 900,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(tables.skillVersions ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps unscanned GitHub-backed skills hidden from public listings", async () => {
|
||||
const { db, tables } = createDb();
|
||||
|
||||
await seedGitHubBackedSkillSourceHandler(
|
||||
createMutationCtx(db) as never,
|
||||
{
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
skills: [
|
||||
{
|
||||
slug: "pending-github-skill",
|
||||
displayName: "Pending GitHub Skill",
|
||||
githubPath: "skills/pending-github-skill",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-pending",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
{
|
||||
slug: "failed-scan-github-skill",
|
||||
displayName: "Failed Scan GitHub Skill",
|
||||
githubPath: "skills/failed-scan-github-skill",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-failed-scan",
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(tables.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "pending-github-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "failed-scan-github-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.failed",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds moderation and plugin fixtures for an explicit local user with scoped identifiers", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
|
||||
+985
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__test,
|
||||
pruneDownloadMetricDedupesInternal,
|
||||
recordDownloadMetricInternal,
|
||||
} from "./downloadMetrics";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const recordDownloadMetricHandler = (
|
||||
recordDownloadMetricInternal as unknown as WrappedHandler<
|
||||
{
|
||||
target: { kind: "skill"; id: string } | { kind: "package"; id: string };
|
||||
identityKind: "user" | "ip";
|
||||
identityHash: string;
|
||||
dayStart: number;
|
||||
occurredAt?: number;
|
||||
},
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const pruneDownloadMetricDedupesHandler = (
|
||||
pruneDownloadMetricDedupesInternal as unknown as WrappedHandler<
|
||||
Record<string, never>,
|
||||
{ deleted: number; hasMore: boolean }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeQueryBuilder() {
|
||||
const builder = {
|
||||
eq: vi.fn(() => builder),
|
||||
lt: vi.fn(() => builder),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
type QueryBuilder = ReturnType<typeof makeQueryBuilder>;
|
||||
|
||||
function makeDb(
|
||||
existingByTable: Record<string, unknown> = {},
|
||||
rowsByTable: Record<string, Array<{ _id: string }>> = {},
|
||||
) {
|
||||
const indexCalls: Array<{ table: string; indexName: string; builder: QueryBuilder }> = [];
|
||||
const insert = vi.fn();
|
||||
const unique = vi.fn(async function uniqueForTable(this: { table: string }) {
|
||||
return existingByTable[this.table] ?? null;
|
||||
});
|
||||
const take = vi.fn(async function takeForTable(this: { table: string }, limit: number) {
|
||||
return (rowsByTable[this.table] ?? []).slice(0, limit);
|
||||
});
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const builder = makeQueryBuilder();
|
||||
buildQuery(builder);
|
||||
indexCalls.push({ table, indexName, builder });
|
||||
return {
|
||||
unique: unique.bind({ table }),
|
||||
take: take.bind({ table }),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const delete_ = vi.fn();
|
||||
return {
|
||||
db: {
|
||||
query,
|
||||
get: vi.fn(),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
delete: delete_,
|
||||
normalizeId: vi.fn(),
|
||||
system: {
|
||||
get: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert,
|
||||
delete_,
|
||||
take,
|
||||
indexCalls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("download metric helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses a day bucket for download dedupe", () => {
|
||||
expect(__test.getDayStart(86_400_000 - 1)).toBe(0);
|
||||
expect(__test.getDayStart(86_400_000)).toBe(86_400_000);
|
||||
});
|
||||
|
||||
it("prefers user identity and falls back to IP identity", () => {
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.10" },
|
||||
});
|
||||
|
||||
expect(__test.getDownloadIdentity(request, "users:one")).toEqual({
|
||||
identityKind: "user",
|
||||
identityValue: "users:one",
|
||||
});
|
||||
expect(__test.getDownloadIdentity(request, null)).toEqual({
|
||||
identityKind: "ip",
|
||||
identityValue: "203.0.113.10",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a metering identity when user and IP are missing", () => {
|
||||
expect(__test.getDownloadIdentity(new Request("https://example.com"), null)).toBeNull();
|
||||
});
|
||||
|
||||
it("records one authenticated skill download and emits the existing skill stat event", async () => {
|
||||
const { db, insert, indexCalls } = makeDb();
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "skill", id: "skills:one" },
|
||||
identityKind: "user",
|
||||
identityHash: "hash-user",
|
||||
dayStart: 86_400_000,
|
||||
occurredAt: 86_500_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(indexCalls[0]?.table).toBe("downloadMetricDedupes");
|
||||
expect(indexCalls[0]?.indexName).toBe("by_target_identity_day");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("targetKind", "skill");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("targetId", "skills:one");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("identityKind", "user");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("identityHash", "hash-user");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"downloadMetricDedupes",
|
||||
expect.objectContaining({
|
||||
targetKind: "skill",
|
||||
targetId: "skills:one",
|
||||
identityKind: "user",
|
||||
identityHash: "hash-user",
|
||||
dayStart: 86_400_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillStatEvents",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:one",
|
||||
kind: "download",
|
||||
occurredAt: 86_500_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("packageStatEvents", expect.anything());
|
||||
});
|
||||
|
||||
it("records one anonymous package download and emits the existing package stat event", async () => {
|
||||
const { db, insert } = makeDb();
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "package", id: "packages:one" },
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
occurredAt: 86_500_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"downloadMetricDedupes",
|
||||
expect.objectContaining({
|
||||
targetKind: "package",
|
||||
targetId: "packages:one",
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageStatEvents",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:one",
|
||||
kind: "download",
|
||||
occurredAt: 86_500_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
|
||||
});
|
||||
|
||||
it("ignores duplicate identities in the same target/day bucket", async () => {
|
||||
const { db, insert } = makeDb({
|
||||
downloadMetricDedupes: { _id: "downloadMetricDedupes:existing" },
|
||||
});
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "skill", id: "skills:one" },
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes stale dedupe rows by day bucket", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const { db, delete_, take, indexCalls } = makeDb(
|
||||
{},
|
||||
{
|
||||
downloadMetricDedupes: [
|
||||
{ _id: "downloadMetricDedupes:one" },
|
||||
{ _id: "downloadMetricDedupes:two" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await pruneDownloadMetricDedupesHandler({ db }, {});
|
||||
|
||||
expect(result).toEqual({ deleted: 2, hasMore: false });
|
||||
expect(indexCalls[0]?.table).toBe("downloadMetricDedupes");
|
||||
expect(indexCalls[0]?.indexName).toBe("by_day");
|
||||
expect(take).toHaveBeenCalledWith(200);
|
||||
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:one");
|
||||
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:two");
|
||||
});
|
||||
|
||||
it("reschedules stale dedupe pruning when one bounded batch fills", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const rows = Array.from({ length: 200 }, (_, index) => ({
|
||||
_id: `downloadMetricDedupes:${index}`,
|
||||
}));
|
||||
const { db, delete_ } = makeDb({}, { downloadMetricDedupes: rows });
|
||||
const runAfter = vi.fn();
|
||||
|
||||
const result = await pruneDownloadMetricDedupesHandler({ db, scheduler: { runAfter } }, {});
|
||||
|
||||
expect(result).toEqual({ deleted: 200, hasMore: true });
|
||||
expect(delete_).toHaveBeenCalledTimes(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./functions";
|
||||
import { getClientIp } from "./lib/httpRateLimit";
|
||||
import { hashToken } from "./lib/tokens";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const DEDUPE_RETENTION_MS = 14 * DAY_MS;
|
||||
const PRUNE_BATCH_SIZE = 200;
|
||||
|
||||
const identityKindValidator = v.union(v.literal("user"), v.literal("ip"));
|
||||
|
||||
const targetValidator = v.union(
|
||||
v.object({ kind: v.literal("skill"), id: v.id("skills") }),
|
||||
v.object({ kind: v.literal("package"), id: v.id("packages") }),
|
||||
);
|
||||
|
||||
type DownloadIdentityKind = "user" | "ip";
|
||||
|
||||
type DownloadIdentity = {
|
||||
identityKind: DownloadIdentityKind;
|
||||
identityValue: string;
|
||||
};
|
||||
|
||||
export function getDownloadIdentity(
|
||||
request: Request,
|
||||
userId: string | null,
|
||||
): DownloadIdentity | null {
|
||||
if (userId) return { identityKind: "user", identityValue: userId };
|
||||
const ip = getClientIp(request);
|
||||
if (!ip) return null;
|
||||
return { identityKind: "ip", identityValue: ip };
|
||||
}
|
||||
|
||||
export async function buildDownloadMetricArgs(params: {
|
||||
target: { kind: "skill"; id: Id<"skills"> } | { kind: "package"; id: Id<"packages"> };
|
||||
identity: DownloadIdentity;
|
||||
now: number;
|
||||
}) {
|
||||
return {
|
||||
target: params.target,
|
||||
identityKind: params.identity.identityKind,
|
||||
identityHash: await hashToken(
|
||||
`${params.identity.identityKind}:${params.identity.identityValue}`,
|
||||
),
|
||||
dayStart: getDayStart(params.now),
|
||||
occurredAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export const recordDownloadMetricInternal = internalMutation({
|
||||
args: {
|
||||
target: targetValidator,
|
||||
identityKind: identityKindValidator,
|
||||
identityHash: v.string(),
|
||||
dayStart: v.number(),
|
||||
occurredAt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const targetId = args.target.id;
|
||||
const existing = await ctx.db
|
||||
.query("downloadMetricDedupes")
|
||||
.withIndex("by_target_identity_day", (q) =>
|
||||
q
|
||||
.eq("targetKind", args.target.kind)
|
||||
.eq("targetId", targetId)
|
||||
.eq("identityKind", args.identityKind)
|
||||
.eq("identityHash", args.identityHash)
|
||||
.eq("dayStart", args.dayStart),
|
||||
)
|
||||
.unique();
|
||||
if (existing) return;
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("downloadMetricDedupes", {
|
||||
targetKind: args.target.kind,
|
||||
targetId,
|
||||
identityKind: args.identityKind,
|
||||
identityHash: args.identityHash,
|
||||
dayStart: args.dayStart,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (args.target.kind === "skill") {
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: args.target.id,
|
||||
kind: "download",
|
||||
occurredAt: args.occurredAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.db.insert("packageStatEvents", {
|
||||
packageId: args.target.id,
|
||||
kind: "download",
|
||||
occurredAt: args.occurredAt ?? now,
|
||||
processedAt: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const pruneDownloadMetricDedupesInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const cutoffDayStart = getDayStart(Date.now() - DEDUPE_RETENTION_MS);
|
||||
const stale = await ctx.db
|
||||
.query("downloadMetricDedupes")
|
||||
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
|
||||
.take(PRUNE_BATCH_SIZE);
|
||||
|
||||
for (const entry of stale) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
|
||||
const hasMore = stale.length === PRUNE_BATCH_SIZE;
|
||||
if (hasMore) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return { deleted: stale.length, hasMore };
|
||||
},
|
||||
});
|
||||
|
||||
function getDayStart(timestamp: number) {
|
||||
return Math.floor(timestamp / DAY_MS) * DAY_MS;
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
getDayStart,
|
||||
getDownloadIdentity,
|
||||
};
|
||||
+150
-14
@@ -21,6 +21,19 @@ const okRate = () => ({
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
function stubZipResponse() {
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
|
||||
constructor(_body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
|
||||
}
|
||||
|
||||
describe("downloads helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -63,16 +76,7 @@ describe("downloads helpers", () => {
|
||||
});
|
||||
|
||||
it("schedules zip download stats outside the response path", async () => {
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
|
||||
constructor(_body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
@@ -127,9 +131,10 @@ describe("downloads helpers", () => {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
return (
|
||||
value.skillId === "skills:1" &&
|
||||
typeof value.target === "object" &&
|
||||
typeof value.identityHash === "string" &&
|
||||
typeof value.hourStart === "number"
|
||||
value.identityKind === "ip" &&
|
||||
typeof value.dayStart === "number"
|
||||
);
|
||||
});
|
||||
expect(recordCalls).toHaveLength(1);
|
||||
@@ -137,9 +142,11 @@ describe("downloads helpers", () => {
|
||||
expect(recordCalls[0]?.[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(recordCalls[0]?.[0]).toBeLessThan(60_000);
|
||||
expect(recordCalls[0]?.[2]).toEqual({
|
||||
skillId: "skills:1",
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.any(String),
|
||||
hourStart: expect.any(Number),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,4 +209,133 @@ describe("downloads helpers", () => {
|
||||
expect(await response.text()).toBe("Version not found");
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses API token user identity for zip download stats when present", async () => {
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("tokenHash" in args) {
|
||||
return { _id: "apiTokens:1", revokedAt: undefined };
|
||||
}
|
||||
if ("tokenId" in args) {
|
||||
return { _id: "users:token", deletedAt: undefined, deactivatedAt: undefined };
|
||||
}
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return { tokenTouched: "tokenId" in args };
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo", {
|
||||
headers: {
|
||||
authorization: "Bearer clh_test",
|
||||
"cf-connecting-ip": "1.2.3.4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "user",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns zip downloads when download metering is scheduled", async () => {
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return { mutationRecorded: true };
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo", {
|
||||
headers: { "cf-connecting-ip": "1.2.3.4" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-9
@@ -1,12 +1,14 @@
|
||||
import { v } from "convex/values";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { buildDownloadMetricArgs, getDownloadIdentity } from "./downloadMetrics";
|
||||
import { httpAction, internalMutation } from "./functions";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "./lib/access";
|
||||
import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
|
||||
import { applyRateLimit, getClientIp } from "./lib/httpRateLimit";
|
||||
import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "./lib/skillFileAccess";
|
||||
import { buildDeterministicZip } from "./lib/skillZip";
|
||||
import { hashToken } from "./lib/tokens";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
@@ -98,17 +100,17 @@ export async function downloadZipHandler(
|
||||
const zipBlob = new Blob([zipArray], { type: "application/zip" });
|
||||
|
||||
try {
|
||||
const userId = await getOptionalApiTokenUserId(ctx, request);
|
||||
const identity = getDownloadIdentityValue(request, userId ? String(userId) : null);
|
||||
const userId = await getOptionalDownloadUserId(ctx, request);
|
||||
const identity = getDownloadIdentity(request, userId ? String(userId) : null);
|
||||
if (identity) {
|
||||
await ctx.scheduler.runAfter(
|
||||
Math.floor(Math.random() * DOWNLOAD_STAT_JITTER_MS),
|
||||
internal.downloads.recordDownloadInternal,
|
||||
{
|
||||
skillId: skill._id,
|
||||
identityHash: await hashToken(identity),
|
||||
hourStart: getHourStart(Date.now()),
|
||||
},
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "skill", id: skill._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -196,6 +198,15 @@ export function getDownloadIdentityValue(request: Request, userId: string | null
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
async function getOptionalDownloadUserId(
|
||||
ctx: Parameters<Parameters<typeof httpAction>[0]>[0],
|
||||
request: Request,
|
||||
): Promise<Id<"users"> | null> {
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
|
||||
if (apiTokenUserId) return apiTokenUserId;
|
||||
return (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
getHourStart,
|
||||
getDownloadIdentityValue,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use node";
|
||||
|
||||
import { mkdir, appendFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { v } from "convex/values";
|
||||
import { Resend } from "resend";
|
||||
import { internalAction } from "./functions";
|
||||
import {
|
||||
buildBanNotificationEmail,
|
||||
buildMaliciousArtifactEmail,
|
||||
buildRestoredAccountEmail,
|
||||
type NotificationArtifact,
|
||||
} from "./lib/emails";
|
||||
|
||||
const DEFAULT_FROM = "ClawHub Security <noreply@notifications.openclaw.ai>";
|
||||
const DEFAULT_REPLY_TO = "security@notifications.openclaw.ai";
|
||||
|
||||
const notificationArtifactValidator = v.object({
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
name: v.string(),
|
||||
});
|
||||
|
||||
type SendEmailArgs = {
|
||||
idempotencyKey: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
function getEmailConfig() {
|
||||
return {
|
||||
apiKey: process.env.RESEND_API_KEY,
|
||||
from: process.env.CLAWHUB_SECURITY_EMAIL_FROM || DEFAULT_FROM,
|
||||
replyTo: process.env.CLAWHUB_SECURITY_EMAIL || DEFAULT_REPLY_TO,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendTransactionalEmail(args: SendEmailArgs) {
|
||||
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE?.trim();
|
||||
if (captureFile) {
|
||||
await mkdir(dirname(captureFile), { recursive: true });
|
||||
await appendFile(
|
||||
captureFile,
|
||||
`${JSON.stringify({ ...args, capturedAt: Date.now() })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { ok: true as const, id: "local-capture" };
|
||||
}
|
||||
|
||||
const config = getEmailConfig();
|
||||
if (!config.apiKey) {
|
||||
console.warn(`[emails] RESEND_API_KEY is not configured; skipped ${args.idempotencyKey}`);
|
||||
return { ok: false as const, reason: "missing_api_key" as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(config.apiKey);
|
||||
const result = await resend.emails.send(
|
||||
{
|
||||
from: config.from,
|
||||
to: args.to,
|
||||
replyTo: config.replyTo,
|
||||
subject: args.subject,
|
||||
text: args.text,
|
||||
html: args.html,
|
||||
},
|
||||
{ idempotencyKey: args.idempotencyKey },
|
||||
);
|
||||
if (result.error) {
|
||||
console.error("[emails] Resend error", result.error);
|
||||
return { ok: false as const, reason: "resend_error" as const };
|
||||
}
|
||||
return { ok: true as const, id: result.data?.id ?? null };
|
||||
} catch (error) {
|
||||
console.error("[emails] Send failed", error);
|
||||
return { ok: false as const, reason: "send_error" as const };
|
||||
}
|
||||
}
|
||||
|
||||
export const sendBanNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
bannedAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
source: v.union(v.literal("manual"), v.literal("autoban")),
|
||||
reason: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
artifact: v.optional(notificationArtifactValidator),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: args.handle,
|
||||
source: args.source,
|
||||
reason: args.reason,
|
||||
trigger: args.trigger,
|
||||
artifact: args.artifact as NotificationArtifact | undefined,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `ban:${args.userId}:${args.bannedAt}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const sendRestoredAccountNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
restoredAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
restoredListings: v.optional(v.array(notificationArtifactValidator)),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildRestoredAccountEmail({
|
||||
handle: args.handle,
|
||||
restoredListings: args.restoredListings as NotificationArtifact[] | undefined,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `account-restored:${args.userId}:${args.restoredAt}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const sendMaliciousArtifactNotificationInternal = internalAction({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
findingAt: v.number(),
|
||||
to: v.string(),
|
||||
handle: v.optional(v.string()),
|
||||
artifact: notificationArtifactValidator,
|
||||
version: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: args.handle,
|
||||
artifact: args.artifact as NotificationArtifact,
|
||||
version: args.version,
|
||||
trigger: args.trigger,
|
||||
});
|
||||
return await sendTransactionalEmail({
|
||||
idempotencyKey: `malicious-artifact:${args.userId}:${args.findingAt}:${args.artifact.kind}:${args.artifact.name}:${args.version ?? ""}`,
|
||||
to: args.to,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -331,6 +331,7 @@ export async function repointPackageLatestRelease(
|
||||
if (!packageId || !affectedReleaseId) return;
|
||||
const pkg = await ctx.db.get(packageId);
|
||||
if (!pkg) return;
|
||||
if (pkg.softDeletedAt) return;
|
||||
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(pkg.tags).filter(([, releaseId]) => releaseId !== affectedReleaseId),
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/access", () => ({
|
||||
requireUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/publishers", async () => {
|
||||
const actual = await vi.importActual<typeof import("./lib/publishers")>("./lib/publishers");
|
||||
return {
|
||||
...actual,
|
||||
requirePublisherRole: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { requireUser } = await import("./lib/access");
|
||||
const { requirePublisherRole } = await import("./lib/publishers");
|
||||
const { deleteForPublisherHandler } = await import("./githubSkillSources");
|
||||
const { buildSkillInstallResolution } = await import("./lib/installResolver");
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Row, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const tables: Record<string, Row[]> = Object.fromEntries(
|
||||
Object.entries(initial).map(([table, rows]) => [table, [...rows]]),
|
||||
);
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((row) => row._id === id) ?? null;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const row = list(table).find((candidate) => candidate._id === id);
|
||||
if (!row) return;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) delete row[key];
|
||||
else row[key] = value;
|
||||
}
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
const id = `${table}:${list(table).length + 1}`;
|
||||
list(table).push({ _id: id, ...doc });
|
||||
return id;
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((row) => row._id === id);
|
||||
if (index >= 0) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requireUser).mockResolvedValue({ userId: "users:owner" } as never);
|
||||
vi.mocked(requirePublisherRole).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
it("deletes a source and removes only GitHub-backed skills from that source", async () => {
|
||||
const { db, tables } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:matt",
|
||||
repo: "mattpocock/skills",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
githubSkillContents: [
|
||||
{
|
||||
_id: "githubSkillContents:one",
|
||||
skillId: "skills:github",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:github",
|
||||
slug: "source-backed",
|
||||
displayName: "Source Backed",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
githubPath: "skills/source-backed",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
githubCurrentContentHash: "hash-source-backed",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
forkOf: undefined,
|
||||
tags: {},
|
||||
capabilityTags: undefined,
|
||||
badges: {},
|
||||
stats: {
|
||||
comments: 0,
|
||||
downloads: 0,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
},
|
||||
moderationStatus: "active",
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
{
|
||||
_id: "skills:direct",
|
||||
slug: "direct-upload",
|
||||
displayName: "Direct Upload",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
{
|
||||
_id: "skills:other-source",
|
||||
slug: "other-source",
|
||||
displayName: "Other Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:other",
|
||||
githubPath: "skills/other-source",
|
||||
githubCurrentCommit: "b".repeat(40),
|
||||
githubCurrentContentHash: "hash-other-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteForPublisherHandler({ db } as never, {
|
||||
ownerPublisherId: "publishers:openclaw" as never,
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
now: 123,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, deletedSkills: 1 });
|
||||
|
||||
expect(requirePublisherRole).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
publisherId: "publishers:openclaw",
|
||||
userId: "users:owner",
|
||||
allowed: ["admin"],
|
||||
}),
|
||||
);
|
||||
expect(tables.githubSkillSources).toHaveLength(0);
|
||||
expect(tables.githubSkillContents).toHaveLength(0);
|
||||
const deletedSkill = tables.skills.find((skill) => skill._id === "skills:github");
|
||||
expect(deletedSkill).toMatchObject({
|
||||
softDeletedAt: 123,
|
||||
githubRemovedAt: 123,
|
||||
githubCurrentStatus: "missing",
|
||||
updatedAt: 123,
|
||||
});
|
||||
expect(tables.skillSearchDigest).toEqual([
|
||||
expect.objectContaining({
|
||||
skillId: "skills:github",
|
||||
githubCurrentStatus: "missing",
|
||||
githubScanStatus: "clean",
|
||||
softDeletedAt: 123,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: deletedSkill as never,
|
||||
source: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_upstream_removed",
|
||||
status: 410,
|
||||
});
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:direct")).toMatchObject({
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:other-source")).toMatchObject({
|
||||
githubCurrentStatus: "present",
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deleting a source from another publisher", async () => {
|
||||
const { db } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:matt",
|
||||
repo: "mattpocock/skills",
|
||||
ownerPublisherId: "publishers:other",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteForPublisherHandler({ db } as never, {
|
||||
ownerPublisherId: "publishers:openclaw" as never,
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
now: 123,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConvexError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalQuery, mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { adjustGlobalPublicSkillsCount, getPublicSkillVisibilityDelta } from "./lib/globalStats";
|
||||
import { isOfficialPublisher } from "./lib/officialPublishers";
|
||||
import { isPublisherActive, isPublisherRoleAllowed, requirePublisherRole } from "./lib/publishers";
|
||||
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
|
||||
|
||||
type PublicGitHubSkillSource = Pick<
|
||||
Doc<"githubSkillSources">,
|
||||
| "_id"
|
||||
| "repo"
|
||||
| "defaultBranch"
|
||||
| "lastSyncStatus"
|
||||
| "lastSyncError"
|
||||
| "lastSyncErrorAt"
|
||||
| "displayManifestStatus"
|
||||
| "displayManifestFetchedAt"
|
||||
| "displayManifestCommit"
|
||||
| "lastSyncIssues"
|
||||
| "lastSyncInvalidSkills"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
> & {
|
||||
ownerPublisher: Pick<Doc<"publishers">, "_id" | "handle" | "displayName"> | null;
|
||||
skills: Array<
|
||||
Pick<Doc<"skills">, "_id" | "slug" | "displayName" | "githubPath" | "githubCurrentStatus">
|
||||
>;
|
||||
};
|
||||
|
||||
export const getByIdInternal = internalQuery({
|
||||
args: { sourceId: v.id("githubSkillSources") },
|
||||
handler: async (ctx, args) => ctx.db.get(args.sourceId),
|
||||
});
|
||||
|
||||
async function toPublicGitHubSkillSource(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
source: Doc<"githubSkillSources">,
|
||||
): Promise<PublicGitHubSkillSource> {
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", source._id))
|
||||
.collect();
|
||||
const visibleGitHubSkills = skills
|
||||
.filter((skill) => skill.installKind === "github" && !skill.softDeletedAt)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
.map((skill) => ({
|
||||
_id: skill._id,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentStatus: skill.githubCurrentStatus,
|
||||
}));
|
||||
const ownerPublisher = source.ownerPublisherId ? await ctx.db.get(source.ownerPublisherId) : null;
|
||||
|
||||
return {
|
||||
_id: source._id as Id<"githubSkillSources">,
|
||||
repo: source.repo,
|
||||
ownerPublisher: ownerPublisher
|
||||
? {
|
||||
_id: ownerPublisher._id,
|
||||
handle: ownerPublisher.handle,
|
||||
displayName: ownerPublisher.displayName,
|
||||
}
|
||||
: null,
|
||||
defaultBranch: source.defaultBranch,
|
||||
lastSyncStatus: source.lastSyncStatus,
|
||||
lastSyncError: source.lastSyncError,
|
||||
lastSyncErrorAt: source.lastSyncErrorAt,
|
||||
displayManifestStatus: source.displayManifestStatus,
|
||||
displayManifestFetchedAt: source.displayManifestFetchedAt,
|
||||
displayManifestCommit: source.displayManifestCommit,
|
||||
lastSyncIssues: source.lastSyncIssues,
|
||||
lastSyncInvalidSkills: source.lastSyncInvalidSkills,
|
||||
createdAt: source.createdAt,
|
||||
updatedAt: source.updatedAt,
|
||||
skills: visibleGitHubSkills,
|
||||
};
|
||||
}
|
||||
|
||||
export const listForPublisher = query({
|
||||
args: { ownerPublisherId: v.id("publishers") },
|
||||
handler: async (ctx, args): Promise<PublicGitHubSkillSource[]> => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
await requirePublisherRole(ctx, {
|
||||
publisherId: args.ownerPublisherId,
|
||||
userId,
|
||||
allowed: ["admin"],
|
||||
});
|
||||
const sources = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId))
|
||||
.collect();
|
||||
const sortedSources = sources.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source)));
|
||||
},
|
||||
});
|
||||
|
||||
export const listForManageableOfficialPublishers = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<PublicGitHubSkillSource[]> => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const ownerPublisherIds: Id<"publishers">[] = [];
|
||||
for (const membership of memberships) {
|
||||
if (!isPublisherRoleAllowed(membership.role, ["admin"])) continue;
|
||||
const publisher = await ctx.db.get(membership.publisherId);
|
||||
if (
|
||||
!publisher ||
|
||||
publisher.kind !== "org" ||
|
||||
!isPublisherActive(publisher) ||
|
||||
!(await isOfficialPublisher(ctx, publisher))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ownerPublisherIds.push(publisher._id);
|
||||
}
|
||||
const sourceGroups = await Promise.all(
|
||||
ownerPublisherIds.map((ownerPublisherId) =>
|
||||
ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
const sortedSources = sourceGroups.flat().sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source)));
|
||||
},
|
||||
});
|
||||
|
||||
export async function deleteForPublisherHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
ownerPublisherId: Id<"publishers">;
|
||||
sourceId: Id<"githubSkillSources">;
|
||||
now?: number;
|
||||
},
|
||||
) {
|
||||
const { userId } = await requireUser(ctx);
|
||||
await requirePublisherRole(ctx, {
|
||||
publisherId: args.ownerPublisherId,
|
||||
userId,
|
||||
allowed: ["admin"],
|
||||
});
|
||||
|
||||
const source = await ctx.db.get(args.sourceId);
|
||||
if (!source || source.ownerPublisherId !== args.ownerPublisherId) {
|
||||
throw new ConvexError("GitHub source not found.");
|
||||
}
|
||||
|
||||
const now = args.now ?? Date.now();
|
||||
const contents = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", args.sourceId))
|
||||
.collect();
|
||||
for (const content of contents) {
|
||||
await ctx.db.delete(content._id);
|
||||
}
|
||||
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", args.sourceId))
|
||||
.collect();
|
||||
let deletedSkills = 0;
|
||||
let publicSkillDelta = 0;
|
||||
for (const skill of skills) {
|
||||
if (skill.installKind !== "github") continue;
|
||||
|
||||
const nextSkill: Doc<"skills"> = {
|
||||
...skill,
|
||||
softDeletedAt: skill.softDeletedAt ?? now,
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: skill.githubRemovedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
publicSkillDelta += getPublicSkillVisibilityDelta(skill, nextSkill);
|
||||
await ctx.db.patch(skill._id, {
|
||||
softDeletedAt: nextSkill.softDeletedAt,
|
||||
githubCurrentStatus: nextSkill.githubCurrentStatus,
|
||||
githubRemovedAt: nextSkill.githubRemovedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
await syncSkillSearchDigestForSkill(ctx, nextSkill);
|
||||
deletedSkills += 1;
|
||||
}
|
||||
|
||||
if (publicSkillDelta !== 0) {
|
||||
await adjustGlobalPublicSkillsCount(ctx, publicSkillDelta, now);
|
||||
}
|
||||
await ctx.db.delete(args.sourceId);
|
||||
|
||||
return { ok: true as const, deletedSkills };
|
||||
}
|
||||
|
||||
export const deleteForPublisher: ReturnType<typeof mutation> = mutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
sourceId: v.id("githubSkillSources"),
|
||||
},
|
||||
handler: async (ctx, args) => deleteForPublisherHandler(ctx, args),
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyGitHubSkillSourceSyncHandler,
|
||||
applyGitHubSkillVerificationResultHandler,
|
||||
configurePublicGitHubSkillSourceHandler,
|
||||
upsertGitHubSkillContentHandler,
|
||||
verifyGitHubSkillHandler,
|
||||
} from "./githubSkillSync";
|
||||
import { buildSkillInstallResolution } from "./lib/installResolver";
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Row, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const tables: Record<string, Row[]> = Object.fromEntries(
|
||||
Object.entries(initial).map(([table, rows]) => [table, [...rows]]),
|
||||
);
|
||||
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((row) => row._id === id) ?? null;
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
_id: `${table}:new-${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 row = list(table).find((candidate) => candidate._id === id);
|
||||
if (!row) return;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) delete row[key];
|
||||
else row[key] = value;
|
||||
}
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_indexName: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build?.(chainEq(constraints));
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
function getSkill(tables: Record<string, Row[]>, slug: string) {
|
||||
const skill = tables.skills?.find((row) => row.slug === slug);
|
||||
if (!skill) throw new Error(`Live GitHub canary did not discover skill: ${slug}`);
|
||||
return skill;
|
||||
}
|
||||
|
||||
function resolveInstallFromTables(tables: Record<string, Row[]>, slug: string) {
|
||||
const skill = getSkill(tables, slug);
|
||||
const source =
|
||||
typeof skill.githubSourceId === "string"
|
||||
? (tables.githubSkillSources?.find((row) => row._id === skill.githubSourceId) ?? null)
|
||||
: null;
|
||||
return buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: skill as never,
|
||||
source: source as never,
|
||||
});
|
||||
}
|
||||
|
||||
const liveCanaryEnabled = process.env.CLAWHUB_LIVE_GITHUB_CANARY === "1";
|
||||
const itIfLive = liveCanaryEnabled ? it : it.skip;
|
||||
|
||||
describe("GitHub-backed skills live canary", () => {
|
||||
itIfLive(
|
||||
"discovers and verifies an installable skill from a real GitHub repo",
|
||||
{ timeout: 45_000 },
|
||||
async () => {
|
||||
const repo = process.env.CLAWHUB_LIVE_GITHUB_REPO?.trim() || "openclaw/agent-skills";
|
||||
const skillSlug = process.env.CLAWHUB_LIVE_GITHUB_SKILL?.trim() || "handoff";
|
||||
const { db, tables } = createDb({
|
||||
globalStats: [
|
||||
{
|
||||
_id: "globalStats:default",
|
||||
key: "default",
|
||||
activeSkillsCount: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: async () => undefined };
|
||||
let now = Date.now();
|
||||
const actionCtx = {
|
||||
runQuery: async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("ownerPublisherId" in args && "actorUserId" in args) {
|
||||
return {
|
||||
ownerUserId: "users:live-owner",
|
||||
existingSource:
|
||||
tables.githubSkillSources?.find((source) => source.repo === repo) ?? null,
|
||||
official: true,
|
||||
};
|
||||
}
|
||||
if ("skillId" in args) {
|
||||
const skill = tables.skills?.find((row) => row._id === args.skillId);
|
||||
const source =
|
||||
skill && typeof skill.githubSourceId === "string"
|
||||
? tables.githubSkillSources?.find((row) => row._id === skill.githubSourceId)
|
||||
: null;
|
||||
return skill && source ? { skill, source } : null;
|
||||
}
|
||||
if ("sourceId" in args) {
|
||||
return (tables.skills ?? []).flatMap((skill) => {
|
||||
if (
|
||||
skill.githubSourceId !== args.sourceId ||
|
||||
skill.installKind !== "github" ||
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
typeof skill.githubPath !== "string" ||
|
||||
typeof skill.githubCurrentContentHash !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
skillId: skill._id,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentContentHash: skill.githubCurrentContentHash,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected live canary query args: ${JSON.stringify(args)}`);
|
||||
},
|
||||
runMutation: async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("snapshot" in args) {
|
||||
return await applyGitHubSkillSourceSyncHandler(
|
||||
{ db, scheduler } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if ("scanStatus" in args && "contentHash" in args) {
|
||||
return await applyGitHubSkillVerificationResultHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if ("discovered" in args && "commit" in args) {
|
||||
return await upsertGitHubSkillContentHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected live canary mutation args: ${JSON.stringify(args)}`);
|
||||
},
|
||||
auth: { getUserIdentity: async () => null },
|
||||
};
|
||||
|
||||
const configured = await configurePublicGitHubSkillSourceHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
ownerPublisherId: "publishers:live" as never,
|
||||
repo,
|
||||
},
|
||||
fetch,
|
||||
{ userId: "users:live-owner" as never },
|
||||
);
|
||||
|
||||
expect(configured.stats.discovered).toBeGreaterThan(0);
|
||||
expect(configured.manifestStatus === "missing" || configured.manifestStatus === "ok").toBe(
|
||||
true,
|
||||
);
|
||||
expect(configured.commit).toMatch(/^[a-f0-9]{40}$/);
|
||||
|
||||
let skill = getSkill(tables, skillSlug);
|
||||
expect(skill).toMatchObject({
|
||||
installKind: "github",
|
||||
githubPath: `skills/${skillSlug}`,
|
||||
githubCurrentCommit: configured.commit,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
});
|
||||
|
||||
now = Date.now();
|
||||
const verified = await verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fetch,
|
||||
);
|
||||
|
||||
expect(verified).toMatchObject({ ok: true, scanStatus: "clean" });
|
||||
skill = getSkill(tables, skillSlug);
|
||||
expect(skill).toMatchObject({
|
||||
githubCurrentCommit: configured.commit,
|
||||
githubScanStatus: "clean",
|
||||
moderationStatus: "active",
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo,
|
||||
path: `skills/${skillSlug}`,
|
||||
commit: configured.commit,
|
||||
contentHash: skill.githubCurrentContentHash,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
||||
cliDeviceTokenHttp,
|
||||
cliSkillDeleteHttp,
|
||||
cliSkillUndeleteHttp,
|
||||
cliTelemetryInstallHttp,
|
||||
cliTelemetrySyncHttp,
|
||||
cliUploadUrlHttp,
|
||||
cliWhoamiHttp,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
starsPostRouterV1Http,
|
||||
transfersGetRouterV1Http,
|
||||
banAppealContextV1Http,
|
||||
usersGetRouterV1Http,
|
||||
usersListV1Http,
|
||||
usersPostRouterV1Http,
|
||||
verifyDocsSessionV1Http,
|
||||
@@ -271,6 +273,12 @@ http.route({
|
||||
handler: banAppealContextV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.users}/`,
|
||||
method: "GET",
|
||||
handler: usersGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.users,
|
||||
method: "GET",
|
||||
@@ -355,6 +363,12 @@ http.route({
|
||||
handler: cliPublishHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
method: "POST",
|
||||
handler: cliTelemetryInstallHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliTelemetrySync,
|
||||
method: "POST",
|
||||
|
||||
@@ -243,7 +243,7 @@ describe("httpApi handlers", () => {
|
||||
it("cliWhoamiHttp returns 401 on auth failure", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, contact security@openclaw.ai.",
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.",
|
||||
),
|
||||
);
|
||||
const response = await __handlers.cliWhoamiHandler(
|
||||
@@ -267,12 +267,12 @@ describe("httpApi handlers", () => {
|
||||
expect(json.user.handle).toBe("p");
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp forwards roots and returns ok", async () => {
|
||||
it("cliTelemetryInstallHttp forwards roots and returns ok", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -291,6 +291,22 @@ describe("httpApi handlers", () => {
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp remains a backwards-compatible alias", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp returns 400 on invalid payload", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
|
||||
+5
-2
@@ -227,7 +227,7 @@ export const cliSkillUndeleteHttp = httpAction((ctx, request) =>
|
||||
cliSkillDeleteHandler(ctx, request, false),
|
||||
);
|
||||
|
||||
async function cliTelemetrySyncHandler(ctx: ActionCtx, request: Request) {
|
||||
async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -258,7 +258,9 @@ async function cliTelemetrySyncHandler(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export const cliTelemetrySyncHttp = httpAction(cliTelemetrySyncHandler);
|
||||
const cliTelemetrySyncHandler = cliTelemetryInstallHandler;
|
||||
export const cliTelemetryInstallHttp = httpAction(cliTelemetryInstallHandler);
|
||||
export const cliTelemetrySyncHttp = httpAction(cliTelemetryInstallHandler);
|
||||
|
||||
async function cliDeviceCodeHandler(ctx: ActionCtx, request: Request) {
|
||||
if (request.method !== "POST") return text("Method not allowed", 405);
|
||||
@@ -392,6 +394,7 @@ export const __handlers = {
|
||||
cliUploadUrlHandler,
|
||||
cliPublishHandler,
|
||||
cliSkillDeleteHandler,
|
||||
cliTelemetryInstallHandler,
|
||||
cliTelemetrySyncHandler,
|
||||
cliDeviceCodeHandler,
|
||||
cliDeviceTokenHandler,
|
||||
|
||||
@@ -64,10 +64,53 @@ function hasPackageNameArgs(args: unknown): args is { name: string } {
|
||||
return typeof value.name === "string";
|
||||
}
|
||||
|
||||
function hasPackageDownloadMetricTarget(args: unknown, packageId: string) {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
const target = value.target;
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const targetValue = target as Record<string, unknown>;
|
||||
return targetValue.kind === "package" && targetValue.id === packageId;
|
||||
}
|
||||
|
||||
function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeInstallResolverRunQuery({
|
||||
skill,
|
||||
source = null,
|
||||
publicVisible = true,
|
||||
}: {
|
||||
skill: Record<string, unknown> | null;
|
||||
source?: Record<string, unknown> | null;
|
||||
publicVisible?: boolean;
|
||||
}) {
|
||||
let slugQueryCount = 0;
|
||||
return vi.fn(async (query: unknown, args: Record<string, unknown>) => {
|
||||
void query;
|
||||
if ("sourceId" in args) return source;
|
||||
if ("slug" in args) {
|
||||
slugQueryCount += 1;
|
||||
if (slugQueryCount === 1) {
|
||||
return skill;
|
||||
}
|
||||
if (slugQueryCount === 2) {
|
||||
return publicVisible && skill
|
||||
? {
|
||||
skill: {
|
||||
_id: skill._id,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected query ${JSON.stringify(args)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function makeCatalogItem(
|
||||
name: string,
|
||||
options: {
|
||||
@@ -214,6 +257,76 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("httpApiV1 handlers", () => {
|
||||
it("rejects local scan upload submissions with scan-download guidance", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
|
||||
});
|
||||
const response = await __handlers.skillScanSubmitV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/-/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: { kind: "upload" } }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
expect(await response.text()).toContain("clawhub scan download <slug> --version <version>");
|
||||
});
|
||||
|
||||
it("downloads stored scan reports for submitted skill versions", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
expect(args).toMatchObject({
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "demo-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
scanId: "skill:demo-skill:1.2.3",
|
||||
status: "succeeded",
|
||||
sourceKind: "published",
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "demo-skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
report: {
|
||||
clawscan: { status: "malicious", checkedAt: 1 },
|
||||
skillspector: null,
|
||||
staticAnalysis: null,
|
||||
virustotal: null,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
completedAt: 1,
|
||||
};
|
||||
});
|
||||
const response = await __handlers.skillScanGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/skills/-/scan/download/demo-skill?version=1.2.3"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/zip");
|
||||
expect(response.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="clawhub-scan-demo-skill-1.2.3.zip"',
|
||||
);
|
||||
expect((await response.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("search returns empty results for blank query", async () => {
|
||||
const runAction = vi.fn();
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
@@ -893,6 +1006,130 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("users/publisher-official lists official publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
items: [
|
||||
{
|
||||
officialPublisherId: "officialPublishers:openclaw",
|
||||
publisherId: "publishers:openclaw",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
kind: "org",
|
||||
active: true,
|
||||
reason: "platform-owned publisher",
|
||||
createdByUserId: "users:admin",
|
||||
createdByHandle: "patrick-erichsen-2",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.usersGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
items: [{ handle: "openclaw" }],
|
||||
});
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.publishers.listOfficialPublishersInternal, {
|
||||
actorUserId: "users:admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher-official adds official org publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
added: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
action: "add",
|
||||
handle: "NVIDIA",
|
||||
reason: "NVIDIA source-backed catalog",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ ok: true, added: true });
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.publishers.addOfficialPublisherInternal, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "nvidia",
|
||||
reason: "NVIDIA source-backed catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher-official removes official org publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
removed: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
action: "remove",
|
||||
handle: "NVIDIA",
|
||||
reason: "requested by publisher",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ ok: true, removed: true });
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.publishers.removeOfficialPublisherInternal, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "nvidia",
|
||||
reason: "requested by publisher",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishers creates a self-serve org publisher for the authenticated user", async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
@@ -1691,6 +1928,326 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns archive descriptor for hosted direct uploads", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo Skill",
|
||||
latestVersionSummary: { version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.0.0",
|
||||
downloadUrl: "https://example.com/api/v1/download?slug=demo&version=1.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns a pinned GitHub descriptor for scan-clean source-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
source: {
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "hosted direct uploads",
|
||||
slug: "hidden-direct",
|
||||
skill: {
|
||||
_id: "skills:hidden-direct",
|
||||
slug: "hidden-direct",
|
||||
displayName: "Hidden Direct",
|
||||
moderationStatus: "hidden",
|
||||
latestVersionSummary: { version: "1.0.0" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub-backed skills",
|
||||
slug: "hidden-github",
|
||||
skill: {
|
||||
_id: "skills:hidden-github",
|
||||
slug: "hidden-github",
|
||||
displayName: "Hidden GitHub",
|
||||
moderationStatus: "hidden",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/hidden-github",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-hidden-github",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
},
|
||||
])("skill install resolver hides moderated $name", async ({ slug, skill }) => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill,
|
||||
publicVisible: false,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(`https://example.com/api/v1/skills/${slug}/install`),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.text()).resolves.toBe("Skill not found");
|
||||
});
|
||||
|
||||
it("skill install resolver hides skills absent from the public skill detail path", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
publicVisible: false,
|
||||
skill: {
|
||||
_id: "skills:orphaned-github",
|
||||
slug: "orphaned-github",
|
||||
displayName: "Orphaned GitHub",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/orphaned-github",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-orphaned-github",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/orphaned-github/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.text()).resolves.toBe("Skill not found");
|
||||
});
|
||||
|
||||
it("skill install resolver installs the current GitHub hash after it is clean", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns structured GitHub blocks for pending source-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver force-installs pending GitHub-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install?forceInstall=1"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver blocks GitHub-backed skills with failed scans", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:bad-source",
|
||||
slug: "bad-source",
|
||||
displayName: "Bad Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/bad-source",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-bad-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/bad-source/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_scan_failed",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "pending scan",
|
||||
patch: { githubScanStatus: "pending" },
|
||||
status: 423,
|
||||
reason: "github_verification_pending",
|
||||
},
|
||||
{
|
||||
name: "missing upstream path",
|
||||
patch: { githubCurrentStatus: "missing" },
|
||||
status: 410,
|
||||
reason: "github_upstream_missing",
|
||||
},
|
||||
])(
|
||||
"skill install resolver blocks GitHub-backed skills with $name",
|
||||
async ({ patch, status, reason }) => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:blocked-source",
|
||||
slug: "blocked-source",
|
||||
displayName: "Blocked Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/blocked-source",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-blocked-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
...patch,
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/blocked-source/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(status);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("get skill treats reports as a valid slug", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
@@ -8743,7 +9300,7 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("npm mirror tarball downloads record package installs", async () => {
|
||||
it("npm mirror tarball downloads record package installs and download metrics", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args && !("paginationOpts" in args)) {
|
||||
return {
|
||||
@@ -8798,13 +9355,25 @@ describe("httpApiV1 handlers", () => {
|
||||
get: vi.fn(async () => new Blob(["tarball"], { type: "application/octet-stream" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz"),
|
||||
new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.10" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageInstallInternal, {
|
||||
packageId: "packages:demo-plugin",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
expect.objectContaining({
|
||||
target: { kind: "package", id: "packages:demo-plugin" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("npm mirror returns not found for invalid package lookup names", async () => {
|
||||
@@ -9202,7 +9771,9 @@ describe("httpApiV1 handlers", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.20" },
|
||||
}),
|
||||
);
|
||||
|
||||
const zipEntries = unzipSync(new Uint8Array(await response.arrayBuffer()));
|
||||
@@ -9211,9 +9782,147 @@ describe("httpApiV1 handlers", () => {
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(zipEntries["_meta.json"]).toBeUndefined();
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
|
||||
packageId: "packages:1",
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
{
|
||||
target: { kind: "package", id: "packages:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.any(String),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("package download metrics prefer API token user identity over IP", async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: {
|
||||
authorization: "Bearer clh_test",
|
||||
"cf-connecting-ip": "203.0.113.20",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
expect.objectContaining({
|
||||
target: { kind: "package", id: "packages:1" },
|
||||
identityKind: "user",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("package downloads succeed and record download metrics", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.20" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const mutationArgs = runMutation.mock.calls.map(([, args]) => args);
|
||||
expect(
|
||||
mutationArgs.filter((args) => hasPackageDownloadMetricTarget(args, "packages:1")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("package download fails when any stored file is missing", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatUserFacingErrorMessage,
|
||||
parseMultipartSkillScan,
|
||||
resolveVersionTagsBatch,
|
||||
softDeleteErrorToResponse,
|
||||
} from "./httpApiV1/shared";
|
||||
|
||||
function makeCtx() {
|
||||
@@ -32,6 +33,54 @@ describe("http API v1 shared helpers", () => {
|
||||
).toBe("Publisher not found");
|
||||
});
|
||||
|
||||
it("maps soft-delete validation failures to 400 with cleaned messages", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error(
|
||||
"[CONVEX M] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Package name must be lowercase and npm-safe (example: @scope/name or plugin-name)",
|
||||
),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe(
|
||||
"Package name must be lowercase and npm-safe (example: @scope/name or plugin-name)",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps reserved package route validation failures to 400 with cleaned messages", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error(
|
||||
'[CONVEX M] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Package name "publish" is reserved for ClawHub routes. Use a scoped name or choose a different package name.',
|
||||
),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe(
|
||||
'Package name "publish" is reserved for ClawHub routes. Use a scoped name or choose a different package name.',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unknown soft-delete failures generic 500s", async () => {
|
||||
const response = softDeleteErrorToResponse("soul", new Error("boom"), {});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.text()).resolves.toBe("Internal Server Error");
|
||||
});
|
||||
|
||||
it("keeps unrelated reserved-word failures generic 500s", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error("database reserved capacity exceeded"),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.text()).resolves.toBe("Internal Server Error");
|
||||
});
|
||||
|
||||
it("resolves latest tags without reading version documents", async () => {
|
||||
const ctx = makeCtx();
|
||||
const versionId = "skillVersions:latest" as Id<"skillVersions">;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { starsDeleteRouterV1Handler, starsPostRouterV1Handler } from "./httpApiV
|
||||
import { transfersGetRouterV1Handler } from "./httpApiV1/transfersV1";
|
||||
import {
|
||||
banAppealContextV1Handler,
|
||||
usersGetRouterV1Handler,
|
||||
usersListV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
} from "./httpApiV1/usersV1";
|
||||
@@ -84,6 +85,7 @@ export const starsDeleteRouterV1Http = httpAction(starsDeleteRouterV1Handler);
|
||||
export const transfersGetRouterV1Http = httpAction(transfersGetRouterV1Handler);
|
||||
|
||||
export const whoamiV1Http = httpAction(whoamiV1Handler);
|
||||
export const usersGetRouterV1Http = httpAction(usersGetRouterV1Handler);
|
||||
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler);
|
||||
export const usersListV1Http = httpAction(usersListV1Handler);
|
||||
export const banAppealContextV1Http = httpAction(banAppealContextV1Handler);
|
||||
@@ -108,6 +110,10 @@ export const __handlers = {
|
||||
skillsGetRouterV1Handler,
|
||||
publishSkillV1Handler,
|
||||
skillSecurityVerdictsV1Handler,
|
||||
skillScanSubmitV1Handler,
|
||||
skillScanGetRouterV1Handler,
|
||||
skillScanBatchSubmitV1Handler,
|
||||
skillScanBatchStatusV1Handler,
|
||||
skillsPostRouterV1Handler,
|
||||
skillsDeleteRouterV1Handler,
|
||||
exportSkillsV1Handler,
|
||||
@@ -120,6 +126,7 @@ export const __handlers = {
|
||||
starsDeleteRouterV1Handler,
|
||||
transfersGetRouterV1Handler,
|
||||
whoamiV1Handler,
|
||||
usersGetRouterV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
usersListV1Handler,
|
||||
banAppealContextV1Handler,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { buildDownloadMetricArgs, getDownloadIdentity } from "../downloadMetrics";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { parseClawPack, sha256Base64, sha256Hex } from "../lib/clawpack";
|
||||
@@ -123,6 +124,9 @@ const internalRefs = internal as unknown as {
|
||||
backfillPackageArtifactKindsInternal: unknown;
|
||||
listPackageModerationQueueInternal: unknown;
|
||||
};
|
||||
downloadMetrics: {
|
||||
recordDownloadMetricInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
createInternal: unknown;
|
||||
};
|
||||
@@ -227,6 +231,7 @@ function normalizeCapabilityTagSegment(value: string) {
|
||||
|
||||
const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
|
||||
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
|
||||
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads"] as const;
|
||||
|
||||
function invalidQueryParamMessage(name: string) {
|
||||
return `Invalid ${name} query parameter`;
|
||||
@@ -388,6 +393,7 @@ type PackageListQueryArgs = {
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: (typeof PACKAGE_LIST_SORT_VALUES)[number];
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
};
|
||||
@@ -643,9 +649,11 @@ function releaseArtifactUrls(request: Request, packageName: string, release: Rel
|
||||
|
||||
async function streamClawPackRelease(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
rateHeaders: HeadersInit,
|
||||
pkg: PublicPackageDocLike,
|
||||
release: ReleaseLike,
|
||||
viewerUserId: Id<"users"> | null,
|
||||
statKind: "download" | "install" = "download",
|
||||
) {
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
@@ -656,13 +664,24 @@ async function streamClawPackRelease(
|
||||
const blob = await ctx.storage.get(release.clawpackStorageId);
|
||||
if (!blob) return text("ClawPack artifact not found", 404, rateHeaders);
|
||||
try {
|
||||
const statMutation =
|
||||
statKind === "install"
|
||||
? internalRefs.packages.recordPackageInstallInternal
|
||||
: internalRefs.packages.recordPackageDownloadInternal;
|
||||
await runMutationRef(ctx, statMutation, {
|
||||
packageId: pkg._id,
|
||||
});
|
||||
if (statKind === "install") {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageInstallInternal, {
|
||||
packageId: pkg._id,
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
if (identity) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: pkg._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort metric path; never fail package downloads.
|
||||
}
|
||||
@@ -719,6 +738,7 @@ type CatalogListItem = {
|
||||
capabilityTags?: string[];
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
stats?: { downloads: number; installs: number; stars: number; versions: number };
|
||||
};
|
||||
|
||||
type CatalogSearchEntry = {
|
||||
@@ -924,6 +944,18 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function compareCatalogItemsForSort(
|
||||
a: CatalogListItem,
|
||||
b: CatalogListItem,
|
||||
sort: (typeof PACKAGE_LIST_SORT_VALUES)[number] | undefined,
|
||||
) {
|
||||
if (sort === "downloads") {
|
||||
const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0);
|
||||
if (downloads !== 0) return downloads;
|
||||
}
|
||||
return compareCatalogItems(a, b);
|
||||
}
|
||||
|
||||
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
|
||||
return (
|
||||
(a.rankTier ?? Number.POSITIVE_INFINITY) - (b.rankTier ?? Number.POSITIVE_INFINITY) ||
|
||||
@@ -1335,6 +1367,8 @@ async function listPackages(
|
||||
if (!highlightedOnlyParam.ok) return text(highlightedOnlyParam.message, 400, rate.headers);
|
||||
const executesCode = parseBooleanQueryParam(url.searchParams, "executesCode");
|
||||
if (!executesCode.ok) return text(executesCode.message, 400, rate.headers);
|
||||
const sortParam = parseEnumQueryParam(url.searchParams, "sort", PACKAGE_LIST_SORT_VALUES);
|
||||
if (!sortParam.ok) return text(sortParam.message, 400, rate.headers);
|
||||
const category = url.searchParams.get("category")?.trim() || undefined;
|
||||
if (category && !isPluginCategorySlug(category)) {
|
||||
return text("Invalid plugin category", 400, rate.headers);
|
||||
@@ -1361,6 +1395,7 @@ async function listPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
sort: sortParam.value,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
});
|
||||
return json(
|
||||
@@ -1394,6 +1429,7 @@ async function listPackages(
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
category,
|
||||
sort: sortParam.value,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
@@ -1414,6 +1450,7 @@ async function listPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
sort: sortParam.value,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
return {
|
||||
@@ -1427,7 +1464,8 @@ async function listPackages(
|
||||
if (!packageCandidate && !skillCandidate) break;
|
||||
if (
|
||||
!skillCandidate ||
|
||||
(packageCandidate && compareCatalogItems(packageCandidate, skillCandidate) <= 0)
|
||||
(packageCandidate &&
|
||||
compareCatalogItemsForSort(packageCandidate, skillCandidate, sortParam.value) <= 0)
|
||||
) {
|
||||
items.push(packageCandidate!);
|
||||
packageSource.index += 1;
|
||||
@@ -1479,6 +1517,7 @@ async function listPackages(
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
category,
|
||||
sort: sortParam.value,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
@@ -1507,7 +1546,8 @@ async function listPackages(
|
||||
if (
|
||||
!bundlePluginCandidate ||
|
||||
(codePluginCandidate &&
|
||||
compareCatalogItems(codePluginCandidate, bundlePluginCandidate) <= 0)
|
||||
compareCatalogItemsForSort(codePluginCandidate, bundlePluginCandidate, sortParam.value) <=
|
||||
0)
|
||||
) {
|
||||
items.push(codePluginCandidate!);
|
||||
codePluginSource.index += 1;
|
||||
@@ -1548,6 +1588,7 @@ async function listPackages(
|
||||
executesCode: executesCode.value,
|
||||
capabilityTag,
|
||||
category,
|
||||
sort: sortParam.value,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
} satisfies PackageListQueryArgs);
|
||||
@@ -2906,7 +2947,14 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
if (packageSegments[3] === "download") {
|
||||
if (release.artifactKind === "npm-pack") {
|
||||
return await streamClawPackRelease(ctx, rate.headers, publicPackage!, release);
|
||||
return await streamClawPackRelease(
|
||||
ctx,
|
||||
request,
|
||||
rate.headers,
|
||||
publicPackage!,
|
||||
release,
|
||||
viewerUserId ?? null,
|
||||
);
|
||||
}
|
||||
const url = new URL(
|
||||
`/api/v1/packages/${encodePackagePath(publicPackage!.name)}/download`,
|
||||
@@ -3105,9 +3153,18 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
const zip = buildDeterministicPackageZip(entries);
|
||||
const [zipSha256, zipSha256Base64] = await Promise.all([sha256Hex(zip), sha256Base64(zip)]);
|
||||
try {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
|
||||
packageId: publicPackage!._id,
|
||||
});
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
if (identity) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: publicPackage!._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort metric path; never fail package downloads.
|
||||
}
|
||||
@@ -3253,7 +3310,15 @@ export async function npmMirrorGetHandler(ctx: ActionCtx, request: Request) {
|
||||
const tarballName = path.rest[1]!;
|
||||
const release = releases.find((candidate) => candidate.npmTarballName === tarballName);
|
||||
if (!release) return text("ClawPack artifact not found", 404, rate.headers);
|
||||
return await streamClawPackRelease(ctx, rate.headers, detail.package, release, "install");
|
||||
return await streamClawPackRelease(
|
||||
ctx,
|
||||
request,
|
||||
rate.headers,
|
||||
detail.package,
|
||||
release,
|
||||
viewerUserId ?? null,
|
||||
"install",
|
||||
);
|
||||
}
|
||||
if (path.rest.length > 0) return text("Not found", 404, rate.headers);
|
||||
|
||||
|
||||
@@ -514,22 +514,40 @@ export function parsePublishBody(body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
// Substrings that indicate user-input validation failures from the underlying
|
||||
// mutations (e.g. `normalizePackageName` ConvexErrors). These are surfaced as
|
||||
// 400s with the cleaned message so CLI/API clients can see the actual reason
|
||||
// instead of an opaque 500.
|
||||
const SOFT_DELETE_BAD_REQUEST_HINTS = [
|
||||
"slug required",
|
||||
"package name required",
|
||||
"package name must be",
|
||||
"must be lowercase",
|
||||
"npm-safe",
|
||||
"reserved for clawhub routes",
|
||||
"version required",
|
||||
] as const;
|
||||
|
||||
export function softDeleteErrorToResponse(
|
||||
entity: "skill" | "soul" | "package",
|
||||
error: unknown,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const message = error instanceof Error ? error.message : `${entity} delete failed`;
|
||||
const lower = message.toLowerCase();
|
||||
const rawMessage = error instanceof Error ? error.message : `${entity} delete failed`;
|
||||
const cleaned = cleanUserFacingErrorMessage(rawMessage) || rawMessage;
|
||||
const lower = cleaned.toLowerCase();
|
||||
|
||||
if (lower.includes("unauthorized"))
|
||||
return text(formatAuthzMessage(error, "Unauthorized"), 401, headers);
|
||||
if (lower.includes("forbidden"))
|
||||
return text(formatAuthzMessage(error, "Forbidden"), 403, headers);
|
||||
if (lower.includes("not found")) return text(message, 404, headers);
|
||||
if (lower.includes("slug required")) return text("Slug required", 400, headers);
|
||||
if (lower.includes("not found")) return text(cleaned, 404, headers);
|
||||
if (SOFT_DELETE_BAD_REQUEST_HINTS.some((hint) => lower.includes(hint))) {
|
||||
return text(cleaned, 400, headers);
|
||||
}
|
||||
|
||||
// Unknown: server-side failure. Keep body generic.
|
||||
// Unknown: server-side failure. Keep the body generic; only known
|
||||
// user-input validation failures above surface the cleaned mutation message.
|
||||
return text("Internal Server Error", 500, headers);
|
||||
}
|
||||
|
||||
|
||||
+130
-36
@@ -21,6 +21,12 @@ import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenA
|
||||
import { mergeHeaders } from "../lib/httpHeaders";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import {
|
||||
buildSkillInstallResolution,
|
||||
type InstallResolverSkill,
|
||||
type InstallResolverSource,
|
||||
type SkillInstallResolution,
|
||||
} from "../lib/installResolver";
|
||||
import type {
|
||||
LlmAgenticRiskFinding,
|
||||
LlmEvalDimension,
|
||||
@@ -42,7 +48,6 @@ import {
|
||||
getPathSegments,
|
||||
json,
|
||||
parseJsonPayload,
|
||||
parseMultipartSkillScan,
|
||||
parseMultipartPublish,
|
||||
parsePublishBody,
|
||||
publicApiOrigin,
|
||||
@@ -284,10 +289,13 @@ type SkillSecuritySnapshot = {
|
||||
};
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
githubSkillSources: {
|
||||
getByIdInternal: unknown;
|
||||
};
|
||||
securityScan: {
|
||||
createUploadedSkillScanRequestInternal: unknown;
|
||||
createPublishedSkillScanRequestInternal: unknown;
|
||||
enqueueBulkSkillRescanBatchForAdminInternal: unknown;
|
||||
getStoredScanReportForUserInternal: unknown;
|
||||
getSkillScanRequestForUserInternal: unknown;
|
||||
getBulkSkillRescanBatchStatusForAdminInternal: unknown;
|
||||
requestSkillRescanForUserInternal: unknown;
|
||||
@@ -297,6 +305,7 @@ const internalRefs = internal as unknown as {
|
||||
};
|
||||
skills: {
|
||||
getSecurityVerdictTargetInternal: unknown;
|
||||
getSkillBySlugInternal: unknown;
|
||||
reportSkillForUserInternal: unknown;
|
||||
listSkillReportsInternal: unknown;
|
||||
triageSkillReportForUserInternal: unknown;
|
||||
@@ -324,10 +333,6 @@ function isMultipartRequest(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteStoredScanFiles(ctx: ActionCtx, files: Array<{ storageId: Id<"_storage"> }>) {
|
||||
await Promise.allSettled(files.map((file) => ctx.storage.delete(file.storageId)));
|
||||
}
|
||||
|
||||
function encodeJsonEntry(value: unknown) {
|
||||
return new TextEncoder().encode(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
@@ -386,6 +391,16 @@ function buildSkillScanReportZip(status: Record<string, unknown>) {
|
||||
]);
|
||||
}
|
||||
|
||||
function safeScanReportFilenamePart(value: string) {
|
||||
return (
|
||||
value
|
||||
.replace(/^@/, "")
|
||||
.replaceAll("/", "-")
|
||||
.replaceAll(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "artifact"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSkillScanBatchSubmit(ctx: ActionCtx, request: Request, headers: HeadersInit) {
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
@@ -1113,35 +1128,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
|
||||
try {
|
||||
if (isMultipartRequest(request)) {
|
||||
const multipart = await parseMultipartSkillScan(ctx, request, (payload) => {
|
||||
const parsed = parseArk(
|
||||
ApiV1SkillScanSubmitRequestSchema,
|
||||
payload,
|
||||
"Skill scan payload",
|
||||
) as {
|
||||
source: { kind: "upload" } | { kind: "published"; slug: string; version?: string };
|
||||
update?: boolean;
|
||||
};
|
||||
if (parsed.source.kind !== "upload") {
|
||||
throw new Error("multipart scan payload must use source.kind=upload");
|
||||
}
|
||||
if (parsed.update === true) {
|
||||
throw new Error("update is not valid for uploaded scans");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.createUploadedSkillScanRequestInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
files: multipart.files,
|
||||
},
|
||||
).catch(async (error) => {
|
||||
await deleteStoredScanFiles(ctx, multipart.files);
|
||||
throw error;
|
||||
});
|
||||
return json(result, 202, rate.headers);
|
||||
return text(
|
||||
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
|
||||
410,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
const body = parseArk(
|
||||
@@ -1153,7 +1144,11 @@ export async function skillScanSubmitV1Handler(ctx: ActionCtx, request: Request)
|
||||
update?: boolean;
|
||||
};
|
||||
if (body.source.kind === "upload") {
|
||||
return text("uploaded scans must use multipart/form-data", 400, rate.headers);
|
||||
return text(
|
||||
"Local upload scans are no longer supported. Upload a version, then use `clawhub scan download <slug> --version <version>` to retrieve stored scan results.",
|
||||
410,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
@@ -1187,6 +1182,32 @@ export async function skillScanGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
if (!scanId) return text("scanId required", 400, rate.headers);
|
||||
|
||||
try {
|
||||
if (segments.length === 2 && scanId === "download") {
|
||||
const name = (segments[1] ?? "").trim();
|
||||
const url = new URL(request.url);
|
||||
const version = url.searchParams.get("version")?.trim() ?? "";
|
||||
const kind = url.searchParams.get("kind")?.trim() === "plugin" ? "plugin" : "skill";
|
||||
if (!name) return text("name required", 400, rate.headers);
|
||||
if (!version) return text("version required", 400, rate.headers);
|
||||
|
||||
const status = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.getStoredScanReportForUserInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
kind,
|
||||
name,
|
||||
version,
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
const zip = buildSkillScanReportZip(status);
|
||||
const headers = mergeHeaders(rate.headers, {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="clawhub-scan-${safeScanReportFilenamePart(name)}-${safeScanReportFilenamePart(version)}.zip"`,
|
||||
});
|
||||
return new Response(zip, { status: 200, headers });
|
||||
}
|
||||
|
||||
const status = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.securityScan.getSkillScanRequestForUserInternal,
|
||||
@@ -1466,6 +1487,25 @@ async function describeOwnerVisibleSkillState(
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldExposeHiddenGitHubInstallBlock(
|
||||
skill: InstallResolverSkill & {
|
||||
installKind?: "github";
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
},
|
||||
resolution: SkillInstallResolution,
|
||||
) {
|
||||
if (skill.installKind !== "github" || resolution.ok) return false;
|
||||
if (skill.moderationStatus !== "hidden") return false;
|
||||
const reason = skill.moderationReason ?? "";
|
||||
return (
|
||||
reason === "pending.scan" ||
|
||||
reason === "scanner.failed" ||
|
||||
reason === "scanner.llm.malicious" ||
|
||||
reason.startsWith("github.")
|
||||
);
|
||||
}
|
||||
|
||||
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
@@ -1530,6 +1570,60 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
|
||||
if (second === "install" && segments.length === 2) {
|
||||
const url = new URL(request.url);
|
||||
const forceInstall = parseBooleanQueryParam(url.searchParams.get("forceInstall"));
|
||||
const skill = (await runQueryRef<
|
||||
| (InstallResolverSkill & {
|
||||
_id: Id<"skills">;
|
||||
githubSourceId?: Id<"githubSkillSources">;
|
||||
softDeletedAt?: number;
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
moderationFlags?: string[];
|
||||
})
|
||||
| null
|
||||
>(ctx, internalRefs.skills.getSkillBySlugInternal, { slug })) as
|
||||
| (InstallResolverSkill & {
|
||||
_id: Id<"skills">;
|
||||
githubSourceId?: Id<"githubSkillSources">;
|
||||
softDeletedAt?: number;
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
moderationFlags?: string[];
|
||||
})
|
||||
| null;
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === "removed") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
const source =
|
||||
skill.installKind === "github" && skill.githubSourceId
|
||||
? ((await runQueryRef(ctx, internalRefs.githubSkillSources.getByIdInternal, {
|
||||
sourceId: skill.githubSourceId,
|
||||
})) as InstallResolverSource | null)
|
||||
: null;
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: publicApiOrigin(request),
|
||||
skill,
|
||||
source,
|
||||
forceInstall,
|
||||
});
|
||||
|
||||
const publicSkillResult = (await ctx.runQuery(api.skills.getBySlug, {
|
||||
slug,
|
||||
})) as GetBySlugResult;
|
||||
const publiclyVisible = publicSkillResult?.skill?._id === skill._id;
|
||||
if (!publiclyVisible) {
|
||||
if (!resolution.ok && shouldExposeHiddenGitHubInstallBlock(skill, resolution)) {
|
||||
return json(resolution, resolution.status, rate.headers);
|
||||
}
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
return json(resolution, resolution.ok ? 200 : resolution.status, rate.headers);
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
|
||||
if (!result?.skill) {
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
|
||||
const usersV1InternalRefs = internal as unknown as {
|
||||
publishers: {
|
||||
addOfficialPublisherInternal: unknown;
|
||||
listOfficialPublishersInternal: unknown;
|
||||
removeOrgPublisherMemberInternal: unknown;
|
||||
removeOfficialPublisherInternal: unknown;
|
||||
};
|
||||
users: {
|
||||
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
|
||||
@@ -84,6 +87,7 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
action !== "reclaim" &&
|
||||
action !== "reserve" &&
|
||||
action !== "publisher" &&
|
||||
action !== "publisher-official" &&
|
||||
action !== "publisher-member"
|
||||
) {
|
||||
return text("Not found", 404, rate.headers);
|
||||
@@ -139,6 +143,12 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return handleAdminEnsurePublisher(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher-official") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
return handleAdminOfficialPublisherPost(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher-member") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
@@ -352,6 +362,37 @@ async function handleAdminRemediateAutobans(
|
||||
}
|
||||
}
|
||||
|
||||
export async function usersGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const segments = getPathSegments(request, "/api/v1/users/");
|
||||
if (segments.length !== 1 || segments[0] !== "publisher-official") {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!authResult.ok) return authResult.response;
|
||||
const admin = requireAdminOrResponse(authResult.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
|
||||
try {
|
||||
const result = await runUsersV1QueryRef(
|
||||
ctx,
|
||||
usersV1InternalRefs.publishers.listOfficialPublishersInternal,
|
||||
{ actorUserId: authResult.userId },
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Official publisher list failed";
|
||||
if (message.toLowerCase().includes("forbidden")) return text("Forbidden", 403, rate.headers);
|
||||
if (message.toLowerCase().includes("unauthorized")) {
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
return text(message, 400, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/restore
|
||||
* Admin-only: restore skills from GitHub backup for a user.
|
||||
@@ -532,6 +573,42 @@ async function handleAdminReserve(
|
||||
return json({ ok: true, results, succeeded, failed }, 200, headers);
|
||||
}
|
||||
|
||||
async function handleAdminOfficialPublisherPost(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<"users">,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const action = typeof payload.action === "string" ? payload.action.trim().toLowerCase() : "";
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
||||
if (action !== "add" && action !== "remove") return text("Invalid action", 400, headers);
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
if (!reason) return text("Missing reason", 400, headers);
|
||||
if (reason.length > 500) return text("Reason too long (max 500 chars)", 400, headers);
|
||||
|
||||
try {
|
||||
const result = await runUsersV1MutationRef(
|
||||
ctx,
|
||||
action === "add"
|
||||
? usersV1InternalRefs.publishers.addOfficialPublisherInternal
|
||||
: usersV1InternalRefs.publishers.removeOfficialPublisherInternal,
|
||||
{
|
||||
actorUserId,
|
||||
handle,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Official publisher update failed";
|
||||
if (message.toLowerCase().includes("forbidden")) return text("Forbidden", 403, headers);
|
||||
if (message.toLowerCase().includes("unauthorized")) return text("Unauthorized", 401, headers);
|
||||
if (message.toLowerCase().includes("not found")) return text(message, 404, headers);
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminEnsurePublisher(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
@@ -9,6 +9,10 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { assertAdmin, assertModerator, assertRole, requireUser, requireUserFromAction } =
|
||||
await import("./access");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
describe("access.requireUser", () => {
|
||||
it("throws when auth is missing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null);
|
||||
@@ -59,6 +63,67 @@ describe("access.requireUser", () => {
|
||||
expect(dbGet).toHaveBeenCalledWith("users:2");
|
||||
expect(result).toEqual({ userId: "users:2", user });
|
||||
});
|
||||
|
||||
it("uses the local dev impersonation user before browser auth", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:local", handle: "local", role: "admin" };
|
||||
const unique = vi.fn().mockResolvedValue(user as never);
|
||||
const withIndex = vi.fn().mockReturnValue({ unique });
|
||||
const query = vi.fn().mockReturnValue({ withIndex });
|
||||
const dbGet = vi.fn().mockResolvedValue(user as never);
|
||||
|
||||
const result = await requireUser({
|
||||
db: { get: dbGet, query },
|
||||
} as never);
|
||||
|
||||
expect(query).toHaveBeenCalledWith("users");
|
||||
expect(withIndex).toHaveBeenCalledWith("handle", expect.any(Function));
|
||||
expect(dbGet).toHaveBeenCalledWith("users:local");
|
||||
expect(getAuthUserId).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ userId: "users:local", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use local dev impersonation in production deployments", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:browser", handle: "browser", role: "user" };
|
||||
const query = vi.fn();
|
||||
const dbGet = vi.fn().mockResolvedValue(user as never);
|
||||
|
||||
const result = await requireUser({
|
||||
db: { get: dbGet, query },
|
||||
} as never);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(getAuthUserId).toHaveBeenCalled();
|
||||
expect(dbGet).toHaveBeenCalledWith("users:browser");
|
||||
expect(result).toEqual({ userId: "users:browser", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
if (previousDeployment === undefined) delete process.env.CONVEX_DEPLOYMENT;
|
||||
else process.env.CONVEX_DEPLOYMENT = previousDeployment;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("access.requireUserFromAction", () => {
|
||||
@@ -111,6 +176,37 @@ describe("access.requireUserFromAction", () => {
|
||||
expect(runQuery).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ userId: "users:9", user });
|
||||
});
|
||||
|
||||
it("uses the local dev impersonation user before action auth", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:local", handle: "local", role: "admin" };
|
||||
const runQuery = vi.fn(async (_query, args: { handle?: string; userId?: string }) => {
|
||||
if (args.handle === "local") return user;
|
||||
if (args.userId === "users:local") return user;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await requireUserFromAction({ runQuery } as never);
|
||||
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
expect(runQuery).toHaveBeenNthCalledWith(1, expect.anything(), { handle: "local" });
|
||||
expect(runQuery).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
userId: "users:local",
|
||||
});
|
||||
expect(getAuthUserId).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ userId: "users:local", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("access role assertions", () => {
|
||||
|
||||
+36
-7
@@ -8,10 +8,23 @@ export type Role = "admin" | "moderator" | "user" | "mirror";
|
||||
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
|
||||
|
||||
function readEnv(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
const value = readKnownEnv(name)?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function readKnownEnv(name: string) {
|
||||
if (name === "CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE") {
|
||||
return process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
}
|
||||
if (name === "CLAW_HUB_ENABLE_DEV_IMPERSONATION") {
|
||||
return process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
}
|
||||
if (name === "CONVEX_DEPLOYMENT") {
|
||||
return process.env.CONVEX_DEPLOYMENT;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDevImpersonationAllowed() {
|
||||
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
|
||||
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
|
||||
@@ -52,39 +65,49 @@ async function getDevImpersonatedUserIdFromAction(
|
||||
export async function getOptionalActiveAuthUserId(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
const devUserId = await getDevImpersonatedUserId(ctx);
|
||||
if (devUserId) return devUserId;
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return await getDevImpersonatedUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return await getDevImpersonatedUserId(ctx);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptionalActiveAuthUserIdFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
const devUserId = await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (devUserId) return devUserId;
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const devUserId = await getDevImpersonatedUserId(ctx);
|
||||
if (devUserId) {
|
||||
const devUser = await ctx.db.get(devUserId);
|
||||
if (!devUser || devUser.deletedAt || devUser.deactivatedAt) throw new Error("User not found");
|
||||
return { userId: devUserId, user: devUser };
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -99,13 +122,19 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
export async function requireUserFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
|
||||
const devUserId = await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (devUserId) {
|
||||
const devUser = await ctx.runQuery(internal.users.getByIdInternal, { userId: devUserId });
|
||||
if (!devUser || devUser.deletedAt || devUser.deactivatedAt) throw new Error("User not found");
|
||||
return { userId: devUserId, user: devUser as Doc<"users"> };
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -34,7 +34,7 @@ export const MISSING_API_TOKEN_MESSAGE =
|
||||
export const INVALID_API_TOKEN_MESSAGE =
|
||||
"Unauthorized: API token is invalid or revoked. Run `clawhub login` again.";
|
||||
export const BLOCKED_API_TOKEN_ACCOUNT_MESSAGE =
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, contact security@openclaw.ai.";
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.";
|
||||
|
||||
export async function requireApiTokenUser(
|
||||
ctx: ActionCtx,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLocalDevAuthEnabled } from "./devAuth";
|
||||
|
||||
const CLOUD_DEV_AUTH_SECRET = "dev-auth-secret-with-enough-entropy-123";
|
||||
|
||||
describe("isLocalDevAuthEnabled", () => {
|
||||
it("requires the explicit dev auth flag", () => {
|
||||
expect(
|
||||
@@ -41,16 +43,78 @@ describe("isLocalDevAuthEnabled", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments even when the dev auth flag is set", () => {
|
||||
it("allows cloud dev deployments with an explicit localhost site and matching secret", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows cloud dev deployments from the fallback marker when Convex deployment is blank", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_DEPLOYMENT: "",
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments when the secret is missing", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled({
|
||||
CONVEX_SITE_URL: "http://127.0.0.1:3211",
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments when the configured secret is too short", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: "short",
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
"short",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments without an explicit localhost dev auth site", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "http://127.0.0.1:3211",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost site URLs without a local deployment marker", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled({
|
||||
|
||||
+33
-3
@@ -3,18 +3,48 @@ type DevAuthEnv = {
|
||||
CONVEX_SITE_URL?: string;
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT?: string;
|
||||
DEV_AUTH_ENABLED?: string;
|
||||
DEV_AUTH_SECRET?: string;
|
||||
DEV_AUTH_SITE_URL?: string;
|
||||
};
|
||||
|
||||
export function isLocalDevAuthEnabled(env: DevAuthEnv = process.env) {
|
||||
const MIN_CLOUD_DEV_AUTH_SECRET_LENGTH = 32;
|
||||
|
||||
export function isLocalDevAuthEnabled(env: DevAuthEnv = process.env, suppliedSecret?: string) {
|
||||
if (env.DEV_AUTH_ENABLED !== "1") return false;
|
||||
const deployment = env.CONVEX_DEPLOYMENT?.trim() || env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim() || "";
|
||||
return isLocalConvexDeployment(deployment) && isLocalhostUrl(env.CONVEX_SITE_URL);
|
||||
const convexDeployment = env.CONVEX_DEPLOYMENT?.trim();
|
||||
const devAuthDeployment = env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim();
|
||||
const deployment = convexDeployment || devAuthDeployment || "";
|
||||
|
||||
if (isLocalConvexDeployment(deployment)) {
|
||||
return isLocalhostUrl(env.CONVEX_SITE_URL);
|
||||
}
|
||||
|
||||
if (isDevConvexDeployment(deployment)) {
|
||||
return isLocalhostUrl(env.DEV_AUTH_SITE_URL) && hasValidCloudDevAuthSecret(env, suppliedSecret);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalConvexDeployment(deployment: string) {
|
||||
return deployment.startsWith("local:") || deployment.startsWith("anonymous:");
|
||||
}
|
||||
|
||||
function isDevConvexDeployment(deployment: string) {
|
||||
return deployment.startsWith("dev:");
|
||||
}
|
||||
|
||||
function hasValidCloudDevAuthSecret(env: DevAuthEnv, suppliedSecret: string | undefined) {
|
||||
const expected = env.DEV_AUTH_SECRET?.trim();
|
||||
const actual = suppliedSecret?.trim();
|
||||
return Boolean(
|
||||
expected &&
|
||||
actual &&
|
||||
expected.length >= MIN_CLOUD_DEV_AUTH_SECRET_LENGTH &&
|
||||
actual === expected,
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalhostUrl(value: string | undefined) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function assertLocalDevSeedAllowed(seedName: string): void {
|
||||
const deployment =
|
||||
process.env.CONVEX_DEPLOYMENT?.trim() || process.env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim() || "";
|
||||
if (
|
||||
deployment.startsWith("dev:") ||
|
||||
deployment.startsWith("local:") ||
|
||||
deployment.startsWith("anonymous:")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!deployment &&
|
||||
(process.env.DEV_AUTH_ENABLED === "1" || process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION === "1")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${seedName} dev seed is disabled outside local/dev deployments`);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APPEALS_URL,
|
||||
buildMaliciousArtifactEmail,
|
||||
buildBanNotificationEmail,
|
||||
buildRestoredAccountEmail,
|
||||
} from "./emails";
|
||||
|
||||
describe("moderation notification email copy", () => {
|
||||
it("builds public-safe malicious skill context with appeal but no local scan guidance", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "gingiris",
|
||||
source: "autoban",
|
||||
reason: "malicious.llm_malicious",
|
||||
artifact: { kind: "skill", name: "gingiris-launch" },
|
||||
trigger: "scanner.llm.malicious",
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("Your ClawHub account was disabled");
|
||||
expect(email.context).toMatchObject({
|
||||
appealUrl: APPEALS_URL,
|
||||
artifact: { kind: "skill", name: "gingiris-launch" },
|
||||
scannerLabel: "ClawScan",
|
||||
findingSummary: "ClawScan classified the uploaded skill as malicious.",
|
||||
});
|
||||
expect(email.text).toContain("Skill: gingiris-launch");
|
||||
expect(email.text).not.toContain("Scanner:");
|
||||
expect(email.html).not.toContain("<strong>Scanner:</strong>");
|
||||
expect(email.text).not.toContain("republishing");
|
||||
expect(email.html).not.toContain("republishing");
|
||||
expect(email.text).not.toContain("To support your appeal, include scan results");
|
||||
expect(email.html).not.toContain("Include scan results with your appeal");
|
||||
expect(email.text).toContain("Appeal: https://appeals.openclaw.ai/");
|
||||
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
|
||||
expect(email.text).not.toContain("https://docs.openclaw.ai/clawhub/cli#scan-path");
|
||||
});
|
||||
|
||||
it("does not leak raw manual moderator notes into outbound email", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "target",
|
||||
source: "manual",
|
||||
reason: "internal reviewer note: reporter=user_123 secret finding id=abc",
|
||||
});
|
||||
|
||||
expect(email.context.findingSummary).toBe(
|
||||
"ClawHub staff disabled the account after a security review.",
|
||||
);
|
||||
expect(email.text).not.toContain("internal reviewer note");
|
||||
expect(email.text).not.toContain("reporter=user_123");
|
||||
expect(email.html).not.toContain("secret finding id");
|
||||
});
|
||||
|
||||
it("uses rate-limit copy without scan remediation guidance", () => {
|
||||
const email = buildBanNotificationEmail({
|
||||
handle: "publish-loop",
|
||||
source: "manual",
|
||||
reason: "rate limit triggered by automated CLI publishing",
|
||||
});
|
||||
|
||||
expect(email.context).toMatchObject({
|
||||
scannerLabel: null,
|
||||
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
|
||||
});
|
||||
expect(email.text).toContain("Publishing automation");
|
||||
expect(email.text).not.toContain("clawhub scan");
|
||||
expect(email.text).not.toContain("Include scan results");
|
||||
expect(email.html).not.toContain("Include scan results");
|
||||
expect(email.html).not.toContain("fixed local copy");
|
||||
});
|
||||
|
||||
it("builds restored-account copy that explains tokens stay revoked", () => {
|
||||
const email = buildRestoredAccountEmail({
|
||||
handle: "restored",
|
||||
restoredListings: [
|
||||
{ kind: "skill", name: "safe-one" },
|
||||
{ kind: "plugin", name: "@scope/demo" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("Your ClawHub account was restored");
|
||||
expect(email.text).toContain("Your ClawHub account can sign in again.");
|
||||
expect(email.text).toContain("Skill: safe-one");
|
||||
expect(email.text).toContain("Plugin: @scope/demo");
|
||||
expect(email.text).toContain("Previously revoked API tokens stay revoked.");
|
||||
});
|
||||
|
||||
it("builds malicious artifact copy without account appeal language", () => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: "publisher",
|
||||
artifact: { kind: "skill", name: "demo-skill" },
|
||||
version: "1.2.3",
|
||||
trigger: "malicious.llm_malicious",
|
||||
});
|
||||
|
||||
expect(email.subject).toBe("ClawHub blocked a skill version");
|
||||
expect(email.text).toContain("Skill: demo-skill");
|
||||
expect(email.text).toContain("Version: 1.2.3");
|
||||
expect(email.text).toContain("clawhub scan download demo-skill --version 1.2.3");
|
||||
expect(email.text).toContain("Increment the version number before uploading the fixed skill.");
|
||||
expect(email.text).toContain("https://docs.openclaw.ai/clawhub/moderation");
|
||||
expect(email.text).not.toContain("clawhub scan ./my-skill --output clawhub-scan.zip");
|
||||
expect(email.text).not.toContain("fixed local copy");
|
||||
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.html).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.text).not.toContain(APPEALS_URL);
|
||||
expect(email.html).not.toContain(APPEALS_URL);
|
||||
expect(email.html).not.toContain("appeal this decision");
|
||||
});
|
||||
|
||||
it("builds plugin scan download copy with an explicit artifact kind", () => {
|
||||
const email = buildMaliciousArtifactEmail({
|
||||
handle: "publisher",
|
||||
artifact: { kind: "plugin", name: "@scope/demo" },
|
||||
version: "2.0.0",
|
||||
trigger: "malicious.static",
|
||||
});
|
||||
|
||||
expect(email.text).toContain("Plugin: @scope/demo");
|
||||
expect(email.text).toContain("clawhub scan download @scope/demo --version 2.0.0 --kind plugin");
|
||||
expect(email.text).toContain("Increment the version number before uploading the fixed plugin.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
export const APPEALS_URL = "https://appeals.openclaw.ai/";
|
||||
export const MODERATION_GUIDELINES_URL = "https://docs.openclaw.ai/clawhub/moderation";
|
||||
export const MALICIOUS_REJECTION_ACCOUNT_WARNING =
|
||||
"Repeated malicious rejections may lead to account disablement.";
|
||||
|
||||
export type NotificationArtifact = {
|
||||
kind: "skill" | "plugin";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type BanNotificationSource = "manual" | "autoban";
|
||||
|
||||
export type BanNotificationEmailArgs = {
|
||||
handle?: string;
|
||||
source: BanNotificationSource;
|
||||
reason?: string;
|
||||
trigger?: string;
|
||||
artifact?: NotificationArtifact;
|
||||
};
|
||||
|
||||
export type BanNotificationEmailContext = {
|
||||
appealUrl: typeof APPEALS_URL;
|
||||
artifact: NotificationArtifact | null;
|
||||
scannerLabel: string | null;
|
||||
findingSummary: string;
|
||||
};
|
||||
|
||||
export type TransactionalEmail = {
|
||||
subject: string;
|
||||
context: BanNotificationEmailContext;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type RestoredAccountEmailArgs = {
|
||||
handle?: string;
|
||||
restoredListings?: NotificationArtifact[];
|
||||
};
|
||||
|
||||
export type MaliciousArtifactEmailArgs = {
|
||||
handle?: string;
|
||||
artifact: NotificationArtifact;
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
};
|
||||
|
||||
type BanReasonSummary = {
|
||||
scannerLabel: string | null;
|
||||
findingSummary: string;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeReasonInput(args: Pick<BanNotificationEmailArgs, "reason" | "trigger">) {
|
||||
return `${args.reason ?? ""} ${args.trigger ?? ""}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function summarizeBanReason(args: BanNotificationEmailArgs): BanReasonSummary {
|
||||
const normalized = normalizeReasonInput(args);
|
||||
|
||||
if (args.source === "autoban") {
|
||||
if (normalized.includes("virustotal") || normalized.includes("virus_total")) {
|
||||
return {
|
||||
scannerLabel: "VirusTotal",
|
||||
findingSummary: "VirusTotal telemetry contributed to a malicious upload finding.",
|
||||
};
|
||||
}
|
||||
if (normalized.includes("static")) {
|
||||
return {
|
||||
scannerLabel: "Static analysis",
|
||||
findingSummary: "Static analysis flagged malicious upload patterns.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes("clawscan") ||
|
||||
normalized.includes("llm") ||
|
||||
normalized.includes("malicious")
|
||||
) {
|
||||
return {
|
||||
scannerLabel: "ClawScan",
|
||||
findingSummary: "ClawScan classified the uploaded skill as malicious.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
scannerLabel: "ClawHub security checks",
|
||||
findingSummary: "ClawHub security checks classified the uploaded skill as malicious.",
|
||||
};
|
||||
}
|
||||
|
||||
if (/rate[-\s]?limit|publishing automation|automated(?: cli)? publishing/.test(normalized)) {
|
||||
return {
|
||||
scannerLabel: null,
|
||||
findingSummary: "Publishing automation triggered ClawHub rate-limit abuse controls.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scannerLabel: null,
|
||||
findingSummary: "ClawHub staff disabled the account after a security review.",
|
||||
};
|
||||
}
|
||||
|
||||
function artifactLabel(artifact: NotificationArtifact) {
|
||||
return `${artifact.kind === "skill" ? "Skill" : "Plugin"}: ${artifact.name}`;
|
||||
}
|
||||
|
||||
function greeting(handle: string | undefined) {
|
||||
return handle?.trim() ? `Hi ${handle.trim()},` : "Hi,";
|
||||
}
|
||||
|
||||
function emailShell(args: { preheader: string; title: string; body: string }) {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>${escapeHtml(args.title)}</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#ffffff;color:#1f2328;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(
|
||||
args.preheader,
|
||||
)}</span>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#ffffff;margin:0;padding:24px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;">
|
||||
<tr>
|
||||
<td style="padding:0;font-size:15px;line-height:22px;color:#1f2328;">
|
||||
${args.body}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function textLink(href: string, label: string) {
|
||||
return `<a href="${escapeHtml(href)}" style="color:#0969da;text-decoration:underline;">${escapeHtml(label)}</a>`;
|
||||
}
|
||||
|
||||
function detailLine(label: string, value: string) {
|
||||
return `<p style="margin:0 0 6px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</p>`;
|
||||
}
|
||||
|
||||
function sectionHeading(label: string) {
|
||||
return `<p style="margin:18px 0 8px;font-size:15px;line-height:22px;color:#1f2328;"><strong>${escapeHtml(label)}</strong></p>`;
|
||||
}
|
||||
|
||||
function paragraph(value: string) {
|
||||
return `<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">${escapeHtml(value)}</p>`;
|
||||
}
|
||||
|
||||
function bulletList(items: string[]) {
|
||||
return `<ul style="margin:0 0 14px;padding-left:22px;font-size:15px;line-height:22px;color:#1f2328;">${items
|
||||
.map((item) => `<li style="margin:0 0 6px;">${escapeHtml(item)}</li>`)
|
||||
.join("")}</ul>`;
|
||||
}
|
||||
|
||||
function commandBlock(command: string) {
|
||||
return `<pre style="margin:8px 0 14px;padding:10px 12px;background:#f6f8fa;border:1px solid #d8dee4;border-radius:6px;white-space:pre-wrap;color:#1f2328;font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',monospace;font-size:13px;line-height:20px;"><code>${escapeHtml(command)}</code></pre>`;
|
||||
}
|
||||
|
||||
function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) {
|
||||
const version = args.version?.trim() || "<version>";
|
||||
const kindFlag = args.artifact.kind === "plugin" ? " --kind plugin" : "";
|
||||
return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`;
|
||||
}
|
||||
|
||||
export function buildBanNotificationEmail(args: BanNotificationEmailArgs): TransactionalEmail {
|
||||
const summary = summarizeBanReason(args);
|
||||
const artifact = args.artifact ?? null;
|
||||
const context: BanNotificationEmailContext = {
|
||||
appealUrl: APPEALS_URL,
|
||||
artifact,
|
||||
scannerLabel: summary.scannerLabel,
|
||||
findingSummary: summary.findingSummary,
|
||||
};
|
||||
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
"Your ClawHub account was disabled.",
|
||||
`Reason: ${context.findingSummary}`,
|
||||
];
|
||||
if (artifact) lines.push(artifactLabel(artifact));
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"What changed:",
|
||||
"- Your ClawHub account cannot sign in.",
|
||||
"- Existing API tokens for the account have been revoked.",
|
||||
"- Published listings owned by the account may be hidden from public view.",
|
||||
"",
|
||||
`Appeal: ${APPEALS_URL}`,
|
||||
);
|
||||
|
||||
lines.push("", "ClawHub Security");
|
||||
|
||||
const impactItems = [
|
||||
"Your ClawHub account cannot sign in.",
|
||||
"Existing API tokens for the account have been revoked.",
|
||||
"Published listings owned by the account may be hidden from public view.",
|
||||
];
|
||||
const detailLines = [
|
||||
detailLine("Reason", context.findingSummary),
|
||||
...(artifact
|
||||
? [detailLine(artifact.kind === "skill" ? "Skill" : "Plugin", artifact.name)]
|
||||
: []),
|
||||
].join("");
|
||||
|
||||
const html = emailShell({
|
||||
title: "Your ClawHub account was disabled",
|
||||
preheader: context.findingSummary,
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph("We disabled your ClawHub account after an account-safety review."),
|
||||
detailLines,
|
||||
sectionHeading("What changed"),
|
||||
bulletList(impactItems),
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">You can ${textLink(APPEALS_URL, "appeal this decision")} if you believe this was a mistake.</p>`,
|
||||
`<p style="margin:18px 0 0;color:#6a737d;font-size:13px;line-height:20px;">If you already appealed, you do not need to send a separate support email.</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject: "Your ClawHub account was disabled",
|
||||
context,
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRestoredAccountEmail(args: RestoredAccountEmailArgs) {
|
||||
const restoredListings = args.restoredListings ?? [];
|
||||
const listingLines = restoredListings.map(artifactLabel);
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
"Your ClawHub account can sign in again.",
|
||||
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
|
||||
];
|
||||
if (listingLines.length > 0) {
|
||||
lines.push("", "Restored listings:", ...listingLines);
|
||||
}
|
||||
lines.push("", "ClawHub Security");
|
||||
|
||||
const html = emailShell({
|
||||
title: "Your ClawHub account was restored",
|
||||
preheader: "Your ClawHub account can sign in again.",
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph("Your ClawHub account can sign in again."),
|
||||
paragraph(
|
||||
"Previously revoked API tokens stay revoked. Create a new token before using the CLI or API again.",
|
||||
),
|
||||
listingLines.length > 0
|
||||
? `${sectionHeading("Restored listings")}${bulletList(listingLines)}`
|
||||
: "",
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Settings: ${textLink("https://clawhub.ai/settings", "open ClawHub settings")}</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject: "Your ClawHub account was restored",
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailArgs) {
|
||||
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
|
||||
const artifactLabelText = artifactLabel(args.artifact);
|
||||
const scanDownloadCommand = buildScanDownloadCommand(args);
|
||||
const findingSummary =
|
||||
args.trigger?.includes("static") === true
|
||||
? "Static analysis flagged malicious upload patterns."
|
||||
: args.trigger?.includes("virustotal") === true || args.trigger?.includes("vt_") === true
|
||||
? "VirusTotal telemetry contributed to a malicious upload finding."
|
||||
: "ClawScan classified the uploaded artifact as malicious.";
|
||||
const subject = `ClawHub blocked a ${artifactKind} version`;
|
||||
|
||||
const lines = [
|
||||
greeting(args.handle),
|
||||
"",
|
||||
`ClawHub blocked a ${artifactKind} version after a security scan.`,
|
||||
`Reason: ${findingSummary}`,
|
||||
artifactLabelText,
|
||||
];
|
||||
if (args.version?.trim()) lines.push(`Version: ${args.version.trim()}`);
|
||||
lines.push(
|
||||
"",
|
||||
"What changed:",
|
||||
"- This version was not made public.",
|
||||
"- Your account can still sign in.",
|
||||
`- You can upload a fixed version of this ${artifactKind}.`,
|
||||
`- ${MALICIOUS_REJECTION_ACCOUNT_WARNING}`,
|
||||
"",
|
||||
"Download the scan results for the blocked submitted version:",
|
||||
scanDownloadCommand,
|
||||
`Docs: ${MODERATION_GUIDELINES_URL}`,
|
||||
`Increment the version number before uploading the fixed ${artifactKind}.`,
|
||||
"",
|
||||
"ClawHub Security",
|
||||
);
|
||||
|
||||
const detailLines = [
|
||||
detailLine("Reason", findingSummary),
|
||||
detailLine(args.artifact.kind === "skill" ? "Skill" : "Plugin", args.artifact.name),
|
||||
...(args.version?.trim() ? [detailLine("Version", args.version.trim())] : []),
|
||||
].join("");
|
||||
|
||||
const html = emailShell({
|
||||
title: subject,
|
||||
preheader: `${artifactLabelText} was blocked by ClawHub security scans.`,
|
||||
body: [
|
||||
paragraph(greeting(args.handle)),
|
||||
paragraph(`ClawHub blocked a ${artifactKind} version after a security scan.`),
|
||||
detailLines,
|
||||
sectionHeading("What changed"),
|
||||
bulletList([
|
||||
"This version was not made public.",
|
||||
"Your account can still sign in.",
|
||||
`You can upload a fixed version of this ${artifactKind}.`,
|
||||
MALICIOUS_REJECTION_ACCOUNT_WARNING,
|
||||
]),
|
||||
sectionHeading("Review the blocked-version scan results"),
|
||||
paragraph("Download the scan results for the blocked submitted version."),
|
||||
commandBlock(scanDownloadCommand),
|
||||
paragraph(`Increment the version number before uploading the fixed ${artifactKind}.`),
|
||||
`<p style="margin:0 0 14px;font-size:15px;line-height:22px;color:#1f2328;">Docs: ${textLink(MODERATION_GUIDELINES_URL, "moderation and account safety")}</p>`,
|
||||
paragraph("ClawHub Security"),
|
||||
].join(""),
|
||||
});
|
||||
|
||||
return {
|
||||
subject,
|
||||
text: lines.join("\n"),
|
||||
html,
|
||||
};
|
||||
}
|
||||
@@ -91,12 +91,7 @@ export function parseGitHubImportUrl(input: string): GitHubImportUrl {
|
||||
}
|
||||
|
||||
function canonicalGitHubImportUrl(url: URL) {
|
||||
const canonical = new URL(url.toString());
|
||||
canonical.username = "";
|
||||
canonical.password = "";
|
||||
canonical.search = "";
|
||||
canonical.hash = "";
|
||||
return `${canonical.origin}${canonical.pathname}`;
|
||||
return `https://${url.hostname}${url.pathname}`;
|
||||
}
|
||||
|
||||
export async function resolveGitHubCommit(
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildGitHubSkillSourceSnapshot,
|
||||
buildGitHubSkillSyncPlan,
|
||||
parseSkillsShDisplayManifest,
|
||||
} from "./githubSkillSync";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function bytes(text: string) {
|
||||
return encoder.encode(text);
|
||||
}
|
||||
|
||||
function repoEntries(entries: Record<string, string>) {
|
||||
return Object.fromEntries(Object.entries(entries).map(([path, text]) => [path, bytes(text)]));
|
||||
}
|
||||
|
||||
describe("parseSkillsShDisplayManifest", () => {
|
||||
it("keeps the supported skills.sh rendering fields and drops invalid groups", () => {
|
||||
const result = parseSkillsShDisplayManifest(
|
||||
JSON.stringify({
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic workflows.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
{ title: "Broken", skills: [123] },
|
||||
{ description: "Missing title", skills: ["ignored"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
manifest: {
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic workflows.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks missing and invalid manifests so the UI can fall back", () => {
|
||||
expect(parseSkillsShDisplayManifest(undefined)).toEqual({
|
||||
status: "missing",
|
||||
manifest: undefined,
|
||||
});
|
||||
expect(parseSkillsShDisplayManifest("{nope")).toEqual({
|
||||
status: "invalid",
|
||||
manifest: undefined,
|
||||
});
|
||||
expect(parseSkillsShDisplayManifest(JSON.stringify({ groupings: [] }))).toEqual({
|
||||
status: "invalid",
|
||||
manifest: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubSkillSourceSnapshot", () => {
|
||||
it("discovers skill folders, parses SKILL.md metadata, and hashes exact folder bytes", async () => {
|
||||
const baseEntries = repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md":
|
||||
"---\nname: AIQ Deploy\nversion: 0.2.0\ndescription: Deploy AgentIQ workflows.\n---\n# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/skill-card.md": "# Card\n",
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
});
|
||||
const changedEntries = {
|
||||
...baseEntries,
|
||||
"skills/aiq-deploy/skill-card.md": bytes("# Card changed\n"),
|
||||
};
|
||||
|
||||
const base = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: baseEntries,
|
||||
});
|
||||
const changed = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: changedEntries,
|
||||
});
|
||||
|
||||
expect(base.manifestStatus).toBe("ok");
|
||||
expect(base.manifest).toEqual({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
});
|
||||
expect(base.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
upstreamVersion: "0.2.0",
|
||||
path: "skills/aiq-deploy",
|
||||
skillMarkdownPath: "skills/aiq-deploy/SKILL.md",
|
||||
skillMarkdown:
|
||||
"---\nname: AIQ Deploy\nversion: 0.2.0\ndescription: Deploy AgentIQ workflows.\n---\n# AIQ Deploy\n",
|
||||
skillCardMarkdownPath: "skills/aiq-deploy/skill-card.md",
|
||||
skillCardMarkdown: "# Card\n",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "vision-helper",
|
||||
displayName: "Vision Helper",
|
||||
path: "skills/vision-helper",
|
||||
skillMarkdownPath: "skills/vision-helper/SKILL.md",
|
||||
skillMarkdown: "# Vision Helper\n",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(changed.skills.find((skill) => skill.slug === "aiq-deploy")?.contentHash).not.toBe(
|
||||
base.skills.find((skill) => skill.slug === "aiq-deploy")?.contentHash,
|
||||
);
|
||||
expect(changed.skills.find((skill) => skill.slug === "vision-helper")?.contentHash).toBe(
|
||||
base.skills.find((skill) => skill.slug === "vision-helper")?.contentHash,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes valid filenames containing dot-dot text in folder hashes", async () => {
|
||||
const base = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/payload..sh": "echo safe\n",
|
||||
}),
|
||||
});
|
||||
const changed = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/payload..sh": "echo changed\n",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(changed.skills[0]?.contentHash).not.toBe(base.skills[0]?.contentHash);
|
||||
});
|
||||
|
||||
it("rejects duplicate normalized skill slugs before syncing content", async () => {
|
||||
await expect(
|
||||
buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq_deploy/SKILL.md": "# AIQ Deploy A\n",
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy B\n",
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(/duplicate normalized slug/i);
|
||||
});
|
||||
|
||||
it("prefers the top-level skills catalog folder over duplicate plugin copies", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"plugins/nvidia-skills/skills/aiq-deploy/SKILL.md": "# Plugin Copy\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(snapshot.skills.map((skill) => skill.path)).toEqual(["skills/aiq-deploy"]);
|
||||
expect(snapshot.skills[0]?.displayName).toBe("AIQ Deploy");
|
||||
});
|
||||
|
||||
it("rejects oversized cached markdown before writing Convex content docs", async () => {
|
||||
await expect(
|
||||
buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": `# AIQ Deploy\n${"x".repeat(513 * 1024)}`,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(/too large to cache/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubSkillSyncPlan", () => {
|
||||
it("marks changed upstream content pending", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy v2\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: "old-hash",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches).toEqual([
|
||||
expect.objectContaining({
|
||||
skillId: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
patch: expect.objectContaining({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: snapshot.skills[0]?.contentHash,
|
||||
githubScanStatus: "pending",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(plan.skillInserts).toEqual([]);
|
||||
expect(plan.stats.changed).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps clean scan status when only the repo commit changes", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "---\nversion: 0.2.0\n---\n# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
latestVersionSummary: {
|
||||
version: "0.2.0",
|
||||
createdAt: 7,
|
||||
},
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "clean",
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("latestVersionSummary");
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("updates existing skill ownership when a source is reassigned", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:new-owner",
|
||||
ownerPublisherId: "publishers:new-owner",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: snapshot.skills[0]?.contentHash ?? "",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
ownerUserId: "users:new-owner",
|
||||
ownerPublisherId: "publishers:new-owner",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves pending scan status for unchanged pending content", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "3".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "pending",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
});
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves terminal scan status for unchanged current content", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves terminal scan status for unchanged current bytes", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("revives soft-deleted skills when a configured repo is synced again", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "mattpocock/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "4".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/engineering/tdd/SKILL.md": "# TDD\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:matt",
|
||||
ownerUserId: "users:matt",
|
||||
ownerPublisherId: "publishers:matt",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:tdd",
|
||||
slug: "tdd",
|
||||
displayName: "TDD",
|
||||
githubPath: "skills/engineering/tdd",
|
||||
githubCurrentStatus: "missing",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentStatus: "present",
|
||||
githubRemovedAt: undefined,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("tombstones upstream removals instead of leaving stale installs active", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches).toEqual([
|
||||
expect.objectContaining({
|
||||
skillId: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
patch: expect.objectContaining({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: 123,
|
||||
softDeletedAt: 123,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(plan.skillInserts).toHaveLength(1);
|
||||
expect(plan.stats.removed).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves first upstream removal time on later syncs", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: 77,
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "3".repeat(40),
|
||||
githubCurrentStatus: "missing",
|
||||
githubCurrentCheckedAt: 123,
|
||||
githubRemovedAt: 77,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,671 @@
|
||||
import { getFrontmatterValue, parseFrontmatter } from "./skills";
|
||||
|
||||
export type GitHubSkillScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "failed";
|
||||
export type GitHubCurrentStatus = "present" | "missing" | "unknown";
|
||||
export type DisplayManifestStatus = "ok" | "missing" | "invalid" | "failed";
|
||||
|
||||
export type DisplayManifest = {
|
||||
notGrouped?: "top" | "bottom";
|
||||
groupings: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
skills: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GitHubSkillSourceSnapshot = {
|
||||
repo: string;
|
||||
defaultBranch: string;
|
||||
commit: string;
|
||||
manifestStatus: DisplayManifestStatus;
|
||||
manifestHash?: string;
|
||||
manifest?: DisplayManifest;
|
||||
skills: DiscoveredGitHubSkill[];
|
||||
};
|
||||
|
||||
export type GitHubSkillSourceMetadataSnapshot = Omit<GitHubSkillSourceSnapshot, "skills"> & {
|
||||
skills: DiscoveredGitHubSkillMetadata[];
|
||||
};
|
||||
|
||||
export type DiscoveredGitHubSkill = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
upstreamVersion?: string;
|
||||
path: string;
|
||||
skillMarkdownPath: string;
|
||||
skillMarkdown: string;
|
||||
skillCardMarkdownPath?: string;
|
||||
skillCardMarkdown?: string;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
export type DiscoveredGitHubSkillMetadata = Omit<
|
||||
DiscoveredGitHubSkill,
|
||||
"skillMarkdown" | "skillCardMarkdown"
|
||||
>;
|
||||
|
||||
export type ExistingGitHubSkillForSync = {
|
||||
_id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
latestVersionSummary?: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
};
|
||||
githubPath?: string;
|
||||
githubCurrentCommit?: string;
|
||||
githubCurrentContentHash?: string;
|
||||
githubCurrentStatus?: GitHubCurrentStatus;
|
||||
githubScanStatus?: GitHubSkillScanStatus;
|
||||
githubRemovedAt?: number;
|
||||
softDeletedAt?: number;
|
||||
};
|
||||
|
||||
export type GitHubBackedSkillModeration = {
|
||||
moderationStatus: "active" | "hidden";
|
||||
moderationReason?: string;
|
||||
moderationVerdict?: "clean" | "suspicious" | "malicious";
|
||||
moderationFlags: string[];
|
||||
isSuspicious: boolean;
|
||||
};
|
||||
|
||||
export type GitHubSkillPatchForSync = {
|
||||
skillId: string;
|
||||
slug: string;
|
||||
patch: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GitHubSkillInsertForSync = {
|
||||
slug: string;
|
||||
doc: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GitHubSkillSyncPlan = {
|
||||
sourcePatch: Record<string, unknown>;
|
||||
skillPatches: GitHubSkillPatchForSync[];
|
||||
skillInserts: GitHubSkillInsertForSync[];
|
||||
stats: {
|
||||
discovered: number;
|
||||
inserted: number;
|
||||
changed: number;
|
||||
unchanged: number;
|
||||
removed: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SKILL_MARKDOWN_BASENAME = "skill.md";
|
||||
const SKILL_CARD_MARKDOWN_BASENAME = "skill-card.md";
|
||||
const MAX_STORED_MARKDOWN_BYTES = 512 * 1024;
|
||||
const MAX_STORED_SKILL_CONTENT_BYTES = 768 * 1024;
|
||||
|
||||
export function parseSkillsShDisplayManifest(raw: string | undefined | null): {
|
||||
status: DisplayManifestStatus;
|
||||
manifest?: DisplayManifest;
|
||||
} {
|
||||
if (raw === undefined || raw === null) return { status: "missing", manifest: undefined };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { status: "invalid", manifest: undefined };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { status: "invalid", manifest: undefined };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const rawGroups = record.groupings;
|
||||
if (!Array.isArray(rawGroups)) return { status: "invalid", manifest: undefined };
|
||||
|
||||
const groupings = rawGroups.flatMap((group): DisplayManifest["groupings"] => {
|
||||
if (!group || typeof group !== "object" || Array.isArray(group)) return [];
|
||||
const groupRecord = group as Record<string, unknown>;
|
||||
const title = typeof groupRecord.title === "string" ? groupRecord.title.trim() : "";
|
||||
const description =
|
||||
typeof groupRecord.description === "string" ? groupRecord.description.trim() : "";
|
||||
const skills = Array.isArray(groupRecord.skills)
|
||||
? groupRecord.skills
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (!title || skills.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
skills,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (groupings.length === 0) return { status: "invalid", manifest: undefined };
|
||||
|
||||
const notGrouped =
|
||||
record.notGrouped === "top" || record.notGrouped === "bottom" ? record.notGrouped : undefined;
|
||||
return {
|
||||
status: "ok",
|
||||
manifest: {
|
||||
...(notGrouped ? { notGrouped } : {}),
|
||||
groupings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildGitHubSkillSourceSnapshot({
|
||||
repo,
|
||||
defaultBranch,
|
||||
commit,
|
||||
entries,
|
||||
}: {
|
||||
repo: string;
|
||||
defaultBranch: string;
|
||||
commit: string;
|
||||
entries: Record<string, Uint8Array>;
|
||||
}): Promise<GitHubSkillSourceSnapshot> {
|
||||
const normalizedEntries = normalizeEntryMap(entries);
|
||||
const manifestBytes = normalizedEntries["skills.sh.json"];
|
||||
const manifestText = manifestBytes ? decodeUtf8(manifestBytes) : undefined;
|
||||
const parsedManifest = parseSkillsShDisplayManifest(manifestText);
|
||||
const manifestHash = manifestBytes ? await sha256Hex(manifestBytes) : undefined;
|
||||
const skillPaths = discoverSkillPaths(normalizedEntries);
|
||||
const skills: DiscoveredGitHubSkill[] = [];
|
||||
|
||||
for (const skillMdPath of skillPaths) {
|
||||
const path = parentPath(skillMdPath);
|
||||
const markdownBytes = normalizedEntries[skillMdPath] ?? new Uint8Array();
|
||||
assertStoredMarkdownSize(skillMdPath, markdownBytes);
|
||||
const markdown = decodeUtf8(markdownBytes);
|
||||
const frontmatter = parseFrontmatter(markdown);
|
||||
const folderName = path.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const slug = slugFromPathSegment(folderName);
|
||||
if (!slug) continue;
|
||||
const frontmatterName = getFrontmatterValue(frontmatter, "name")?.trim();
|
||||
const frontmatterDescription = getFrontmatterValue(frontmatter, "description")?.trim();
|
||||
const frontmatterVersion = getFrontmatterValue(frontmatter, "version")?.trim();
|
||||
const heading = firstMarkdownHeading(markdown);
|
||||
const skillCardMarkdownPath = findFolderFilePath(
|
||||
normalizedEntries,
|
||||
path,
|
||||
SKILL_CARD_MARKDOWN_BASENAME,
|
||||
);
|
||||
const skillCardBytes = skillCardMarkdownPath
|
||||
? normalizedEntries[skillCardMarkdownPath]
|
||||
: undefined;
|
||||
if (skillCardMarkdownPath && skillCardBytes) {
|
||||
assertStoredMarkdownSize(skillCardMarkdownPath, skillCardBytes);
|
||||
assertStoredSkillContentSize(markdownBytes.byteLength + skillCardBytes.byteLength);
|
||||
} else {
|
||||
assertStoredSkillContentSize(markdownBytes.byteLength);
|
||||
}
|
||||
const skillCardMarkdown = skillCardBytes ? decodeUtf8(skillCardBytes) : undefined;
|
||||
|
||||
skills.push({
|
||||
slug,
|
||||
displayName: frontmatterName || heading || titleizeSlug(slug),
|
||||
...(frontmatterDescription ? { summary: frontmatterDescription } : {}),
|
||||
...(frontmatterVersion ? { upstreamVersion: frontmatterVersion } : {}),
|
||||
path,
|
||||
skillMarkdownPath: skillMdPath,
|
||||
skillMarkdown: markdown,
|
||||
...(skillCardMarkdownPath ? { skillCardMarkdownPath } : {}),
|
||||
...(skillCardMarkdown !== undefined ? { skillCardMarkdown } : {}),
|
||||
contentHash: await computeGitHubSkillFolderContentHash(normalizedEntries, path),
|
||||
});
|
||||
}
|
||||
|
||||
const sortedSkills = skills.sort((a, b) => a.path.localeCompare(b.path));
|
||||
assertUniqueDiscoveredSlugs(sortedSkills);
|
||||
|
||||
return {
|
||||
repo,
|
||||
defaultBranch,
|
||||
commit,
|
||||
manifestStatus: parsedManifest.status,
|
||||
...(manifestHash ? { manifestHash } : {}),
|
||||
...(parsedManifest.manifest ? { manifest: parsedManifest.manifest } : {}),
|
||||
skills: sortedSkills,
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeGitHubSkillFolderContentHash(
|
||||
entries: Record<string, Uint8Array>,
|
||||
folderPath: string,
|
||||
) {
|
||||
const normalizedEntries = normalizeEntryMap(entries);
|
||||
const root = folderPath ? `${folderPath}/` : "";
|
||||
const lines: string[] = [];
|
||||
for (const [path, content] of Object.entries(normalizedEntries).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (root && path !== folderPath && !path.startsWith(root)) continue;
|
||||
if (!root && path.includes("/")) continue;
|
||||
const relativePath = root ? path.slice(root.length) : path;
|
||||
if (!relativePath) continue;
|
||||
const fileHash = await sha256Hex(content);
|
||||
lines.push(`${relativePath}\0${content.byteLength}\0${fileHash}`);
|
||||
}
|
||||
return sha256Hex(new TextEncoder().encode(lines.join("\n")));
|
||||
}
|
||||
|
||||
export function buildGitHubSkillSyncPlan({
|
||||
sourceId,
|
||||
ownerUserId,
|
||||
ownerPublisherId,
|
||||
existingSkills,
|
||||
snapshot,
|
||||
now,
|
||||
}: {
|
||||
sourceId: string;
|
||||
ownerUserId: string;
|
||||
ownerPublisherId?: string;
|
||||
existingSkills: ExistingGitHubSkillForSync[];
|
||||
snapshot: GitHubSkillSourceSnapshot | GitHubSkillSourceMetadataSnapshot;
|
||||
now: number;
|
||||
}): GitHubSkillSyncPlan {
|
||||
const sourcePatch = {
|
||||
repo: snapshot.repo,
|
||||
defaultBranch: snapshot.defaultBranch,
|
||||
lastSyncStatus: "ok",
|
||||
lastSyncError: undefined,
|
||||
lastSyncErrorAt: undefined,
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: snapshot.manifestHash,
|
||||
displayManifestCommit: snapshot.commit,
|
||||
displayManifestFetchedAt: now,
|
||||
displayManifestStatus: snapshot.manifestStatus,
|
||||
displayManifest: snapshot.manifest,
|
||||
...(ownerPublisherId ? { ownerPublisherId } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
const existingByPath = new Map(
|
||||
existingSkills
|
||||
.filter((skill) => skill.githubPath)
|
||||
.map((skill) => [skill.githubPath as string, skill]),
|
||||
);
|
||||
const existingBySlug = new Map(existingSkills.map((skill) => [skill.slug, skill]));
|
||||
const matchedSkillIds = new Set<string>();
|
||||
const skillPatches: GitHubSkillPatchForSync[] = [];
|
||||
const skillInserts: GitHubSkillInsertForSync[] = [];
|
||||
const stats = {
|
||||
discovered: snapshot.skills.length,
|
||||
inserted: 0,
|
||||
changed: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
};
|
||||
|
||||
for (const discovered of snapshot.skills) {
|
||||
const existing = existingByPath.get(discovered.path) ?? existingBySlug.get(discovered.slug);
|
||||
if (!existing) {
|
||||
const scanStatus: GitHubSkillScanStatus = "pending";
|
||||
const moderation = githubBackedSkillModeration(scanStatus);
|
||||
skillInserts.push({
|
||||
slug: discovered.slug,
|
||||
doc: {
|
||||
slug: discovered.slug,
|
||||
displayName: discovered.displayName,
|
||||
summary: discovered.summary,
|
||||
ownerUserId,
|
||||
ownerPublisherId,
|
||||
installKind: "github",
|
||||
githubSourceId: sourceId,
|
||||
githubPath: discovered.path,
|
||||
githubHasSkillCard: Boolean(discovered.skillCardMarkdownPath),
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentContentHash: discovered.contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubScanStatus: scanStatus,
|
||||
githubRemovedAt: undefined,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: latestVersionSummary(discovered.upstreamVersion, now),
|
||||
tags: {},
|
||||
capabilityTags: [],
|
||||
softDeletedAt: undefined,
|
||||
badges: undefined,
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
...moderation,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
stats.inserted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedSkillIds.add(existing._id);
|
||||
const currentContentUnchanged =
|
||||
existing.githubCurrentStatus === "present" &&
|
||||
existing.githubCurrentContentHash === discovered.contentHash;
|
||||
const scanStatus: GitHubSkillScanStatus = currentContentUnchanged
|
||||
? githubScanStatusForUnchangedContent(existing.githubScanStatus)
|
||||
: "pending";
|
||||
const moderation = githubBackedSkillModeration(scanStatus);
|
||||
const nextLatestVersionSummary = latestVersionSummary(
|
||||
discovered.upstreamVersion,
|
||||
existing.latestVersionSummary?.createdAt ?? now,
|
||||
);
|
||||
const materialChanged =
|
||||
!currentContentUnchanged ||
|
||||
existing.displayName !== discovered.displayName ||
|
||||
(existing.summary ?? undefined) !== (discovered.summary ?? undefined) ||
|
||||
(existing.githubPath ?? undefined) !== discovered.path ||
|
||||
!sameLatestVersionSummary(existing.latestVersionSummary, nextLatestVersionSummary);
|
||||
const patch = {
|
||||
displayName: discovered.displayName,
|
||||
summary: discovered.summary,
|
||||
ownerUserId,
|
||||
...(ownerPublisherId ? { ownerPublisherId } : {}),
|
||||
githubSourceId: sourceId,
|
||||
githubPath: discovered.path,
|
||||
githubHasSkillCard: Boolean(discovered.skillCardMarkdownPath),
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentContentHash: discovered.contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubScanStatus: scanStatus,
|
||||
githubRemovedAt: undefined,
|
||||
softDeletedAt: undefined,
|
||||
...(materialChanged
|
||||
? {
|
||||
latestVersionSummary: latestVersionSummary(discovered.upstreamVersion, now),
|
||||
updatedAt: now,
|
||||
}
|
||||
: {}),
|
||||
...moderation,
|
||||
};
|
||||
skillPatches.push({ skillId: existing._id, slug: existing.slug, patch });
|
||||
if (materialChanged) stats.changed += 1;
|
||||
else stats.unchanged += 1;
|
||||
}
|
||||
|
||||
for (const existing of existingSkills) {
|
||||
if (matchedSkillIds.has(existing._id)) continue;
|
||||
const removedAt = existing.githubRemovedAt ?? now;
|
||||
const moderation = githubBackedSkillModeration(
|
||||
existing.githubScanStatus ?? "pending",
|
||||
removedAt,
|
||||
);
|
||||
const wasAlreadyRemoved =
|
||||
existing.githubCurrentStatus === "missing" && existing.githubRemovedAt !== undefined;
|
||||
skillPatches.push({
|
||||
skillId: existing._id,
|
||||
slug: existing.slug,
|
||||
patch: {
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentStatus: "missing",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubRemovedAt: removedAt,
|
||||
softDeletedAt: existing.softDeletedAt ?? removedAt,
|
||||
...(wasAlreadyRemoved ? {} : { updatedAt: now }),
|
||||
...moderation,
|
||||
},
|
||||
});
|
||||
stats.removed += 1;
|
||||
}
|
||||
|
||||
return { sourcePatch, skillPatches, skillInserts, stats };
|
||||
}
|
||||
|
||||
function githubScanStatusForUnchangedContent(
|
||||
status: GitHubSkillScanStatus | undefined,
|
||||
): GitHubSkillScanStatus {
|
||||
if (
|
||||
status === "clean" ||
|
||||
status === "failed" ||
|
||||
status === "malicious" ||
|
||||
status === "suspicious"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function githubBackedSkillModeration(
|
||||
scanStatus: GitHubSkillScanStatus,
|
||||
removedAt?: number,
|
||||
): GitHubBackedSkillModeration {
|
||||
if (typeof removedAt === "number") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "pending") {
|
||||
return {
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "failed") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.failed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "suspicious") {
|
||||
return {
|
||||
moderationStatus: "active",
|
||||
moderationReason: "scanner.llm.suspicious",
|
||||
moderationVerdict: "suspicious",
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
moderationStatus: "active",
|
||||
moderationReason: undefined,
|
||||
moderationVerdict: "clean",
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
|
||||
function latestVersionSummary(version: string | undefined, now: number) {
|
||||
if (!version) return undefined;
|
||||
return {
|
||||
version,
|
||||
createdAt: now,
|
||||
changelog: "Synced from GitHub source.",
|
||||
changelogSource: "auto" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function sameLatestVersionSummary(
|
||||
a: ExistingGitHubSkillForSync["latestVersionSummary"] | undefined,
|
||||
b: ReturnType<typeof latestVersionSummary>,
|
||||
) {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.version === b.version;
|
||||
}
|
||||
|
||||
function assertStoredMarkdownSize(path: string, bytes: Uint8Array) {
|
||||
if (bytes.byteLength > MAX_STORED_MARKDOWN_BYTES) {
|
||||
throw new Error(`GitHub skill markdown file is too large to cache: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStoredSkillContentSize(totalBytes: number) {
|
||||
if (totalBytes > MAX_STORED_SKILL_CONTENT_BYTES) {
|
||||
throw new Error("GitHub skill cached markdown is too large");
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueDiscoveredSlugs(skills: DiscoveredGitHubSkill[]) {
|
||||
const firstPathBySlug = new Map<string, string>();
|
||||
for (const skill of skills) {
|
||||
const firstPath = firstPathBySlug.get(skill.slug);
|
||||
if (firstPath) {
|
||||
throw duplicateSkillSlugError(skill.slug, firstPath, skill.path);
|
||||
}
|
||||
firstPathBySlug.set(skill.slug, skill.path);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEntryMap(entries: Record<string, Uint8Array>) {
|
||||
const out: Record<string, Uint8Array> = {};
|
||||
for (const [rawPath, bytes] of Object.entries(entries)) {
|
||||
const normalized = normalizeRepoPath(rawPath);
|
||||
if (!normalized) continue;
|
||||
out[normalized] = new Uint8Array(bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function discoverSkillPaths(entries: Record<string, Uint8Array>) {
|
||||
const candidates = Object.keys(entries)
|
||||
.filter((path) => path.split("/").at(-1)?.toLowerCase() === SKILL_MARKDOWN_BASENAME)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const pathsBySlug = new Map<string, string[]>();
|
||||
|
||||
for (const skillMdPath of candidates) {
|
||||
const path = parentPath(skillMdPath);
|
||||
const folderName = path.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const slug = slugFromPathSegment(folderName);
|
||||
if (!slug) continue;
|
||||
const paths = pathsBySlug.get(slug) ?? [];
|
||||
paths.push(skillMdPath);
|
||||
pathsBySlug.set(slug, paths);
|
||||
}
|
||||
|
||||
const selected: string[] = [];
|
||||
for (const [slug, paths] of pathsBySlug) {
|
||||
if (paths.length === 1) {
|
||||
selected.push(paths[0] as string);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalPath = `skills/${slug}/${SKILL_MARKDOWN_BASENAME}`;
|
||||
const exactTopLevelMatches = paths.filter((path) => path.toLowerCase() === canonicalPath);
|
||||
const topLevelSkillMatches = paths.filter((path) => path.toLowerCase().startsWith("skills/"));
|
||||
if (exactTopLevelMatches.length === 1 && topLevelSkillMatches.length === 1) {
|
||||
selected.push(exactTopLevelMatches[0] as string);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw duplicateSkillSlugError(
|
||||
slug,
|
||||
parentPath(paths[0] as string),
|
||||
parentPath(paths[1] as string),
|
||||
);
|
||||
}
|
||||
|
||||
return selected.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function duplicateSkillSlugError(slug: string, firstPath: string, secondPath: string) {
|
||||
return new Error(
|
||||
`GitHub skill source has duplicate normalized slug "${slug}" at ${firstPath} and ${secondPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function findFolderFilePath(
|
||||
entries: Record<string, Uint8Array>,
|
||||
folderPath: string,
|
||||
basename: string,
|
||||
) {
|
||||
const prefix = folderPath ? `${folderPath}/` : "";
|
||||
return Object.keys(entries).find((entryPath) => {
|
||||
if (prefix) {
|
||||
if (!entryPath.startsWith(prefix)) return false;
|
||||
const relativePath = entryPath.slice(prefix.length);
|
||||
return !relativePath.includes("/") && relativePath.toLowerCase() === basename;
|
||||
}
|
||||
return !entryPath.includes("/") && entryPath.toLowerCase() === basename;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRepoPath(path: string) {
|
||||
if (path.includes("\u0000")) return "";
|
||||
const normalized = path
|
||||
.replaceAll("\\", "/")
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/^\/+/, "");
|
||||
if (!normalized) return "";
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) return "";
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function parentPath(path: string) {
|
||||
return path.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const safe = new Uint8Array(bytes);
|
||||
const buffer = safe.buffer.slice(safe.byteOffset, safe.byteOffset + safe.byteLength);
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return toHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array) {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
function slugFromPathSegment(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function titleizeSlug(slug: string) {
|
||||
return slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function firstMarkdownHeading(markdown: string) {
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const match = /^#\s+(.+)$/.exec(line.trim());
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillInstallResolution } from "./installResolver";
|
||||
|
||||
const baseSkill = {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
latestVersionSummary: null,
|
||||
installKind: "github" as const,
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubCurrentStatus: "present" as const,
|
||||
githubScanStatus: "clean" as const,
|
||||
githubRemovedAt: undefined,
|
||||
};
|
||||
|
||||
const source = {
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
};
|
||||
|
||||
describe("buildSkillInstallResolution", () => {
|
||||
it("returns an archive descriptor for hosted direct uploads", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
slug: "direct-skill",
|
||||
displayName: "Direct Skill",
|
||||
latestVersionSummary: { version: "1.2.3" },
|
||||
},
|
||||
source: null,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({
|
||||
ok: true,
|
||||
slug: "direct-skill",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.2.3",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/download?slug=direct-skill&version=1.2.3",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a pinned GitHub descriptor when current upstream state is scan-clean", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: baseSkill,
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"1".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows GitHub-backed installs when upstream content changed and the current hash is clean", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"2".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows GitHub-backed installs when only unrelated repository content changed", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: baseSkill.githubCurrentContentHash,
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"2".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "upstream path is missing",
|
||||
patch: { githubCurrentStatus: "missing" as const },
|
||||
reason: "github_upstream_missing",
|
||||
status: 410,
|
||||
},
|
||||
{
|
||||
name: "skill was pulled upstream",
|
||||
patch: { githubRemovedAt: 456 },
|
||||
reason: "github_upstream_removed",
|
||||
status: 410,
|
||||
},
|
||||
{
|
||||
name: "scan is pending",
|
||||
patch: { githubScanStatus: "pending" as const },
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
},
|
||||
{
|
||||
name: "scan failed",
|
||||
patch: { githubScanStatus: "failed" as const },
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
},
|
||||
{
|
||||
name: "scan is suspicious",
|
||||
patch: { githubScanStatus: "suspicious" as const },
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
},
|
||||
])("blocks GitHub-backed installs when $name", ({ patch, reason, status }) => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: { ...baseSkill, ...patch },
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
slug: "aiq-deploy",
|
||||
reason,
|
||||
status,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains pending GitHub-backed verification clearly", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
slug: "aiq-deploy",
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
message:
|
||||
"GitHub-backed skill security scan is in progress. Try again shortly, or rerun with --force-install to install the unverified upstream commit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows force-install for pending GitHub-backed verification", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source,
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not force-install failed GitHub-backed scans", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
source,
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
export type GitHubSkillScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "failed";
|
||||
export type GitHubCurrentStatus = "present" | "missing" | "unknown";
|
||||
|
||||
export type InstallResolverSkill = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
latestVersionSummary?: { version: string } | null;
|
||||
installKind?: "github";
|
||||
githubPath?: string;
|
||||
githubCurrentCommit?: string;
|
||||
githubCurrentContentHash?: string;
|
||||
githubCurrentStatus?: GitHubCurrentStatus;
|
||||
githubScanStatus?: GitHubSkillScanStatus;
|
||||
githubRemovedAt?: number;
|
||||
};
|
||||
|
||||
export type InstallResolverSource = {
|
||||
repo: string;
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
export type SkillInstallResolution =
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "archive";
|
||||
archive: {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "github";
|
||||
github: {
|
||||
repo: string;
|
||||
path: string;
|
||||
commit: string;
|
||||
contentHash: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
slug: string;
|
||||
reason:
|
||||
| "archive_version_missing"
|
||||
| "github_source_missing"
|
||||
| "github_upstream_removed"
|
||||
| "github_upstream_missing"
|
||||
| "github_upstream_unknown"
|
||||
| "github_verification_pending"
|
||||
| "github_scan_failed";
|
||||
message: string;
|
||||
status: 403 | 409 | 410 | 423;
|
||||
};
|
||||
|
||||
export function buildSkillInstallResolution({
|
||||
origin,
|
||||
skill,
|
||||
source,
|
||||
forceInstall = false,
|
||||
}: {
|
||||
origin: string;
|
||||
skill: InstallResolverSkill;
|
||||
source: InstallResolverSource | null;
|
||||
forceInstall?: boolean;
|
||||
}): SkillInstallResolution {
|
||||
if (skill.installKind !== "github") {
|
||||
const version = skill.latestVersionSummary?.version;
|
||||
if (!version) {
|
||||
return block(skill.slug, "archive_version_missing", 409);
|
||||
}
|
||||
|
||||
const url = new URL("/api/v1/download", origin);
|
||||
url.searchParams.set("slug", skill.slug);
|
||||
url.searchParams.set("version", version);
|
||||
return {
|
||||
ok: true,
|
||||
slug: skill.slug,
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version,
|
||||
downloadUrl: url.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.githubRemovedAt) {
|
||||
return block(skill.slug, "github_upstream_removed", 410);
|
||||
}
|
||||
if (skill.githubCurrentStatus === "missing") {
|
||||
return block(skill.slug, "github_upstream_missing", 410);
|
||||
}
|
||||
if (
|
||||
skill.githubScanStatus === "failed" ||
|
||||
skill.githubScanStatus === "malicious" ||
|
||||
skill.githubScanStatus === "suspicious"
|
||||
) {
|
||||
return block(skill.slug, "github_scan_failed", 403);
|
||||
}
|
||||
if (!source || !skill.githubPath) {
|
||||
return block(skill.slug, "github_source_missing", 409);
|
||||
}
|
||||
if (
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
!skill.githubCurrentCommit ||
|
||||
!skill.githubCurrentContentHash
|
||||
) {
|
||||
return block(skill.slug, "github_upstream_unknown", 423);
|
||||
}
|
||||
if (
|
||||
skill.githubScanStatus !== "clean" &&
|
||||
!(forceInstall && skill.githubScanStatus === "pending")
|
||||
) {
|
||||
return block(skill.slug, "github_verification_pending", 423);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
slug: skill.slug,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: source.repo,
|
||||
path: skill.githubPath,
|
||||
commit: skill.githubCurrentCommit,
|
||||
contentHash: skill.githubCurrentContentHash,
|
||||
sourceUrl: buildGitHubTreeUrl(source.repo, skill.githubCurrentCommit, skill.githubPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function block(
|
||||
slug: string,
|
||||
reason: Extract<SkillInstallResolution, { ok: false }>["reason"],
|
||||
status: Extract<SkillInstallResolution, { ok: false }>["status"],
|
||||
): SkillInstallResolution {
|
||||
return {
|
||||
ok: false,
|
||||
slug,
|
||||
reason,
|
||||
status,
|
||||
message: INSTALL_BLOCK_MESSAGES[reason],
|
||||
};
|
||||
}
|
||||
|
||||
const INSTALL_BLOCK_MESSAGES: Record<
|
||||
Extract<SkillInstallResolution, { ok: false }>["reason"],
|
||||
string
|
||||
> = {
|
||||
archive_version_missing: "Hosted skill has no downloadable version.",
|
||||
github_source_missing: "GitHub-backed skill source metadata is incomplete.",
|
||||
github_upstream_removed: "GitHub-backed skill has been removed upstream.",
|
||||
github_upstream_missing: "GitHub-backed skill path is missing upstream.",
|
||||
github_upstream_unknown: "GitHub-backed skill needs an upstream freshness check before install.",
|
||||
github_verification_pending:
|
||||
"GitHub-backed skill security scan is in progress. Try again shortly, or rerun with --force-install to install the unverified upstream commit.",
|
||||
github_scan_failed: "GitHub-backed skill failed ClawHub security scanning.",
|
||||
};
|
||||
|
||||
function buildGitHubTreeUrl(repo: string, commit: string, path: string) {
|
||||
return `https://github.com/${encodeURIComponentRepo(repo)}/tree/${commit}/${encodeURIComponentPath(
|
||||
path,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function encodeURIComponentRepo(repo: string) {
|
||||
return repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function encodeURIComponentPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const Events = {
|
||||
GitHubSkillSourceSyncStarted: "github_skill_source_sync.started",
|
||||
GitHubSkillSourceSyncCompleted: "github_skill_source_sync.completed",
|
||||
GitHubSkillSourceSyncSourceFailed: "github_skill_source_sync.source_failed",
|
||||
GitHubSkillSourceSyncFailed: "github_skill_source_sync.failed",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
type EventPayload = Record<string, unknown>;
|
||||
|
||||
export function logEvent(event: EventName, payload: EventPayload = {}) {
|
||||
console.log(JSON.stringify({ event, ...payload }));
|
||||
}
|
||||
|
||||
export function logErrorEvent(event: EventName, payload: EventPayload = {}) {
|
||||
console.error(JSON.stringify({ event, ...payload }));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
import { isOfficialPublisher } from "./officialPublishers";
|
||||
import { hasOfficialPublisherRow, isOfficialPublisher } from "./officialPublishers";
|
||||
|
||||
function makePublisher(
|
||||
overrides: Partial<Record<keyof Doc<"publishers">, unknown>>,
|
||||
@@ -17,60 +17,105 @@ function makePublisher(
|
||||
} as Doc<"publishers">;
|
||||
}
|
||||
|
||||
function makeOfficialRow(publisherId: string) {
|
||||
return {
|
||||
_id: `officialPublishers:${publisherId}`,
|
||||
_creationTime: 1,
|
||||
publisherId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx({ officialPublisherIds = [] }: { officialPublisherIds?: string[] } = {}) {
|
||||
return {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "officialPublishers") {
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
let requestedPublisherId: string | undefined;
|
||||
buildQuery({
|
||||
eq: vi.fn((field: string, value: string) => {
|
||||
if (field === "publisherId") requestedPublisherId = value;
|
||||
return {};
|
||||
}),
|
||||
});
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
requestedPublisherId && officialPublisherIds.includes(requestedPublisherId)
|
||||
? makeOfficialRow(requestedPublisherId)
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isOfficialPublisher", () => {
|
||||
it("treats the openclaw org publisher as official", async () => {
|
||||
const ctx = { db: { query: vi.fn() } };
|
||||
it("treats a publisher with an official row as official", async () => {
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:acme"] });
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "openclaw" })),
|
||||
isOfficialPublisher(ctx as never, makePublisher({ _id: "publishers:acme", handle: "acme" })),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats the nvidia org publisher as official", async () => {
|
||||
const ctx = { db: { query: vi.fn() } };
|
||||
it("treats a personal publisher with an official row as official", async () => {
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:alice"] });
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "nvidia" })),
|
||||
isOfficialPublisher(
|
||||
ctx as never,
|
||||
makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats personal publishers for openclaw org members as official", async () => {
|
||||
const openclaw = makePublisher({ _id: "publishers:openclaw", handle: "openclaw" });
|
||||
it("does not treat legacy official handles as official without a row", async () => {
|
||||
const ctx = makeCtx();
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(
|
||||
ctx as never,
|
||||
makePublisher({ _id: "publishers:openclaw", handle: "openclaw" }),
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not inherit official status from org membership", async () => {
|
||||
const personal = makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => openclaw),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => ({
|
||||
_id: "publisherMembers:alice",
|
||||
publisherId: "publishers:openclaw",
|
||||
userId: "users:alice",
|
||||
role: "publisher",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:openclaw"] });
|
||||
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(true);
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("can check raw official rows independently from active publisher state", async () => {
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:acme"] });
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(
|
||||
ctx as never,
|
||||
makePublisher({ _id: "publishers:acme", handle: "acme", deactivatedAt: 123 }),
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(hasOfficialPublisherRow(ctx as never, "publishers:acme" as never)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,28 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import { toPublicPublisher, type PublicPublisher } from "./public";
|
||||
import {
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
normalizePublisherHandle,
|
||||
} from "./publishers";
|
||||
|
||||
const OFFICIAL_ORG_HANDLES = ["openclaw", "nvidia"] as const;
|
||||
const OFFICIAL_ORG_HANDLE_SET = new Set<string>(OFFICIAL_ORG_HANDLES);
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
type OfficialPublisherCandidate = Pick<
|
||||
Doc<"publishers">,
|
||||
| "_id"
|
||||
| "_creationTime"
|
||||
| "kind"
|
||||
| "handle"
|
||||
| "displayName"
|
||||
| "image"
|
||||
| "bio"
|
||||
| "linkedUserId"
|
||||
| "deletedAt"
|
||||
| "deactivatedAt"
|
||||
>;
|
||||
type OfficialPublisherCandidate = Pick<Doc<"publishers">, "_id" | "deletedAt" | "deactivatedAt">;
|
||||
|
||||
export async function isOfficialPublisher(
|
||||
ctx: DbCtx,
|
||||
publisher: OfficialPublisherCandidate | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return false;
|
||||
if (publisher.kind === "org") {
|
||||
const handle = normalizePublisherHandle(publisher.handle);
|
||||
return Boolean(handle && OFFICIAL_ORG_HANDLE_SET.has(handle));
|
||||
}
|
||||
if (!publisher.linkedUserId) return false;
|
||||
return await hasOfficialPublisherRow(ctx, publisher._id);
|
||||
}
|
||||
|
||||
for (const officialOrgHandle of OFFICIAL_ORG_HANDLES) {
|
||||
const officialOrg = await getPublisherByHandle(ctx, officialOrgHandle);
|
||||
if (!officialOrg || officialOrg.deletedAt || officialOrg.deactivatedAt) continue;
|
||||
|
||||
const membership = await getPublisherMembership(ctx, officialOrg._id, publisher.linkedUserId);
|
||||
if (membership) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
export async function hasOfficialPublisherRow(
|
||||
ctx: DbCtx,
|
||||
publisherId: Doc<"publishers">["_id"],
|
||||
): Promise<boolean> {
|
||||
const officialPublisher = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.unique();
|
||||
return Boolean(officialPublisher);
|
||||
}
|
||||
|
||||
export async function toPublicPublisherWithOfficial(
|
||||
|
||||
@@ -20,6 +20,7 @@ const SHARED_KEYS = [
|
||||
"summary",
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"stats",
|
||||
"runtimeId",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
@@ -45,6 +46,7 @@ const CAPABILITY_SHARED_KEYS = [
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"verificationTier",
|
||||
"stats",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
@@ -70,6 +72,7 @@ const PLUGIN_CATEGORY_SHARED_KEYS = [
|
||||
"pluginCategoryTags",
|
||||
"executesCode",
|
||||
"verificationTier",
|
||||
"stats",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
|
||||
@@ -66,6 +66,22 @@ describe("public skill mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes GitHub-backed skill source fields", () => {
|
||||
const mapped = toPublicSkill(
|
||||
makeSkill({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns skill when moderationStatus is active", () => {
|
||||
const skill = makeSkill({ moderationStatus: "active" });
|
||||
expect(toPublicSkill(skill)).not.toBeNull();
|
||||
|
||||
+21
-2
@@ -24,6 +24,12 @@ export type PublicSkill = Pick<
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
| "installKind"
|
||||
| "githubPath"
|
||||
| "githubCurrentCommit"
|
||||
| "githubCurrentStatus"
|
||||
| "githubScanStatus"
|
||||
| "githubHasSkillCard"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
| "badges"
|
||||
@@ -31,7 +37,9 @@ export type PublicSkill = Pick<
|
||||
| "isSuspicious"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>;
|
||||
> & {
|
||||
githubSourceRepo?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimum set of fields needed by `hydrateResults` to filter and convert
|
||||
@@ -52,6 +60,10 @@ export type HydratableSkill = Pick<
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
| "installKind"
|
||||
| "githubHasSkillCard"
|
||||
| "githubCurrentStatus"
|
||||
| "githubScanStatus"
|
||||
| "latestVersionSummary"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
@@ -68,7 +80,8 @@ export type HydratableSkill = Pick<
|
||||
| "isSuspicious"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<Doc<"skills">, "githubPath" | "githubCurrentCommit">>;
|
||||
|
||||
export type PublicSoul = Pick<
|
||||
Doc<"souls">,
|
||||
@@ -149,6 +162,12 @@ export function toPublicSkill(skill: HydratableSkill | null | undefined): Public
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
installKind: skill.installKind,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentCommit: skill.githubCurrentCommit,
|
||||
githubCurrentStatus: skill.githubCurrentStatus,
|
||||
githubScanStatus: skill.githubScanStatus,
|
||||
githubHasSkillCard: skill.githubHasSkillCard,
|
||||
tags: skill.tags,
|
||||
capabilityTags: skill.capabilityTags,
|
||||
badges: skill.badges,
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeCurrentSkillTemporalAbuseScore,
|
||||
computeHistoricalSkillTemporalAbuseScore,
|
||||
computePublisherAbuseRawScore,
|
||||
computeTemporalAbuseCohortBenchmark,
|
||||
computeTemporalPublisherAbuseZScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
labelForTemporalPublisherAbuse,
|
||||
labelForPublisherAbuseZScore,
|
||||
scorePublisherAbuseCohort,
|
||||
} from "./publisherAbuseScoring";
|
||||
@@ -18,6 +23,40 @@ describe("publisher abuse scoring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps temporal labels to review-compatible z-scores", () => {
|
||||
const review = computeTemporalPublisherAbuseZScore({
|
||||
label: "review",
|
||||
highTemporalSkillCount: 1,
|
||||
maxTemporalPressure: 20,
|
||||
});
|
||||
const potentialBan = computeTemporalPublisherAbuseZScore({
|
||||
label: "potential_ban_candidate",
|
||||
highTemporalSkillCount: 2,
|
||||
maxTemporalPressure: 20,
|
||||
});
|
||||
|
||||
expect(
|
||||
computeTemporalPublisherAbuseZScore({
|
||||
label: "pass",
|
||||
highTemporalSkillCount: 0,
|
||||
maxTemporalPressure: 0,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(review).toBeGreaterThanOrEqual(1.5);
|
||||
expect(review).toBeLessThan(2.5);
|
||||
expect(potentialBan).toBeGreaterThanOrEqual(2.5);
|
||||
expect(potentialBan).toBeGreaterThan(review);
|
||||
});
|
||||
|
||||
it("escalates one P99 temporal hit as a potential ban candidate", () => {
|
||||
expect(
|
||||
labelForTemporalPublisherAbuse({ highTemporalSkillCount: 1, p99TemporalSkillCount: 1 }),
|
||||
).toBe("potential_ban_candidate");
|
||||
expect(
|
||||
labelForTemporalPublisherAbuse({ highTemporalSkillCount: 1, p99TemporalSkillCount: 0 }),
|
||||
).toBe("review");
|
||||
});
|
||||
|
||||
it("keeps a high-volume publisher with strong usage below low-engagement publishers", () => {
|
||||
const scored = scorePublisherAbuseCohort([
|
||||
publisher("byungkyu", {
|
||||
@@ -125,8 +164,130 @@ describe("publisher abuse scoring", () => {
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a current 7-day download spike with flat installs", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
benchmark: temporalBenchmark({
|
||||
downloads30dP95: 2_000,
|
||||
downloads30dP99: 5_000,
|
||||
spikeMultiplier7dP95: 5,
|
||||
spikeMultiplier7dP99: 20,
|
||||
}),
|
||||
dailyStats: [
|
||||
...dailyRange(64, 30, { downloads: 5, installs: 0 }),
|
||||
...dailyRange(94, 7, { downloads: 200, installs: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(score.spike).toBe(true);
|
||||
expect(score.sustained).toBe(false);
|
||||
expect(score.recent7Downloads).toBe(1_400);
|
||||
expect(score.recent7Installs).toBe(0);
|
||||
expect(score.previous30Downloads).toBe(150);
|
||||
expect(score.spikeMultiplier).toBeCloseTo(14);
|
||||
expect(score.spikeMultiplierCohortBand).toBe("p95");
|
||||
expect(score.reasonCodes).toContain("temporal_download_spike_flat_installs");
|
||||
});
|
||||
|
||||
it("flags sustained high downloads with flat installs", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
benchmark: temporalBenchmark({
|
||||
downloads30dP95: 3_000,
|
||||
downloads30dP99: 6_000,
|
||||
spikeMultiplier7dP95: 20,
|
||||
spikeMultiplier7dP99: 50,
|
||||
}),
|
||||
dailyStats: dailyRange(71, 30, { downloads: 120, installs: 0 }),
|
||||
});
|
||||
|
||||
expect(score.spike).toBe(false);
|
||||
expect(score.sustained).toBe(true);
|
||||
expect(score.recent30Downloads).toBe(3_600);
|
||||
expect(score.recent30Installs).toBe(0);
|
||||
expect(score.downloadInstallRatio30).toBe(3_600);
|
||||
expect(score.downloads30dCohortBand).toBe("p95");
|
||||
expect(score.reasonCodes).toContain("temporal_sustained_downloads_flat_installs");
|
||||
});
|
||||
|
||||
it("keeps ordinary steady download traffic below temporal thresholds", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
benchmark: temporalBenchmark({
|
||||
downloads30dP95: 4_000,
|
||||
downloads30dP99: 8_000,
|
||||
spikeMultiplier7dP95: 20,
|
||||
spikeMultiplier7dP99: 50,
|
||||
}),
|
||||
dailyStats: [
|
||||
...dailyRange(64, 30, { downloads: 80, installs: 1 }),
|
||||
...dailyRange(94, 7, { downloads: 85, installs: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(score.spike).toBe(false);
|
||||
expect(score.sustained).toBe(false);
|
||||
expect(score.pressure).toBe(0);
|
||||
expect(score.reasonCodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds historical spike and sustained windows for backfill scans", () => {
|
||||
const score = computeHistoricalSkillTemporalAbuseScore({
|
||||
benchmark: temporalBenchmark({
|
||||
downloads30dP95: 3_000,
|
||||
downloads30dP99: 10_000,
|
||||
spikeMultiplier7dP95: 5,
|
||||
spikeMultiplier7dP99: 25,
|
||||
}),
|
||||
dailyStats: [
|
||||
...dailyRange(10, 30, { downloads: 3, installs: 0 }),
|
||||
...dailyRange(40, 7, { downloads: 220, installs: 0 }),
|
||||
...dailyRange(80, 30, { downloads: 150, installs: 0 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(score.spike).toBe(true);
|
||||
expect(score.sustained).toBe(true);
|
||||
expect(score.spikeWindowStartDay).toBe(40);
|
||||
expect(score.sustainedWindowStartDay).toBe(80);
|
||||
expect(score.reasonCodes).toEqual([
|
||||
"temporal_download_spike_flat_installs",
|
||||
"temporal_sustained_downloads_flat_installs",
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes cohort benchmark percentiles from scanned skill windows", () => {
|
||||
const benchmark = computeTemporalAbuseCohortBenchmark([
|
||||
...Array.from({ length: 95 }, () => ({ recent30Downloads: 100, spikeMultiplier: 1 })),
|
||||
...Array.from({ length: 4 }, () => ({ recent30Downloads: 500, spikeMultiplier: 2 })),
|
||||
{ recent30Downloads: 10_000, spikeMultiplier: 30 },
|
||||
]);
|
||||
|
||||
expect(benchmark.sampleSize).toBe(100);
|
||||
expect(benchmark.downloads30dMedian).toBe(100);
|
||||
expect(benchmark.downloads30dP95).toBe(100);
|
||||
expect(benchmark.downloads30dP99).toBe(500);
|
||||
expect(benchmark.spikeMultiplier7dP99).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function temporalBenchmark(overrides = {}) {
|
||||
return {
|
||||
sampleSize: 100,
|
||||
downloads30dAverage: 500,
|
||||
downloads30dMedian: 100,
|
||||
downloads30dP95: 1_000,
|
||||
downloads30dP99: 5_000,
|
||||
spikeMultiplier7dP95: 5,
|
||||
spikeMultiplier7dP99: 25,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function publisher(
|
||||
handleSnapshot: string,
|
||||
stats: {
|
||||
@@ -143,3 +304,15 @@ function publisher(
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
|
||||
function dailyRange(
|
||||
startDay: number,
|
||||
length: number,
|
||||
stats: { downloads: number; installs: number },
|
||||
) {
|
||||
return Array.from({ length }, (_, index) => ({
|
||||
day: startDay + index,
|
||||
downloads: stats.downloads,
|
||||
installs: stats.installs,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const PUBLISHER_ABUSE_MODEL_VERSION = "publisher-abuse-pressure.v1";
|
||||
export const PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION = "publisher-abuse-temporal.v1";
|
||||
|
||||
export type PublisherAbuseLabel = "pass" | "review" | "potential_ban_candidate";
|
||||
|
||||
@@ -50,6 +51,45 @@ export type PublisherAbuseScore = PublisherAbuseRawScore & {
|
||||
zScore: number;
|
||||
};
|
||||
|
||||
export type SkillTemporalAbuseDailyStat = {
|
||||
day: number;
|
||||
downloads: number;
|
||||
installs: number;
|
||||
};
|
||||
|
||||
export type SkillTemporalAbuseScore = {
|
||||
spike: boolean;
|
||||
sustained: boolean;
|
||||
pressure: number;
|
||||
recent7Downloads: number;
|
||||
recent7Installs: number;
|
||||
previous30Downloads: number;
|
||||
baseline7Downloads: number;
|
||||
spikeMultiplier: number;
|
||||
recent30Downloads: number;
|
||||
recent30Installs: number;
|
||||
downloadInstallRatio30: number;
|
||||
downloads30dCohortBand?: "p95" | "p99";
|
||||
spikeMultiplierCohortBand?: "p95" | "p99";
|
||||
downloads30dVsPeerP95?: number;
|
||||
spikeMultiplierVsPeerP95?: number;
|
||||
spikeWindowStartDay?: number;
|
||||
spikeWindowEndDay?: number;
|
||||
sustainedWindowStartDay?: number;
|
||||
sustainedWindowEndDay?: number;
|
||||
reasonCodes: string[];
|
||||
};
|
||||
|
||||
export type TemporalAbuseCohortBenchmark = {
|
||||
sampleSize: number;
|
||||
downloads30dAverage: number;
|
||||
downloads30dMedian: number;
|
||||
downloads30dP95: number;
|
||||
downloads30dP99: number;
|
||||
spikeMultiplier7dP95: number;
|
||||
spikeMultiplier7dP99: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG = {
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
skillPivot: 100,
|
||||
@@ -70,6 +110,12 @@ export const DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG = {
|
||||
} satisfies PublisherAbuseModelConfig;
|
||||
|
||||
const MIN_PRESSURE_FOR_LOG = 1e-9;
|
||||
const TEMPORAL_SPIKE_RECENT_DAYS = 7;
|
||||
const TEMPORAL_SPIKE_BASELINE_DAYS = 30;
|
||||
const TEMPORAL_SUSTAINED_DAYS = 30;
|
||||
const TEMPORAL_MAX_SPIKE_INSTALLS = 2;
|
||||
const TEMPORAL_MAX_SUSTAINED_INSTALLS = 5;
|
||||
const TEMPORAL_MIN_BASELINE_7_DOWNLOADS = 100;
|
||||
|
||||
export function labelForPublisherAbuseZScore(
|
||||
zScore: number,
|
||||
@@ -80,6 +126,21 @@ export function labelForPublisherAbuseZScore(
|
||||
return "pass";
|
||||
}
|
||||
|
||||
export function computeTemporalPublisherAbuseZScore(input: {
|
||||
label: PublisherAbuseLabel;
|
||||
highTemporalSkillCount: number;
|
||||
maxTemporalPressure: number;
|
||||
}): number {
|
||||
if (input.label === "pass") return 0;
|
||||
|
||||
const pressureBoost = Math.log10(Math.max(input.maxTemporalPressure, 1) + 1) / 2;
|
||||
const skillCountBoost = Math.max(0, input.highTemporalSkillCount - 2) * 0.2;
|
||||
if (input.label === "potential_ban_candidate") {
|
||||
return 2.5 + Math.min(2, pressureBoost + skillCountBoost);
|
||||
}
|
||||
return 1.5 + Math.min(0.99, pressureBoost);
|
||||
}
|
||||
|
||||
export function computePublisherAbuseRawScore(
|
||||
input: PublisherAbuseInput,
|
||||
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
@@ -204,6 +265,141 @@ export function summarizePublisherAbuseLogPressure(
|
||||
};
|
||||
}
|
||||
|
||||
export function computeCurrentSkillTemporalAbuseScore(input: {
|
||||
todayDay: number;
|
||||
dailyStats: SkillTemporalAbuseDailyStat[];
|
||||
benchmark?: TemporalAbuseCohortBenchmark;
|
||||
}): SkillTemporalAbuseScore {
|
||||
const statsByDay = aggregateSkillTemporalDailyStats(input.dailyStats);
|
||||
const score = computeSkillTemporalAbuseScoreForWindows({
|
||||
statsByDay,
|
||||
spikeStartDay: input.todayDay - TEMPORAL_SPIKE_RECENT_DAYS + 1,
|
||||
sustainedStartDay: input.todayDay - TEMPORAL_SUSTAINED_DAYS + 1,
|
||||
});
|
||||
return classifySkillTemporalAbuseScore(score, input.benchmark);
|
||||
}
|
||||
|
||||
export function computeHistoricalSkillTemporalAbuseScore(input: {
|
||||
dailyStats: SkillTemporalAbuseDailyStat[];
|
||||
benchmark?: TemporalAbuseCohortBenchmark;
|
||||
}): SkillTemporalAbuseScore {
|
||||
const statsByDay = aggregateSkillTemporalDailyStats(input.dailyStats);
|
||||
const days = [...statsByDay.keys()];
|
||||
if (days.length === 0) return emptySkillTemporalAbuseScore();
|
||||
|
||||
const minDay = Math.min(...days);
|
||||
const maxDay = Math.max(...days);
|
||||
let bestSpike = emptySkillTemporalAbuseScore();
|
||||
let bestSustained = emptySkillTemporalAbuseScore();
|
||||
|
||||
for (let startDay = minDay; startDay <= maxDay; startDay += 1) {
|
||||
if (startDay + TEMPORAL_SPIKE_RECENT_DAYS - 1 <= maxDay) {
|
||||
const score = classifySkillTemporalAbuseScore(
|
||||
computeSkillTemporalAbuseScoreForWindows({
|
||||
statsByDay,
|
||||
spikeStartDay: startDay,
|
||||
sustainedStartDay: startDay,
|
||||
}),
|
||||
input.benchmark,
|
||||
);
|
||||
if (score.spike && score.spikeMultiplier > bestSpike.spikeMultiplier) {
|
||||
bestSpike = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (startDay + TEMPORAL_SUSTAINED_DAYS - 1 <= maxDay) {
|
||||
const score = classifySkillTemporalAbuseScore(
|
||||
computeSkillTemporalAbuseScoreForWindows({
|
||||
statsByDay,
|
||||
spikeStartDay: startDay,
|
||||
sustainedStartDay: startDay,
|
||||
}),
|
||||
input.benchmark,
|
||||
);
|
||||
if (score.sustained && score.recent30Downloads > bestSustained.recent30Downloads) {
|
||||
bestSustained = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeTemporalAbuseWindowScores(bestSpike, bestSustained);
|
||||
}
|
||||
|
||||
export function labelForTemporalPublisherAbuse(input: {
|
||||
highTemporalSkillCount: number;
|
||||
p99TemporalSkillCount?: number;
|
||||
}): PublisherAbuseLabel {
|
||||
if ((input.p99TemporalSkillCount ?? 0) >= 1 || input.highTemporalSkillCount >= 2) {
|
||||
return "potential_ban_candidate";
|
||||
}
|
||||
if (input.highTemporalSkillCount >= 1) return "review";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
export function computeTemporalAbuseCohortBenchmark(
|
||||
scores: Pick<SkillTemporalAbuseScore, "recent30Downloads" | "spikeMultiplier">[],
|
||||
): TemporalAbuseCohortBenchmark {
|
||||
const downloads30d = scores.map((score) => nonNegative(score.recent30Downloads));
|
||||
const spikeMultipliers = scores.map((score) => nonNegative(score.spikeMultiplier));
|
||||
return {
|
||||
sampleSize: scores.length,
|
||||
downloads30dAverage: average(downloads30d),
|
||||
downloads30dMedian: percentile(downloads30d, 0.5),
|
||||
downloads30dP95: percentile(downloads30d, 0.95),
|
||||
downloads30dP99: percentile(downloads30d, 0.99),
|
||||
spikeMultiplier7dP95: percentile(spikeMultipliers, 0.95),
|
||||
spikeMultiplier7dP99: percentile(spikeMultipliers, 0.99),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifySkillTemporalAbuseScore(
|
||||
score: SkillTemporalAbuseScore,
|
||||
benchmark: TemporalAbuseCohortBenchmark | undefined,
|
||||
): SkillTemporalAbuseScore {
|
||||
if (!benchmark || benchmark.sampleSize <= 0) return score;
|
||||
|
||||
const downloads30dVsPeerP95 = score.recent30Downloads / Math.max(1, benchmark.downloads30dP95);
|
||||
const spikeMultiplierVsPeerP95 =
|
||||
score.spikeMultiplier / Math.max(1, benchmark.spikeMultiplier7dP95);
|
||||
const downloads30dCohortBand =
|
||||
score.recent30Installs <= TEMPORAL_MAX_SUSTAINED_INSTALLS
|
||||
? percentileBand({
|
||||
value: score.recent30Downloads,
|
||||
p95: benchmark.downloads30dP95,
|
||||
p99: benchmark.downloads30dP99,
|
||||
})
|
||||
: undefined;
|
||||
const spikeMultiplierCohortBand =
|
||||
score.recent7Installs <= TEMPORAL_MAX_SPIKE_INSTALLS && score.recent7Downloads > 0
|
||||
? percentileBand({
|
||||
value: score.spikeMultiplier,
|
||||
p95: benchmark.spikeMultiplier7dP95,
|
||||
p99: benchmark.spikeMultiplier7dP99,
|
||||
})
|
||||
: undefined;
|
||||
const spike = Boolean(spikeMultiplierCohortBand);
|
||||
const sustained = Boolean(downloads30dCohortBand);
|
||||
const reasonCodes: string[] = [];
|
||||
if (spike) reasonCodes.push("temporal_download_spike_flat_installs");
|
||||
if (sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
|
||||
|
||||
return {
|
||||
...score,
|
||||
spike,
|
||||
sustained,
|
||||
pressure: Math.max(spike ? spikeMultiplierVsPeerP95 : 0, sustained ? downloads30dVsPeerP95 : 0),
|
||||
downloads30dCohortBand,
|
||||
spikeMultiplierCohortBand,
|
||||
downloads30dVsPeerP95,
|
||||
spikeMultiplierVsPeerP95,
|
||||
spikeWindowStartDay: spike ? score.spikeWindowStartDay : undefined,
|
||||
spikeWindowEndDay: spike ? score.spikeWindowEndDay : undefined,
|
||||
sustainedWindowStartDay: sustained ? score.sustainedWindowStartDay : undefined,
|
||||
sustainedWindowEndDay: sustained ? score.sustainedWindowEndDay : undefined,
|
||||
reasonCodes,
|
||||
};
|
||||
}
|
||||
|
||||
function reasonCodesForPublisher(input: {
|
||||
publishedSkills: number;
|
||||
installsPerSkill: number;
|
||||
@@ -229,6 +425,129 @@ function reasonCodesForPublisher(input: {
|
||||
return codes;
|
||||
}
|
||||
|
||||
function computeSkillTemporalAbuseScoreForWindows(input: {
|
||||
statsByDay: Map<number, { downloads: number; installs: number }>;
|
||||
spikeStartDay: number;
|
||||
sustainedStartDay: number;
|
||||
}): SkillTemporalAbuseScore {
|
||||
const spikeEndDay = input.spikeStartDay + TEMPORAL_SPIKE_RECENT_DAYS - 1;
|
||||
const sustainedEndDay = input.sustainedStartDay + TEMPORAL_SUSTAINED_DAYS - 1;
|
||||
const recent7 = sumTemporalStatsRange(input.statsByDay, input.spikeStartDay, spikeEndDay);
|
||||
const previous30 = sumTemporalStatsRange(
|
||||
input.statsByDay,
|
||||
input.spikeStartDay - TEMPORAL_SPIKE_BASELINE_DAYS,
|
||||
input.spikeStartDay - 1,
|
||||
);
|
||||
const recent30 = sumTemporalStatsRange(
|
||||
input.statsByDay,
|
||||
input.sustainedStartDay,
|
||||
sustainedEndDay,
|
||||
);
|
||||
const baseline7Downloads = Math.max(
|
||||
TEMPORAL_MIN_BASELINE_7_DOWNLOADS,
|
||||
(previous30.downloads / TEMPORAL_SPIKE_BASELINE_DAYS) * TEMPORAL_SPIKE_RECENT_DAYS,
|
||||
);
|
||||
const spikeMultiplier = baseline7Downloads > 0 ? recent7.downloads / baseline7Downloads : 0;
|
||||
const downloadInstallRatio30 = recent30.downloads / Math.max(1, recent30.installs);
|
||||
return {
|
||||
spike: false,
|
||||
sustained: false,
|
||||
pressure: 0,
|
||||
recent7Downloads: recent7.downloads,
|
||||
recent7Installs: recent7.installs,
|
||||
previous30Downloads: previous30.downloads,
|
||||
baseline7Downloads,
|
||||
spikeMultiplier,
|
||||
recent30Downloads: recent30.downloads,
|
||||
recent30Installs: recent30.installs,
|
||||
downloadInstallRatio30,
|
||||
spikeWindowStartDay: input.spikeStartDay,
|
||||
spikeWindowEndDay: spikeEndDay,
|
||||
sustainedWindowStartDay: input.sustainedStartDay,
|
||||
sustainedWindowEndDay: sustainedEndDay,
|
||||
reasonCodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTemporalAbuseWindowScores(
|
||||
bestSpike: SkillTemporalAbuseScore,
|
||||
bestSustained: SkillTemporalAbuseScore,
|
||||
): SkillTemporalAbuseScore {
|
||||
if (!bestSpike.spike && !bestSustained.sustained) return emptySkillTemporalAbuseScore();
|
||||
const reasonCodes: string[] = [];
|
||||
if (bestSpike.spike) reasonCodes.push("temporal_download_spike_flat_installs");
|
||||
if (bestSustained.sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
|
||||
|
||||
return {
|
||||
spike: bestSpike.spike,
|
||||
sustained: bestSustained.sustained,
|
||||
pressure: Math.max(bestSpike.pressure, bestSustained.pressure),
|
||||
recent7Downloads: bestSpike.recent7Downloads,
|
||||
recent7Installs: bestSpike.recent7Installs,
|
||||
previous30Downloads: bestSpike.previous30Downloads,
|
||||
baseline7Downloads: bestSpike.baseline7Downloads,
|
||||
spikeMultiplier: bestSpike.spikeMultiplier,
|
||||
recent30Downloads: bestSustained.recent30Downloads,
|
||||
recent30Installs: bestSustained.recent30Installs,
|
||||
downloadInstallRatio30: bestSustained.downloadInstallRatio30,
|
||||
downloads30dCohortBand: bestSustained.downloads30dCohortBand,
|
||||
spikeMultiplierCohortBand: bestSpike.spikeMultiplierCohortBand,
|
||||
downloads30dVsPeerP95: bestSustained.downloads30dVsPeerP95,
|
||||
spikeMultiplierVsPeerP95: bestSpike.spikeMultiplierVsPeerP95,
|
||||
spikeWindowStartDay: bestSpike.spikeWindowStartDay,
|
||||
spikeWindowEndDay: bestSpike.spikeWindowEndDay,
|
||||
sustainedWindowStartDay: bestSustained.sustainedWindowStartDay,
|
||||
sustainedWindowEndDay: bestSustained.sustainedWindowEndDay,
|
||||
reasonCodes,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateSkillTemporalDailyStats(dailyStats: SkillTemporalAbuseDailyStat[]) {
|
||||
const byDay = new Map<number, { downloads: number; installs: number }>();
|
||||
for (const point of dailyStats) {
|
||||
if (!Number.isFinite(point.day)) continue;
|
||||
const day = Math.trunc(point.day);
|
||||
const existing = byDay.get(day) ?? { downloads: 0, installs: 0 };
|
||||
existing.downloads += nonNegative(point.downloads);
|
||||
existing.installs += nonNegative(point.installs);
|
||||
byDay.set(day, existing);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
|
||||
function sumTemporalStatsRange(
|
||||
statsByDay: Map<number, { downloads: number; installs: number }>,
|
||||
startDay: number,
|
||||
endDay: number,
|
||||
) {
|
||||
let downloads = 0;
|
||||
let installs = 0;
|
||||
for (let day = startDay; day <= endDay; day += 1) {
|
||||
const point = statsByDay.get(day);
|
||||
if (!point) continue;
|
||||
downloads += point.downloads;
|
||||
installs += point.installs;
|
||||
}
|
||||
return { downloads, installs };
|
||||
}
|
||||
|
||||
function emptySkillTemporalAbuseScore(): SkillTemporalAbuseScore {
|
||||
return {
|
||||
spike: false,
|
||||
sustained: false,
|
||||
pressure: 0,
|
||||
recent7Downloads: 0,
|
||||
recent7Installs: 0,
|
||||
previous30Downloads: 0,
|
||||
baseline7Downloads: TEMPORAL_MIN_BASELINE_7_DOWNLOADS,
|
||||
spikeMultiplier: 0,
|
||||
recent30Downloads: 0,
|
||||
recent30Installs: 0,
|
||||
downloadInstallRatio30: 0,
|
||||
reasonCodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function nonNegative(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
@@ -238,6 +557,24 @@ function average(values: number[]) {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function percentile(values: number[], quantile: number) {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(quantile * sorted.length) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function percentileBand(input: {
|
||||
value: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
}): "p95" | "p99" | undefined {
|
||||
if (input.value <= 0) return undefined;
|
||||
if (input.p99 > 0 && input.value > input.p99) return "p99";
|
||||
if (input.p95 > 0 && input.value > input.p95) return "p95";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function standardDeviation(values: number[], mean: number) {
|
||||
if (values.length === 0) return 0;
|
||||
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubSkillCatalogDisplay } from "./publisherCatalogDisplay";
|
||||
|
||||
const baseItem = {
|
||||
kind: "skill" as const,
|
||||
summary: null,
|
||||
icon: null,
|
||||
href: "/nvidia/example",
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
isOfficial: true,
|
||||
updatedAt: 1,
|
||||
sourceBacked: true,
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
sourcePath: null,
|
||||
sourceVerifiedCommit: null,
|
||||
};
|
||||
|
||||
describe("buildGitHubSkillCatalogDisplay", () => {
|
||||
it("groups source-backed skills by manifest entries and ignores missing entries", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "missing-upstream-entry"],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
skills: ["vision-helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:vision-helper",
|
||||
displayName: "Vision Helper",
|
||||
slug: "vision-helper",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display).toMatchObject({
|
||||
mode: "grouped",
|
||||
sourceRepos: ["NVIDIA/skills"],
|
||||
sections: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "AIQ Deploy" }],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "Vision Helper" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches manifest entries by normalized display name and places unlisted skills at the requested edge", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Physical AI",
|
||||
skills: ["Isaac Sim Helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:isaac-sim-helper",
|
||||
displayName: "Isaac Sim Helper",
|
||||
slug: "isaac-sim-helper",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:extra",
|
||||
displayName: "Extra Skill",
|
||||
slug: "extra",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display?.sections.map((section) => section.title)).toEqual([
|
||||
"Other skills",
|
||||
"Physical AI",
|
||||
]);
|
||||
expect(display?.sections[0]?.items.map((item) => item.displayName)).toEqual(["Extra Skill"]);
|
||||
});
|
||||
|
||||
it("falls back to the normal catalog when the source manifest is missing or invalid", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "invalid",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps source-backed skills from non-renderable sources in other skills", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
skills: ["aiq-deploy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: "githubSkillSources:invalid",
|
||||
repo: "example/skills",
|
||||
displayManifestStatus: "invalid",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:unlisted",
|
||||
displayName: "Unlisted Source Skill",
|
||||
slug: "unlisted-source-skill",
|
||||
sourceRepo: "example/skills",
|
||||
sourceId: "githubSkillSources:invalid",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display?.sections.map((section) => section.title)).toEqual([
|
||||
"Agentic AI",
|
||||
"Other skills",
|
||||
]);
|
||||
expect(display?.sections.at(-1)?.items.map((item) => item.displayName)).toEqual([
|
||||
"Unlisted Source Skill",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
type DisplayManifest = {
|
||||
notGrouped?: "top" | "bottom";
|
||||
groupings: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
skills: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogSource = {
|
||||
_id: string;
|
||||
repo: string;
|
||||
displayManifestStatus?: "ok" | "missing" | "invalid" | "failed";
|
||||
displayManifest?: DisplayManifest;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogItem = {
|
||||
_id: string;
|
||||
kind: "skill" | "plugin";
|
||||
displayName: string;
|
||||
slug?: string | null;
|
||||
sourceBacked?: boolean;
|
||||
sourceId?: string | null;
|
||||
sourceRepo?: string | null;
|
||||
sourcePath?: string | null;
|
||||
sourceVerifiedCommit?: string | null;
|
||||
summary: string | null;
|
||||
icon: string | null;
|
||||
href: string;
|
||||
downloads: number;
|
||||
stars: number;
|
||||
isOfficial: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogSection = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
sourceRepo: string | null;
|
||||
items: GitHubSkillCatalogItem[];
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogDisplay = {
|
||||
mode: "grouped";
|
||||
sourceRepos: string[];
|
||||
sections: GitHubSkillCatalogSection[];
|
||||
};
|
||||
|
||||
function normalizeManifestSkillKey(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function getItemKeys(item: GitHubSkillCatalogItem) {
|
||||
const keys = new Set<string>();
|
||||
if (item.slug) keys.add(normalizeManifestSkillKey(item.slug));
|
||||
keys.add(normalizeManifestSkillKey(item.displayName));
|
||||
|
||||
const sourcePathName = item.sourcePath?.split("/").filter(Boolean).at(-1);
|
||||
if (sourcePathName) keys.add(normalizeManifestSkillKey(sourcePathName));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function findManifestItem(
|
||||
candidates: GitHubSkillCatalogItem[],
|
||||
manifestEntry: string,
|
||||
usedItemIds: Set<string>,
|
||||
) {
|
||||
const key = normalizeManifestSkillKey(manifestEntry);
|
||||
if (!key) return null;
|
||||
|
||||
return (
|
||||
candidates.find((item) => !usedItemIds.has(item._id) && getItemKeys(item).has(key)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderableSource(source: GitHubSkillCatalogSource) {
|
||||
return (
|
||||
source.displayManifestStatus === "ok" &&
|
||||
Boolean(source.displayManifest) &&
|
||||
source.displayManifest!.groupings.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGitHubSkillCatalogDisplay({
|
||||
sources,
|
||||
items,
|
||||
}: {
|
||||
sources: GitHubSkillCatalogSource[];
|
||||
items: GitHubSkillCatalogItem[];
|
||||
}): GitHubSkillCatalogDisplay | null {
|
||||
const renderableSources = sources.filter(isRenderableSource);
|
||||
if (renderableSources.length === 0) return null;
|
||||
|
||||
const sourceRepos = Array.from(new Set(renderableSources.map((source) => source.repo)));
|
||||
const usedItemIds = new Set<string>();
|
||||
const sections: GitHubSkillCatalogSection[] = [];
|
||||
const otherPosition = renderableSources.some(
|
||||
(source) => source.displayManifest?.notGrouped === "top",
|
||||
)
|
||||
? "top"
|
||||
: "bottom";
|
||||
|
||||
for (const source of renderableSources) {
|
||||
const sourceItems = items.filter(
|
||||
(item) => item.kind === "skill" && item.sourceId === source._id,
|
||||
);
|
||||
if (sourceItems.length === 0) continue;
|
||||
|
||||
for (const [groupIndex, group] of source.displayManifest!.groupings.entries()) {
|
||||
const groupItems = group.skills
|
||||
.map((entry) => findManifestItem(sourceItems, entry, usedItemIds))
|
||||
.filter((item): item is GitHubSkillCatalogItem => Boolean(item));
|
||||
|
||||
if (groupItems.length === 0) continue;
|
||||
for (const item of groupItems) usedItemIds.add(item._id);
|
||||
|
||||
sections.push({
|
||||
key: `${source._id}:${groupIndex}:${group.title}`,
|
||||
title: group.title,
|
||||
description: group.description ?? null,
|
||||
sourceRepo: source.repo,
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const otherItems = items.filter((item) => item.kind === "skill" && !usedItemIds.has(item._id));
|
||||
const otherSection =
|
||||
otherItems.length > 0
|
||||
? {
|
||||
key: "other-skills",
|
||||
title: "Other skills",
|
||||
description: null,
|
||||
sourceRepo: null,
|
||||
items: otherItems,
|
||||
}
|
||||
: null;
|
||||
const orderedSections =
|
||||
otherPosition === "top" && otherSection
|
||||
? [otherSection, ...sections]
|
||||
: [...sections, ...(otherSection ? [otherSection] : [])];
|
||||
|
||||
if (orderedSections.length === 0) return null;
|
||||
return {
|
||||
mode: "grouped",
|
||||
sourceRepos,
|
||||
sections: orderedSections,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,13 @@ describe("publisher stat maintenance", () => {
|
||||
return {
|
||||
collect: vi.fn(async () => [
|
||||
makeSkill({ statsDownloads: 11, statsStars: 2, statsInstallsAllTime: 5 }),
|
||||
makeSkill({
|
||||
_id: "skills:hidden",
|
||||
moderationStatus: "hidden",
|
||||
statsDownloads: 100,
|
||||
statsStars: 100,
|
||||
statsInstallsAllTime: 100,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -136,6 +143,26 @@ describe("publisher stat maintenance", () => {
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count hidden skills in public publisher aggregates", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await adjustPublisherStatsForSkillChange(
|
||||
ctx as never,
|
||||
null,
|
||||
makeSkill({ moderationStatus: "hidden", moderationReason: "pending.scan" }),
|
||||
);
|
||||
|
||||
expect(ctx.db.get).not.toHaveBeenCalled();
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps legacy aggregate updates bounded when skill-only aggregates are missing", async () => {
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
import { isPublicSkillDoc } from "./globalStats";
|
||||
import { readCanonicalStat } from "./skillStats";
|
||||
|
||||
export type PublisherStatsContribution = {
|
||||
@@ -27,7 +28,7 @@ export function emptyPublisherStatsContribution(): PublisherStatsContribution {
|
||||
}
|
||||
|
||||
export function getSkillPublisherContribution(skill: Doc<"skills">): PublisherStatsContribution {
|
||||
if (skill.softDeletedAt) return emptyPublisherStatsContribution();
|
||||
if (!isPublicSkillDoc(skill)) return emptyPublisherStatsContribution();
|
||||
const totalInstalls = readCanonicalStat(skill, "installsAllTime");
|
||||
const totalDownloads = readCanonicalStat(skill, "downloads");
|
||||
const totalStars = readCanonicalStat(skill, "stars");
|
||||
@@ -91,7 +92,7 @@ function publisherHasSkillTotalStats(
|
||||
);
|
||||
}
|
||||
|
||||
async function recomputePublisherStats(
|
||||
export async function recomputePublisherStats(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
publisherId: Id<"publishers">,
|
||||
): Promise<PublisherStatsContribution> {
|
||||
|
||||
@@ -32,7 +32,7 @@ function normalizeGeneratedPublisherHandle(handle: string | undefined | null) {
|
||||
return sanitized || undefined;
|
||||
}
|
||||
|
||||
function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
export function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
const emailLocalPart = user.email?.split("@")[0];
|
||||
const userIdSuffix = String(user._id).split(":").pop();
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
import type { HydratableSkill, PublicPublisher } from "./public";
|
||||
import { getOwnerPublisher } from "./publishers";
|
||||
import { tokenize } from "./searchText";
|
||||
import { readCanonicalStat } from "./skillStats";
|
||||
|
||||
@@ -25,6 +26,10 @@ const SHARED_KEYS = [
|
||||
"canonicalSkillId",
|
||||
"forkOf",
|
||||
"latestVersionId",
|
||||
"installKind",
|
||||
"githubHasSkillCard",
|
||||
"githubCurrentStatus",
|
||||
"githubScanStatus",
|
||||
"latestVersionSummary",
|
||||
"tags",
|
||||
"capabilityTags",
|
||||
@@ -130,6 +135,26 @@ export async function upsertSkillSearchDigest(
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSkillSearchDigestForSkill(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!skill) return;
|
||||
const fields = await extractValidatedDigestFields(ctx, skill);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
await upsertSkillSearchDigest(ctx, {
|
||||
...fields,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
ownerName: owner?.linkedUserId ? owner.handle : undefined,
|
||||
ownerDisplayName: owner?.displayName,
|
||||
ownerImage: owner?.image,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compare new fields against existing row. Returns true if any field differs. */
|
||||
function hasDigestChanged(
|
||||
existing: Doc<"skillSearchDigest">,
|
||||
|
||||
@@ -73,14 +73,21 @@ describe("assertValidSkillSlug", () => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
|
||||
});
|
||||
|
||||
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages", "publishers"])(
|
||||
"rejects reserved slug %s",
|
||||
(slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
"account-banned",
|
||||
"admin",
|
||||
"settings",
|
||||
"api",
|
||||
"openclaw",
|
||||
"clawhub",
|
||||
"souls",
|
||||
"packages",
|
||||
"publishers",
|
||||
])("rejects reserved slug %s", (slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
});
|
||||
|
||||
it.each(["openclaw", "publishers"])(
|
||||
"emits the reserved-specific error for long reserved slug %s",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ConvexError } from "convex/values";
|
||||
// - Lowercase letters, digits, and single hyphens only.
|
||||
// - Must start and end with a letter or digit.
|
||||
// - No consecutive hyphens ("--", "---", ...).
|
||||
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
|
||||
// - Length 3..96 (URL-friendly, but long enough for source-backed upstream slugs).
|
||||
//
|
||||
// The pattern enforces first/last char class and forbids consecutive hyphens
|
||||
// via a negative lookahead. Length bounds are checked separately so we can
|
||||
@@ -12,7 +12,7 @@ import { ConvexError } from "convex/values";
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
|
||||
|
||||
const MIN_SLUG_LENGTH = 3;
|
||||
const MAX_SLUG_LENGTH = 48;
|
||||
const MAX_SLUG_LENGTH = 96;
|
||||
|
||||
// Reserved slugs. These are blocked because they would:
|
||||
// 1. Clash semantically with top-level routes under src/routes/*.
|
||||
@@ -25,6 +25,7 @@ const MAX_SLUG_LENGTH = 48;
|
||||
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Current top-level route segments under src/routes/.
|
||||
"about",
|
||||
"account-banned",
|
||||
"admin",
|
||||
"cli",
|
||||
"dashboard",
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock("./_generated/api", () => ({
|
||||
getUserOwnedSkillsBackfillPageInternal: Symbol("getUserOwnedSkillsBackfillPageInternal"),
|
||||
applyUserStatsBackfillPatchInternal: Symbol("applyUserStatsBackfillPatchInternal"),
|
||||
backfillUserStatsInternal: Symbol("backfillUserStatsInternal"),
|
||||
getPublisherStatsBackfillPageInternal: Symbol("getPublisherStatsBackfillPageInternal"),
|
||||
recomputePublisherStatsInternal: Symbol("recomputePublisherStatsInternal"),
|
||||
backfillPublisherStatsInternal: Symbol("backfillPublisherStatsInternal"),
|
||||
getSkillFingerprintBackfillPageInternal: Symbol("getSkillFingerprintBackfillPageInternal"),
|
||||
applySkillFingerprintBackfillPatchInternal: Symbol(
|
||||
"applySkillFingerprintBackfillPatchInternal",
|
||||
@@ -24,6 +27,7 @@ vi.mock("./_generated/api", () => ({
|
||||
nominateUserForEmptySkillSpamInternal: Symbol("nominateUserForEmptySkillSpamInternal"),
|
||||
cleanupEmptySkillsInternal: Symbol("cleanupEmptySkillsInternal"),
|
||||
nominateEmptySkillSpammersInternal: Symbol("nominateEmptySkillSpammersInternal"),
|
||||
repairLegacyPublisherOwnership: Symbol("repairLegacyPublisherOwnership"),
|
||||
},
|
||||
skills: {
|
||||
backfillLatestSkillModerationInternal: Symbol("skills.backfillLatestSkillModerationInternal"),
|
||||
@@ -44,12 +48,15 @@ const {
|
||||
applySkillCapabilityTagsInternal,
|
||||
backfillDigestVersionSummary,
|
||||
backfillLatestVersionSummaryInternal,
|
||||
backfillPublisherStatsInternalHandler,
|
||||
backfillSkillSearchDigestInternal,
|
||||
backfillSkillFingerprintsInternalHandler,
|
||||
backfillSkillSummariesInternalHandler,
|
||||
backfillUserStatsInternalHandler,
|
||||
cleanupEmptySkillsInternalHandler,
|
||||
nominateEmptySkillSpammersInternalHandler,
|
||||
repairLegacyPublisherOwnershipForUserHandler,
|
||||
repairLegacyPublisherOwnershipHandler,
|
||||
upsertSkillBadgeRecordInternal,
|
||||
} = await import("./maintenance");
|
||||
const { internal } = await import("./_generated/api");
|
||||
@@ -59,6 +66,670 @@ function makeBlob(text: string) {
|
||||
return { text: () => Promise.resolve(text) } as unknown as Blob;
|
||||
}
|
||||
|
||||
type QueryEq = {
|
||||
eq: (field: string, value: unknown) => QueryEq;
|
||||
};
|
||||
|
||||
function makeLegacyPublisherOwnershipDb() {
|
||||
const now = 1_717_456_000_000;
|
||||
let nextPublisherId = 2;
|
||||
let nextMemberId = 1;
|
||||
const users = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"users:legacy",
|
||||
{
|
||||
_id: "users:legacy",
|
||||
_creationTime: now - 1000,
|
||||
handle: "legacy-owner",
|
||||
name: "Legacy Owner",
|
||||
displayName: "Legacy Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"users:deleted",
|
||||
{
|
||||
_id: "users:deleted",
|
||||
_creationTime: now - 1000,
|
||||
handle: "deleted-owner",
|
||||
deletedAt: now - 10,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const publishers = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"publishers:existing",
|
||||
{
|
||||
_id: "publishers:existing",
|
||||
_creationTime: now - 500,
|
||||
kind: "user",
|
||||
handle: "existing-owner",
|
||||
displayName: "Existing Owner",
|
||||
linkedUserId: "users:existing",
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 0,
|
||||
totalStars: 0,
|
||||
skillTotalInstalls: 0,
|
||||
skillTotalDownloads: 0,
|
||||
skillTotalStars: 0,
|
||||
createdAt: now - 500,
|
||||
updatedAt: now - 500,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const publisherMembers = new Map<string, Record<string, unknown>>();
|
||||
const skills = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skills:legacy",
|
||||
{
|
||||
_id: "skills:legacy",
|
||||
_creationTime: now - 400,
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:legacy",
|
||||
tags: { latest: "skillVersions:legacy" },
|
||||
stats: {
|
||||
downloads: 10,
|
||||
stars: 3,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
statsDownloads: 10,
|
||||
statsStars: 3,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 5,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
[
|
||||
"skills:deleted-owner",
|
||||
{
|
||||
_id: "skills:deleted-owner",
|
||||
_creationTime: now - 400,
|
||||
slug: "deleted-owner-skill",
|
||||
displayName: "Deleted Owner Skill",
|
||||
ownerUserId: "users:deleted",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:deleted-owner",
|
||||
tags: { latest: "skillVersions:deleted-owner" },
|
||||
stats: {
|
||||
downloads: 1,
|
||||
stars: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillVersions = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillVersions:legacy",
|
||||
{
|
||||
_id: "skillVersions:legacy",
|
||||
skillId: "skills:legacy",
|
||||
version: "1.0.0",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:deleted-owner",
|
||||
{
|
||||
_id: "skillVersions:deleted-owner",
|
||||
skillId: "skills:deleted-owner",
|
||||
version: "1.0.0",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillSlugAliases = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillSlugAliases:legacy",
|
||||
{
|
||||
_id: "skillSlugAliases:legacy",
|
||||
slug: "old-legacy-skill",
|
||||
skillId: "skills:legacy",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
createdAt: now - 250,
|
||||
updatedAt: now - 250,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillEmbeddings = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillEmbeddings:legacy",
|
||||
{
|
||||
_id: "skillEmbeddings:legacy",
|
||||
skillId: "skills:legacy",
|
||||
versionId: "skillVersions:legacy",
|
||||
ownerId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
embedding: [0.1, 0.2],
|
||||
isLatest: true,
|
||||
isApproved: true,
|
||||
visibility: "public",
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillSearchDigest = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillSearchDigest:legacy",
|
||||
{
|
||||
_id: "skillSearchDigest:legacy",
|
||||
skillId: "skills:legacy",
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
ownerHandle: "legacy-owner",
|
||||
ownerKind: "user",
|
||||
stats: {
|
||||
downloads: 10,
|
||||
stars: 3,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
statsDownloads: 10,
|
||||
statsStars: 3,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 5,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packages = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"packages:legacy",
|
||||
{
|
||||
_id: "packages:legacy",
|
||||
_creationTime: now - 400,
|
||||
name: "@legacy-owner/demo-plugin",
|
||||
normalizedName: "@legacy-owner/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
summary: "Demo package",
|
||||
latestReleaseId: undefined,
|
||||
tags: {},
|
||||
compatibility: undefined,
|
||||
capabilities: undefined,
|
||||
verification: undefined,
|
||||
scanStatus: "clean",
|
||||
stats: { downloads: 7, installs: 4, stars: 2, versions: 1 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageSearchDigest = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"packageSearchDigest:legacy",
|
||||
{
|
||||
_id: "packageSearchDigest:legacy",
|
||||
packageId: "packages:legacy",
|
||||
name: "@legacy-owner/demo-plugin",
|
||||
normalizedName: "@legacy-owner/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
ownerHandle: "legacy-owner",
|
||||
ownerKind: "user",
|
||||
summary: "Demo package",
|
||||
scanStatus: "clean",
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageCapabilitySearchDigest = new Map<string, Record<string, unknown>>();
|
||||
const packagePluginCategorySearchDigest = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const tableMap: Record<string, Map<string, Record<string, unknown>>> = {
|
||||
users,
|
||||
publishers,
|
||||
publisherMembers,
|
||||
skills,
|
||||
skillVersions,
|
||||
skillSlugAliases,
|
||||
skillEmbeddings,
|
||||
skillSearchDigest,
|
||||
packages,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
packagePluginCategorySearchDigest,
|
||||
};
|
||||
const patchCalls: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const insertCalls: Array<{ table: string; value: Record<string, unknown> }> = [];
|
||||
|
||||
const getRows = (table: string) => Array.from(tableMap[table]?.values() ?? []);
|
||||
const getTableForId = (id: string) => id.split(":")[0];
|
||||
const readField = (row: Record<string, unknown>, field: string) =>
|
||||
field.split(".").reduce<unknown>((value, part) => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[part];
|
||||
}, row);
|
||||
const makeQuery = (table: string, rows: Record<string, unknown>[]) => ({
|
||||
collect: vi.fn(async () => rows),
|
||||
unique: vi.fn(async () => rows[0] ?? null),
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) =>
|
||||
paginateRows(rows, cursor, numItems),
|
||||
),
|
||||
})),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) =>
|
||||
paginateRows(rows, cursor, numItems),
|
||||
),
|
||||
withIndex: vi.fn((indexName: string, build?: (q: QueryEq) => unknown) => {
|
||||
const filters: Array<{ field: string; value: unknown }> = [];
|
||||
const q: QueryEq = {
|
||||
eq: (field, value) => {
|
||||
filters.push({ field, value });
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build?.(q);
|
||||
let indexedRows = getRows(table).filter((row) =>
|
||||
filters.every((filter) => readField(row, filter.field) === filter.value),
|
||||
);
|
||||
if (table === "users" && indexName === "by_active_handle") {
|
||||
indexedRows = indexedRows.filter(
|
||||
(row) => row.deletedAt === undefined && row.deactivatedAt === undefined,
|
||||
);
|
||||
}
|
||||
return makeQuery(table, indexedRows);
|
||||
}),
|
||||
});
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => tableMap[getTableForId(id)]?.get(id) ?? null),
|
||||
query: vi.fn((table: string) => makeQuery(table, getRows(table))),
|
||||
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
patchCalls.push({ id, patch });
|
||||
const row = tableMap[getTableForId(id)]?.get(id);
|
||||
if (row) Object.assign(row, patch);
|
||||
}),
|
||||
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
const id =
|
||||
table === "publishers"
|
||||
? `publishers:created${nextPublisherId++}`
|
||||
: table === "publisherMembers"
|
||||
? `publisherMembers:created${nextMemberId++}`
|
||||
: `${table}:created`;
|
||||
insertCalls.push({ table, value });
|
||||
tableMap[table].set(id, { _id: id, _creationTime: now, ...value });
|
||||
return id;
|
||||
}),
|
||||
delete: vi.fn(async (id: string) => {
|
||||
tableMap[getTableForId(id)]?.delete(id);
|
||||
}),
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
patchCalls,
|
||||
insertCalls,
|
||||
tableMap,
|
||||
};
|
||||
}
|
||||
|
||||
function paginateRows(rows: Record<string, unknown>[], cursor: string | null, numItems: number) {
|
||||
const start = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(start, start + numItems);
|
||||
const next = start + page.length;
|
||||
return {
|
||||
page,
|
||||
continueCursor: next >= rows.length ? null : String(next),
|
||||
isDone: next >= rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("maintenance legacy publisher ownership repair", () => {
|
||||
it("dry-runs legacy publisher ownership repair without writes", async () => {
|
||||
const { db, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: true, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: true,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(patchCalls).toEqual([]);
|
||||
expect(insertCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports dry-run personal publisher handle conflicts without writes", async () => {
|
||||
const { db, tableMap, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
tableMap.users.set("users:conflict", {
|
||||
_id: "users:conflict",
|
||||
_creationTime: 1_717_456_000_000 - 1000,
|
||||
handle: "existing-owner",
|
||||
name: "Conflicting Owner",
|
||||
displayName: "Conflicting Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
});
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: true, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: true,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
errors: ['user:users:conflict: Publisher handle "@existing-owner" is already claimed'],
|
||||
});
|
||||
expect(patchCalls).toEqual([]);
|
||||
expect(insertCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips apply-mode personal publisher handle conflicts while repairing other users", async () => {
|
||||
const { db, tableMap } = makeLegacyPublisherOwnershipDb();
|
||||
tableMap.users.set("users:conflict", {
|
||||
_id: "users:conflict",
|
||||
_creationTime: 1_717_456_000_000 - 1000,
|
||||
handle: "existing-owner",
|
||||
name: "Conflicting Owner",
|
||||
displayName: "Conflicting Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
});
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: false, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
const createdPublisher = Array.from(tableMap.publishers.values()).find(
|
||||
(publisher) => publisher.handle === "legacy-owner",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
errors: ['user:users:conflict: Publisher handle "@existing-owner" is already claimed'],
|
||||
});
|
||||
expect(createdPublisher).toMatchObject({
|
||||
kind: "user",
|
||||
linkedUserId: "users:legacy",
|
||||
});
|
||||
expect(tableMap.users.get("users:legacy")).toMatchObject({
|
||||
personalPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.users.get("users:conflict")).not.toHaveProperty("personalPublisherId");
|
||||
});
|
||||
|
||||
it("repairs active legacy users, skills, aliases, embeddings, and packages", async () => {
|
||||
const { db, tableMap, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
const usersResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
const createdPublisher = Array.from(tableMap.publishers.values()).find(
|
||||
(publisher) => publisher.handle === "legacy-owner",
|
||||
);
|
||||
expect(usersResult).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(createdPublisher).toMatchObject({
|
||||
kind: "user",
|
||||
handle: "legacy-owner",
|
||||
displayName: "Legacy Owner",
|
||||
linkedUserId: "users:legacy",
|
||||
});
|
||||
expect(tableMap.users.get("users:legacy")).toMatchObject({
|
||||
personalPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(insertCalls.some((call) => call.table === "publisherMembers")).toBe(true);
|
||||
|
||||
const skillsResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
expect(skillsResult).toMatchObject({
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:deleted-owner")).toMatchObject({
|
||||
ownerPublisherId: undefined,
|
||||
});
|
||||
expect(tableMap.skillSlugAliases.get("skillSlugAliases:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skillEmbeddings.get("skillEmbeddings:legacy")).not.toHaveProperty(
|
||||
"ownerPublisherId",
|
||||
createdPublisher?._id,
|
||||
);
|
||||
|
||||
const packagesResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
expect(packagesResult).toMatchObject({
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.packages.get("packages:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(patchCalls.some((call) => call.id === "skillSearchDigest:legacy")).toBe(false);
|
||||
expect(patchCalls.some((call) => call.id === "packageSearchDigest:legacy")).toBe(false);
|
||||
expect(
|
||||
patchCalls.some(
|
||||
(call) =>
|
||||
call.id === createdPublisher?._id &&
|
||||
("publishedSkills" in call.patch || "publishedPackages" in call.patch),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("repairs legacy owner projections for one targeted user by handle", async () => {
|
||||
const { db, tableMap } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
const createdPublisher = Array.from(tableMap.publishers.values()).find(
|
||||
(publisher) => publisher.handle === "legacy-owner",
|
||||
);
|
||||
|
||||
const skillsResult = await repairLegacyPublisherOwnershipForUserHandler(
|
||||
{ db, scheduler } as never,
|
||||
{
|
||||
handle: "legacy-owner",
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
},
|
||||
);
|
||||
expect(skillsResult).toMatchObject({
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
userId: "users:legacy",
|
||||
publisherId: createdPublisher?._id,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:deleted-owner")).toMatchObject({
|
||||
ownerPublisherId: undefined,
|
||||
});
|
||||
expect(tableMap.skillSlugAliases.get("skillSlugAliases:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skillEmbeddings.get("skillEmbeddings:legacy")).not.toHaveProperty(
|
||||
"ownerPublisherId",
|
||||
createdPublisher?._id,
|
||||
);
|
||||
|
||||
const packagesResult = await repairLegacyPublisherOwnershipForUserHandler(
|
||||
{ db, scheduler } as never,
|
||||
{
|
||||
handle: "legacy-owner",
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
},
|
||||
);
|
||||
expect(packagesResult).toMatchObject({
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
userId: "users:legacy",
|
||||
publisherId: createdPublisher?._id,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.packages.get("packages:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not touch skill embeddings during apply-mode skill repair", async () => {
|
||||
const { db } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
|
||||
const patch = db.patch;
|
||||
db.patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (id === "skillEmbeddings:legacy") throw new Error("embedding sync failed");
|
||||
await patch(id, value);
|
||||
});
|
||||
|
||||
await expect(
|
||||
repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
phase: "skills",
|
||||
repaired: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates apply-mode package patch failures", async () => {
|
||||
const { db } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
|
||||
const patch = db.patch;
|
||||
db.patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (id === "packages:legacy") throw new Error("package patch failed");
|
||||
await patch(id, value);
|
||||
});
|
||||
|
||||
await expect(
|
||||
repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
}),
|
||||
).rejects.toThrow("package patch failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance backfill", () => {
|
||||
it("patches stale skill search digest rank stats from legacy skill stats", async () => {
|
||||
const existingDigest = {
|
||||
@@ -514,6 +1185,54 @@ describe("maintenance backfill", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("backfills denormalized publisher stats through the recompute mutation", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [{ _id: "publishers:1" }, { _id: "publishers:2" }],
|
||||
cursor: "next",
|
||||
isDone: false,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await backfillPublisherStatsInternalHandler({ runQuery, runMutation } as never, {
|
||||
dryRun: true,
|
||||
batchSize: 2,
|
||||
maxBatches: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
stats: {
|
||||
publishersScanned: 2,
|
||||
publishersPatched: 0,
|
||||
},
|
||||
isDone: false,
|
||||
cursor: "next",
|
||||
});
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
internal.maintenance.getPublisherStatsBackfillPageInternal,
|
||||
{
|
||||
cursor: undefined,
|
||||
batchSize: 2,
|
||||
},
|
||||
);
|
||||
expect(runMutation).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
internal.maintenance.recomputePublisherStatsInternal,
|
||||
{
|
||||
publisherId: "publishers:1",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
expect(runMutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
internal.maintenance.recomputePublisherStatsInternal,
|
||||
{
|
||||
publisherId: "publishers:2",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance badge denormalization", () => {
|
||||
|
||||
+574
-1
@@ -1,10 +1,20 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { assertRole, requireUserFromAction } from "./lib/access";
|
||||
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
|
||||
import {
|
||||
derivePersonalPublisherHandle,
|
||||
ensurePersonalPublisherForUser,
|
||||
getPersonalPublisherForUser,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
isPublisherActive,
|
||||
} from "./lib/publishers";
|
||||
import { recomputePublisherStats } from "./lib/publisherStats";
|
||||
import { buildSkillSummaryBackfillPatch, type ParsedSkillData } from "./lib/skillBackfill";
|
||||
import { deriveSkillCapabilityTags } from "./lib/skillCapabilityTags";
|
||||
import { isSkillCardPath } from "./lib/skillCards";
|
||||
@@ -48,6 +58,11 @@ type UserStatsBackfillStats = {
|
||||
usersPatched: number;
|
||||
};
|
||||
|
||||
type PublisherStatsBackfillStats = {
|
||||
publishersScanned: number;
|
||||
publishersPatched: number;
|
||||
};
|
||||
|
||||
type BackfillPageItem =
|
||||
| {
|
||||
kind: "ok";
|
||||
@@ -75,12 +90,44 @@ type UserStatsBackfillPageResult = {
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type PublisherStatsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"publishers">, "_id">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type UserOwnedSkillsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"skills">, "stats" | "softDeletedAt">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type LegacyPublisherOwnershipPhase = "users" | "skills" | "packages";
|
||||
type LegacyPublisherOwnershipTargetPhase = Exclude<LegacyPublisherOwnershipPhase, "users">;
|
||||
|
||||
type LegacyPublisherOwnershipRepairResult = {
|
||||
phase: LegacyPublisherOwnershipPhase;
|
||||
dryRun: boolean;
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
nextPhase?: LegacyPublisherOwnershipPhase;
|
||||
};
|
||||
|
||||
type LegacyPublisherOwnershipForUserRepairResult = Omit<
|
||||
LegacyPublisherOwnershipRepairResult,
|
||||
"phase" | "nextPhase"
|
||||
> & {
|
||||
phase: LegacyPublisherOwnershipTargetPhase;
|
||||
userId: Id<"users">;
|
||||
handle?: string;
|
||||
publisherId: Id<"publishers"> | null;
|
||||
nextPhase?: LegacyPublisherOwnershipTargetPhase;
|
||||
};
|
||||
|
||||
export const getSkillBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
@@ -179,6 +226,25 @@ export const getUserStatsBackfillPageInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPublisherStatsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublisherStatsBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("publishers")
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
return {
|
||||
items: page.map((publisher) => ({ _id: publisher._id })),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserOwnedSkillsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
@@ -219,6 +285,20 @@ export const applyUserStatsBackfillPatchInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const recomputePublisherStatsInternal = internalMutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const stats = await recomputePublisherStats(ctx, args.publisherId);
|
||||
if (!args.dryRun) {
|
||||
await ctx.db.patch(args.publisherId, stats);
|
||||
}
|
||||
return { ok: true as const, stats };
|
||||
},
|
||||
});
|
||||
|
||||
export type BackfillActionArgs = {
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
@@ -248,6 +328,20 @@ export type UserStatsBackfillActionResult = {
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export type PublisherStatsBackfillActionArgs = {
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
maxBatches?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type PublisherStatsBackfillActionResult = {
|
||||
ok: true;
|
||||
stats: PublisherStatsBackfillStats;
|
||||
isDone: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export async function backfillSkillSummariesInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: BackfillActionArgs,
|
||||
@@ -410,6 +504,45 @@ export async function backfillUserStatsInternalHandler(
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export async function backfillPublisherStatsInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: PublisherStatsBackfillActionArgs,
|
||||
): Promise<PublisherStatsBackfillActionResult> {
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
|
||||
const totals: PublisherStatsBackfillStats = {
|
||||
publishersScanned: 0,
|
||||
publishersPatched: 0,
|
||||
};
|
||||
|
||||
let cursor: string | null = args.cursor ?? null;
|
||||
let isDone = false;
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const page = (await ctx.runQuery(internal.maintenance.getPublisherStatsBackfillPageInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
})) as PublisherStatsBackfillPageResult;
|
||||
|
||||
cursor = page.cursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const publisher of page.items) {
|
||||
totals.publishersScanned++;
|
||||
await ctx.runMutation(internal.maintenance.recomputePublisherStatsInternal, {
|
||||
publisherId: publisher._id,
|
||||
dryRun,
|
||||
});
|
||||
if (!dryRun) totals.publishersPatched++;
|
||||
}
|
||||
|
||||
if (isDone) break;
|
||||
}
|
||||
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export const backfillSkillSummariesInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
@@ -431,6 +564,16 @@ export const backfillUserStatsInternal = internalAction({
|
||||
handler: backfillUserStatsInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillPublisherStatsInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: backfillPublisherStatsInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
@@ -449,6 +592,37 @@ export const backfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillPublisherStats: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublisherStatsBackfillActionResult> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin"]);
|
||||
return ctx.runAction(
|
||||
internal.maintenance.backfillPublisherStatsInternal,
|
||||
args,
|
||||
) as Promise<PublisherStatsBackfillActionResult>;
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduleBackfillPublisherStats: ReturnType<typeof action> = action({
|
||||
args: { dryRun: v.optional(v.boolean()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin"]);
|
||||
await ctx.scheduler.runAfter(0, internal.maintenance.backfillPublisherStatsInternal, {
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
maxBatches: DEFAULT_MAX_BATCHES,
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduleBackfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
args: { dryRun: v.optional(v.boolean()), useAi: v.optional(v.boolean()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -2137,6 +2311,405 @@ export const backfillPackagePluginCategoryDigestsInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
function isActiveLegacyPublisherRepairUser(
|
||||
user: Doc<"users"> | null | undefined,
|
||||
): user is Doc<"users"> {
|
||||
return Boolean(user && !user.deletedAt && !user.deactivatedAt && !user.purgedAt);
|
||||
}
|
||||
|
||||
function nextLegacyPublisherOwnershipPhase(
|
||||
phase: LegacyPublisherOwnershipPhase,
|
||||
): LegacyPublisherOwnershipPhase | undefined {
|
||||
if (phase === "users") return "skills";
|
||||
if (phase === "skills") return "packages";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nextLegacyPublisherOwnershipTargetPhase(
|
||||
phase: LegacyPublisherOwnershipTargetPhase,
|
||||
): LegacyPublisherOwnershipTargetPhase | undefined {
|
||||
return phase === "skills" ? "packages" : undefined;
|
||||
}
|
||||
|
||||
async function getExistingActivePersonalPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
const publisher = await getPersonalPublisherForUser(ctx, user._id);
|
||||
return isPublisherActive(publisher) ? publisher : null;
|
||||
}
|
||||
|
||||
async function needsPersonalPublisherRepair(ctx: Pick<MutationCtx, "db">, user: Doc<"users">) {
|
||||
const publisher = await getExistingActivePersonalPublisher(ctx, user);
|
||||
if (!publisher) return true;
|
||||
if (user.personalPublisherId !== publisher._id) return true;
|
||||
if (publisher.kind !== "user" || publisher.linkedUserId !== user._id) return true;
|
||||
const member = await getPublisherMembership(ctx, publisher._id, user._id);
|
||||
return !member;
|
||||
}
|
||||
|
||||
function pushRepairError(errors: string[], label: string, error: unknown) {
|
||||
if (errors.length >= 10) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${label}: ${message}`);
|
||||
}
|
||||
|
||||
function isPublisherHandleConflictError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Publisher handle "@[^"]+" is already claimed/.test(message);
|
||||
}
|
||||
|
||||
async function resolvePersonalPublisherForOwnershipRepair(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
user: Doc<"users">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (dryRun) {
|
||||
const existing = await getExistingActivePersonalPublisher(ctx, user);
|
||||
if (existing) return existing;
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
const conflict = await getPublisherByHandle(ctx, handle);
|
||||
if (conflict && conflict.linkedUserId !== user._id) {
|
||||
throw new ConvexError(`Publisher handle "@${handle}" is already claimed`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return await ensurePersonalPublisherForUser(ctx, user, {
|
||||
source: "maintenance.legacy_publisher_ownership",
|
||||
});
|
||||
}
|
||||
|
||||
async function repairLegacySkillOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skill: Doc<"skills">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (skill.ownerPublisherId) return "skipped" as const;
|
||||
const owner = await ctx.db.get(skill.ownerUserId);
|
||||
if (!isActiveLegacyPublisherRepairUser(owner)) return "skipped" as const;
|
||||
|
||||
const publisher = await resolvePersonalPublisherForOwnershipRepair(ctx, owner, dryRun);
|
||||
if (!dryRun && (!publisher || !isPublisherActive(publisher))) return "skipped" as const;
|
||||
if (dryRun) return "repaired" as const;
|
||||
|
||||
// The trigger-wrapped mutation syncs skill search digest and publisher stats.
|
||||
// This repair only patches owner projections that remain source-of-truth.
|
||||
await ctx.db.patch(skill._id, { ownerPublisherId: publisher!._id });
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
if (alias.ownerPublisherId === publisher!._id) continue;
|
||||
await ctx.db.patch(alias._id, { ownerPublisherId: publisher!._id });
|
||||
}
|
||||
|
||||
return "repaired" as const;
|
||||
}
|
||||
|
||||
async function repairLegacyPackageOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (pkg.ownerPublisherId) return "skipped" as const;
|
||||
const owner = await ctx.db.get(pkg.ownerUserId);
|
||||
if (!isActiveLegacyPublisherRepairUser(owner)) return "skipped" as const;
|
||||
|
||||
const publisher = await resolvePersonalPublisherForOwnershipRepair(ctx, owner, dryRun);
|
||||
if (!dryRun && (!publisher || !isPublisherActive(publisher))) return "skipped" as const;
|
||||
if (dryRun) return "repaired" as const;
|
||||
|
||||
// The trigger-wrapped mutation syncs package search digests and publisher stats.
|
||||
await ctx.db.patch(pkg._id, { ownerPublisherId: publisher!._id });
|
||||
return "repaired" as const;
|
||||
}
|
||||
|
||||
async function resolveLegacyPublisherOwnershipTargetUser(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: { userId?: Id<"users">; handle?: string },
|
||||
) {
|
||||
const user = args.userId
|
||||
? await ctx.db.get(args.userId)
|
||||
: await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
||||
if (!user) throw new ConvexError("Target user not found");
|
||||
if (!isActiveLegacyPublisherRepairUser(user)) throw new ConvexError("Target user is inactive");
|
||||
return user;
|
||||
}
|
||||
|
||||
async function patchLegacySkillOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skill: Doc<"skills">,
|
||||
publisherId: Id<"publishers">,
|
||||
) {
|
||||
await ctx.db.patch(skill._id, { ownerPublisherId: publisherId });
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
if (alias.ownerPublisherId === publisherId) continue;
|
||||
await ctx.db.patch(alias._id, { ownerPublisherId: publisherId });
|
||||
}
|
||||
}
|
||||
|
||||
async function patchLegacyPackageOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
publisherId: Id<"publishers">,
|
||||
) {
|
||||
await ctx.db.patch(pkg._id, { ownerPublisherId: publisherId });
|
||||
}
|
||||
|
||||
export async function repairLegacyPublisherOwnershipHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
phase?: LegacyPublisherOwnershipPhase;
|
||||
cursor?: string;
|
||||
batchSize?: number;
|
||||
delayMs?: number;
|
||||
dryRun?: boolean;
|
||||
scheduleNext?: boolean;
|
||||
},
|
||||
): Promise<LegacyPublisherOwnershipRepairResult> {
|
||||
const phase = args.phase ?? "users";
|
||||
const dryRun = args.dryRun === true;
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
|
||||
const delayMs = clampInt(args.delayMs ?? 500, 0, 60_000);
|
||||
const errors: string[] = [];
|
||||
|
||||
let scanned = 0;
|
||||
let repaired = 0;
|
||||
let skipped = 0;
|
||||
let continueCursor: string | null = null;
|
||||
let isDone = true;
|
||||
|
||||
if (phase === "users") {
|
||||
const page = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) =>
|
||||
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
||||
)
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const user of page.page) {
|
||||
scanned++;
|
||||
if (!isActiveLegacyPublisherRepairUser(user)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (!(await needsPersonalPublisherRepair(ctx, user))) continue;
|
||||
if (dryRun) {
|
||||
await resolvePersonalPublisherForOwnershipRepair(ctx, user, true);
|
||||
} else {
|
||||
await ensurePersonalPublisherForUser(ctx, user, {
|
||||
source: "maintenance.legacy_publisher_ownership",
|
||||
});
|
||||
}
|
||||
repaired++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `user:${user._id}`, error);
|
||||
}
|
||||
}
|
||||
} else if (phase === "skills") {
|
||||
const page = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", undefined))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const skill of page.page) {
|
||||
scanned++;
|
||||
try {
|
||||
const result = await repairLegacySkillOwnerPublisher(ctx, skill, dryRun);
|
||||
if (result === "repaired") repaired++;
|
||||
else skipped++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `skill:${skill._id}`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", undefined))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const pkg of page.page) {
|
||||
scanned++;
|
||||
try {
|
||||
const result = await repairLegacyPackageOwnerPublisher(ctx, pkg, dryRun);
|
||||
if (result === "repaired") repaired++;
|
||||
else skipped++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `package:${pkg._id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextPhase = isDone ? nextLegacyPublisherOwnershipPhase(phase) : phase;
|
||||
if (!dryRun && args.scheduleNext !== false && nextPhase) {
|
||||
await ctx.scheduler.runAfter(delayMs, internal.maintenance.repairLegacyPublisherOwnership, {
|
||||
phase: nextPhase,
|
||||
cursor: isDone ? undefined : (continueCursor ?? undefined),
|
||||
batchSize: args.batchSize,
|
||||
delayMs: args.delayMs,
|
||||
scheduleNext: args.scheduleNext,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
dryRun,
|
||||
scanned,
|
||||
repaired,
|
||||
skipped,
|
||||
errors,
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
...(nextPhase ? { nextPhase } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function repairLegacyPublisherOwnershipForUserHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
userId?: Id<"users">;
|
||||
handle?: string;
|
||||
phase?: LegacyPublisherOwnershipTargetPhase;
|
||||
cursor?: string;
|
||||
batchSize?: number;
|
||||
delayMs?: number;
|
||||
dryRun?: boolean;
|
||||
scheduleNext?: boolean;
|
||||
},
|
||||
): Promise<LegacyPublisherOwnershipForUserRepairResult> {
|
||||
const phase = args.phase ?? "skills";
|
||||
const dryRun = args.dryRun === true;
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
|
||||
const delayMs = clampInt(args.delayMs ?? 500, 0, 60_000);
|
||||
const user = await resolveLegacyPublisherOwnershipTargetUser(ctx, args);
|
||||
const publisher = await resolvePersonalPublisherForOwnershipRepair(ctx, user, dryRun);
|
||||
if (!dryRun && !isPublisherActive(publisher)) {
|
||||
throw new ConvexError("Target personal publisher could not be repaired");
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
let repaired = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const page =
|
||||
phase === "skills"
|
||||
? await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", user._id))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
: await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", user._id))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
for (const item of page.page) {
|
||||
scanned++;
|
||||
if (item.ownerPublisherId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (dryRun) {
|
||||
repaired++;
|
||||
continue;
|
||||
}
|
||||
if (phase === "skills") {
|
||||
await patchLegacySkillOwnerPublisher(ctx, item as Doc<"skills">, publisher!._id);
|
||||
} else {
|
||||
await patchLegacyPackageOwnerPublisher(ctx, item as Doc<"packages">, publisher!._id);
|
||||
}
|
||||
repaired++;
|
||||
}
|
||||
|
||||
const nextPhase = page.isDone ? nextLegacyPublisherOwnershipTargetPhase(phase) : phase;
|
||||
if (!dryRun && args.scheduleNext !== false && nextPhase) {
|
||||
await ctx.scheduler.runAfter(
|
||||
delayMs,
|
||||
internal.maintenance.repairLegacyPublisherOwnershipForUser,
|
||||
{
|
||||
userId: user._id,
|
||||
phase: nextPhase,
|
||||
cursor: page.isDone ? undefined : (page.continueCursor ?? undefined),
|
||||
batchSize: args.batchSize,
|
||||
delayMs: args.delayMs,
|
||||
scheduleNext: args.scheduleNext,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
dryRun,
|
||||
userId: user._id,
|
||||
handle: user.handle,
|
||||
publisherId: publisher?._id ?? null,
|
||||
scanned,
|
||||
repaired,
|
||||
skipped,
|
||||
errors: [],
|
||||
cursor: page.continueCursor,
|
||||
isDone: page.isDone,
|
||||
...(nextPhase ? { nextPhase } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Repair legacy personal publisher ownership after the publisher model rollout.
|
||||
// Dry run one phase:
|
||||
// npx convex run maintenance:repairLegacyPublisherOwnership '{"phase":"skills","dryRun":true,"scheduleNext":false}' --prod
|
||||
// Apply all phases, scheduled batch-by-batch:
|
||||
// npx convex run maintenance:repairLegacyPublisherOwnership '{"phase":"users","batchSize":50}' --prod
|
||||
export const repairLegacyPublisherOwnership = internalMutation({
|
||||
args: {
|
||||
phase: v.optional(v.union(v.literal("users"), v.literal("skills"), v.literal("packages"))),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
delayMs: v.optional(v.number()),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
scheduleNext: v.optional(v.boolean()),
|
||||
},
|
||||
handler: repairLegacyPublisherOwnershipHandler,
|
||||
});
|
||||
|
||||
// Targeted variant for production canaries and one-off account repair.
|
||||
// Example:
|
||||
// npx convex run maintenance:repairLegacyPublisherOwnershipForUser '{"handle":"harrylabsj","dryRun":true,"scheduleNext":false}' --prod
|
||||
export const repairLegacyPublisherOwnershipForUser = internalMutation({
|
||||
args: {
|
||||
userId: v.optional(v.id("users")),
|
||||
handle: v.optional(v.string()),
|
||||
phase: v.optional(v.union(v.literal("skills"), v.literal("packages"))),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
delayMs: v.optional(v.number()),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
scheduleNext: v.optional(v.boolean()),
|
||||
},
|
||||
handler: repairLegacyPublisherOwnershipForUserHandler,
|
||||
});
|
||||
|
||||
const DIGEST_OWNER_BACKFILL_KEY = "digest-owner-backfill";
|
||||
|
||||
// Start/resume backfill:
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_generated/server", () => ({
|
||||
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
}));
|
||||
|
||||
const managementDevSeed = await import("./managementDevSeed");
|
||||
|
||||
type Handler<TArgs, TResult> = (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
type Wrapped<TArgs, TResult> = { _handler: Handler<TArgs, TResult> };
|
||||
type TestDoc = Record<string, unknown> & { _id: string };
|
||||
|
||||
const seedManagementQueuesHandler = (
|
||||
managementDevSeed.seedManagementQueues as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ reportsInserted: number; reportedSkills: number; duplicatePair: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const clearManagementQueuesHandler = (
|
||||
managementDevSeed.clearManagementQueues as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ reportsDeleted: number; fingerprintsDeleted: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const DEMO_REPORT_MARKER = "managementDevSeed:report";
|
||||
const DEMO_FINGERPRINT = "9f8c2a1b7e4d6c30a5b2f1d089c4e76b";
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: TestDoc, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(seedTables: Record<string, TestDoc[]>) {
|
||||
const tables = Object.fromEntries(
|
||||
Object.entries(seedTables).map(([name, docs]) => [name, docs.map((doc) => ({ ...doc }))]),
|
||||
);
|
||||
const queryCalls: Array<{
|
||||
table: string;
|
||||
indexName: string;
|
||||
constraints: Record<string, unknown>;
|
||||
}> = [];
|
||||
const inserts: Array<{ table: string; doc: TestDoc }> = [];
|
||||
let insertCounter = 0;
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const takeRows = (table: string, numItems: number, constraints?: Record<string, unknown>) => {
|
||||
const rows = constraints ? list(table).filter((doc) => matches(doc, constraints)) : list(table);
|
||||
return rows.slice(0, numItems);
|
||||
};
|
||||
|
||||
return {
|
||||
inserts,
|
||||
queryCalls,
|
||||
tables,
|
||||
db: {
|
||||
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);
|
||||
},
|
||||
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>) => {
|
||||
const inserted = { ...doc, _id: `${table}:inserted-${insertCounter}` };
|
||||
insertCounter += 1;
|
||||
list(table).push(inserted);
|
||||
inserts.push({ table, doc: inserted });
|
||||
return inserted._id;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const row = list(table).find((doc) => doc._id === id);
|
||||
if (row) Object.assign(row, patch);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
order: () => ({
|
||||
take: async (numItems: number) => takeRows(table, numItems),
|
||||
}),
|
||||
take: async (numItems: number) => takeRows(table, numItems),
|
||||
withIndex: (indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
queryCalls.push({ table, indexName, constraints });
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (numItems: number) => takeRows(table, numItems, constraints),
|
||||
}),
|
||||
take: async (numItems: number) => takeRows(table, numItems, constraints),
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
|
||||
describe("managementDevSeed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthEnabled = process.env.DEV_AUTH_ENABLED;
|
||||
const previousDevImpersonation = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
restoreEnv("DEV_AUTH_ENABLED", previousDevAuthEnabled);
|
||||
restoreEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION", previousDevImpersonation);
|
||||
});
|
||||
|
||||
it("rejects production deployments before reading tables", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
await expect(clearManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors the explicit fallback deployment when the primary marker is blank", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
await expect(clearManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds content report and duplicate candidate rows for local dashboards", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, inserts, tables } = createDb({
|
||||
users: [{ _id: "users:reporter", handle: "local-admin" }],
|
||||
skills: [
|
||||
{ _id: "skills:one", latestVersionId: "skillVersions:one" },
|
||||
{ _id: "skills:two", latestVersionId: "skillVersions:two" },
|
||||
{ _id: "skills:three", latestVersionId: "skillVersions:three" },
|
||||
{ _id: "skills:hidden", latestVersionId: "skillVersions:hidden", softDeletedAt: 1 },
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:one" },
|
||||
{ _id: "skillVersions:two" },
|
||||
{ _id: "skillVersions:three" },
|
||||
{ _id: "skillVersions:hidden" },
|
||||
],
|
||||
skillReports: [],
|
||||
skillVersionFingerprints: [],
|
||||
});
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsInserted: 6,
|
||||
reportedSkills: 3,
|
||||
duplicatePair: 2,
|
||||
});
|
||||
|
||||
expect(tables.skillReports).toHaveLength(6);
|
||||
expect(tables.skillReports.every((report) => report.triageNote === DEMO_REPORT_MARKER)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:one")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:two")).toEqual(
|
||||
expect.objectContaining({ reportCount: 2 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:three")).toEqual(
|
||||
expect.objectContaining({ reportCount: 3 }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:one")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:two")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(inserts.filter((insert) => insert.table === "skillVersionFingerprints")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not overwrite existing latest-version fingerprints", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, inserts, tables } = createDb({
|
||||
users: [{ _id: "users:reporter", handle: "local-admin" }],
|
||||
skills: [
|
||||
{ _id: "skills:one", latestVersionId: "skillVersions:one" },
|
||||
{ _id: "skills:two", latestVersionId: "skillVersions:two" },
|
||||
{ _id: "skills:three", latestVersionId: "skillVersions:three" },
|
||||
{ _id: "skills:four", latestVersionId: "skillVersions:four" },
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:one", fingerprint: "real-fingerprint-one" },
|
||||
{ _id: "skillVersions:two", fingerprint: "real-fingerprint-two" },
|
||||
{ _id: "skillVersions:three" },
|
||||
{ _id: "skillVersions:four" },
|
||||
],
|
||||
skillReports: [],
|
||||
skillVersionFingerprints: [
|
||||
{
|
||||
_id: "skillVersionFingerprints:real-one",
|
||||
skillId: "skills:one",
|
||||
versionId: "skillVersions:one",
|
||||
fingerprint: "real-fingerprint-one",
|
||||
},
|
||||
{
|
||||
_id: "skillVersionFingerprints:real-two",
|
||||
skillId: "skills:two",
|
||||
versionId: "skillVersions:two",
|
||||
fingerprint: "real-fingerprint-two",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsInserted: 6,
|
||||
reportedSkills: 3,
|
||||
duplicatePair: 2,
|
||||
});
|
||||
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:one")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint-one" }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:two")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint-two" }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:three")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:four")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(
|
||||
inserts
|
||||
.filter((insert) => insert.table === "skillVersionFingerprints")
|
||||
.map((insert) => insert.doc.versionId),
|
||||
).toEqual(["skillVersions:three", "skillVersions:four"]);
|
||||
});
|
||||
|
||||
it("clears only marked demo management rows", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, queryCalls, tables } = createDb({
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:demo",
|
||||
reportCount: 2,
|
||||
lastReportedAt: 200,
|
||||
},
|
||||
{
|
||||
_id: "skills:real",
|
||||
reportCount: 1,
|
||||
lastReportedAt: 200,
|
||||
},
|
||||
],
|
||||
skillReports: [
|
||||
{
|
||||
_id: "skillReports:demo",
|
||||
skillId: "skills:demo",
|
||||
triageNote: DEMO_REPORT_MARKER,
|
||||
status: "open",
|
||||
createdAt: 100,
|
||||
},
|
||||
{
|
||||
_id: "skillReports:demo-real",
|
||||
skillId: "skills:demo",
|
||||
triageNote: "real-user-report",
|
||||
status: "open",
|
||||
createdAt: 200,
|
||||
},
|
||||
{
|
||||
_id: "skillReports:real",
|
||||
skillId: "skills:real",
|
||||
triageNote: "real-user-report",
|
||||
status: "open",
|
||||
createdAt: 200,
|
||||
},
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:demo", fingerprint: DEMO_FINGERPRINT },
|
||||
{ _id: "skillVersions:real", fingerprint: "real-fingerprint" },
|
||||
],
|
||||
skillVersionFingerprints: [
|
||||
{
|
||||
_id: "skillVersionFingerprints:demo",
|
||||
versionId: "skillVersions:demo",
|
||||
fingerprint: DEMO_FINGERPRINT,
|
||||
},
|
||||
{
|
||||
_id: "skillVersionFingerprints:real",
|
||||
versionId: "skillVersions:real",
|
||||
fingerprint: "real-fingerprint",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(clearManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsDeleted: 1,
|
||||
fingerprintsDeleted: 1,
|
||||
});
|
||||
|
||||
expect(tables.skillReports.map((report) => report._id)).toEqual([
|
||||
"skillReports:demo-real",
|
||||
"skillReports:real",
|
||||
]);
|
||||
expect(tables.skillVersionFingerprints.map((fingerprint) => fingerprint._id)).toEqual([
|
||||
"skillVersionFingerprints:real",
|
||||
]);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:demo")).toEqual(
|
||||
expect.objectContaining({ fingerprint: undefined }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:real")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint" }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:demo")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1, lastReportedAt: 200 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:real")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1, lastReportedAt: 200 }),
|
||||
);
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "skillVersionFingerprints",
|
||||
indexName: "by_fingerprint",
|
||||
constraints: { fingerprint: DEMO_FINGERPRINT },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "skillReports",
|
||||
indexName: "by_skill_createdAt",
|
||||
constraints: { skillId: "skills:demo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
// DEV-ONLY seed for the management Content-reports and Duplicate-candidates queues.
|
||||
// Uses the un-wrapped mutation builder (not convex/functions.ts) so patching skills
|
||||
// / versions and inserting report + fingerprint rows does NOT fire table triggers.
|
||||
// It operates on existing seeded skills rather than creating new ones, so the base
|
||||
// dev seed must have run first. All demo rows carry a marker so clearDemo can remove
|
||||
// them precisely.
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { assertLocalDevSeedAllowed } from "./lib/devSeed";
|
||||
|
||||
const DEMO_REPORT_MARKER = "managementDevSeed:report";
|
||||
// Hash-like so the dashboard's fingerprint chip reads like real data; still a
|
||||
// fixed constant so clearDemo can find and remove the seeded rows.
|
||||
const DEMO_FINGERPRINT = "9f8c2a1b7e4d6c30a5b2f1d089c4e76b";
|
||||
|
||||
const DEMO_REPORT_REASONS = [
|
||||
"Possible prompt-injection hidden in the skill instructions.",
|
||||
"Looks like a copy of another publisher's skill.",
|
||||
"Requests credentials it does not appear to need.",
|
||||
"Spammy catalog filler with no real functionality.",
|
||||
];
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const REPORT_SCAN_LIMIT = 500;
|
||||
const SKILL_SCAN_LIMIT = 50;
|
||||
|
||||
type DuplicateDemoTarget = {
|
||||
skill: Doc<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
};
|
||||
|
||||
// Remove previously seeded demo reports + duplicate fingerprints so the seed is
|
||||
// idempotent and the dashboard can be reset.
|
||||
async function clearDemo(ctx: Pick<MutationCtx, "db">): Promise<{
|
||||
reportsDeleted: number;
|
||||
fingerprintsDeleted: number;
|
||||
}> {
|
||||
let reportsDeleted = 0;
|
||||
let fingerprintsDeleted = 0;
|
||||
|
||||
const affectedSkillIds = new Set<Id<"skills">>();
|
||||
const reports = await ctx.db.query("skillReports").order("desc").take(REPORT_SCAN_LIMIT);
|
||||
for (const report of reports) {
|
||||
if (report.triageNote !== DEMO_REPORT_MARKER) continue;
|
||||
affectedSkillIds.add(report.skillId);
|
||||
await ctx.db.delete(report._id);
|
||||
reportsDeleted += 1;
|
||||
}
|
||||
for (const skillId of affectedSkillIds) {
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (!skill) continue;
|
||||
await restoreSkillReportSummary(ctx, skillId);
|
||||
}
|
||||
|
||||
const fingerprints = await ctx.db
|
||||
.query("skillVersionFingerprints")
|
||||
.withIndex("by_fingerprint", (q) => q.eq("fingerprint", DEMO_FINGERPRINT))
|
||||
.take(100);
|
||||
for (const fingerprint of fingerprints) {
|
||||
const version = await ctx.db.get(fingerprint.versionId);
|
||||
if (version && version.fingerprint === DEMO_FINGERPRINT) {
|
||||
await ctx.db.patch(fingerprint.versionId, { fingerprint: undefined });
|
||||
}
|
||||
await ctx.db.delete(fingerprint._id);
|
||||
fingerprintsDeleted += 1;
|
||||
}
|
||||
|
||||
return { reportsDeleted, fingerprintsDeleted };
|
||||
}
|
||||
|
||||
async function restoreSkillReportSummary(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skillId: Id<"skills">,
|
||||
): Promise<void> {
|
||||
const reports = await ctx.db
|
||||
.query("skillReports")
|
||||
.withIndex("by_skill_createdAt", (q) => q.eq("skillId", skillId))
|
||||
.order("desc")
|
||||
.take(REPORT_SCAN_LIMIT);
|
||||
const openReports = reports.filter((report) => (report.status ?? "open") === "open");
|
||||
|
||||
await ctx.db.patch(skillId, {
|
||||
reportCount: openReports.length > 0 ? openReports.length : undefined,
|
||||
lastReportedAt: openReports[0]?.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
async function findDuplicateDemoTargets(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skills: Doc<"skills">[],
|
||||
): Promise<DuplicateDemoTarget[]> {
|
||||
const targets: DuplicateDemoTarget[] = [];
|
||||
for (const skill of skills) {
|
||||
const versionId = skill.latestVersionId;
|
||||
if (!versionId) continue;
|
||||
const version = await ctx.db.get(versionId);
|
||||
if (!version || version.fingerprint) continue;
|
||||
targets.push({ skill, versionId });
|
||||
if (targets.length === 2) break;
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
export const seedManagementQueues = internalMutation({
|
||||
args: {},
|
||||
handler: async (
|
||||
ctx,
|
||||
): Promise<{
|
||||
reportsInserted: number;
|
||||
reportedSkills: number;
|
||||
duplicatePair: number;
|
||||
}> => {
|
||||
assertLocalDevSeedAllowed("Management");
|
||||
await clearDemo(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
const reporter = (await ctx.db.query("users").take(1))[0];
|
||||
if (!reporter) {
|
||||
throw new Error("No users found to attribute demo reports to; run the base dev seed first.");
|
||||
}
|
||||
|
||||
const skills = (await ctx.db.query("skills").order("desc").take(SKILL_SCAN_LIMIT)).filter(
|
||||
(skill) => !skill.softDeletedAt && skill.latestVersionId,
|
||||
);
|
||||
if (skills.length < 2) {
|
||||
throw new Error("Need at least 2 seeded skills; run the base dev seed first.");
|
||||
}
|
||||
|
||||
// Content reports: flag the first few skills with 1-3 reports each.
|
||||
const reportTargets = skills.slice(0, Math.min(3, skills.length));
|
||||
let reportsInserted = 0;
|
||||
for (let i = 0; i < reportTargets.length; i += 1) {
|
||||
const skill = reportTargets[i];
|
||||
const count = 1 + (i % 3);
|
||||
for (let r = 0; r < count; r += 1) {
|
||||
await ctx.db.insert("skillReports", {
|
||||
skillId: skill._id,
|
||||
userId: reporter._id,
|
||||
reason: DEMO_REPORT_REASONS[(i + r) % DEMO_REPORT_REASONS.length],
|
||||
status: "open",
|
||||
triageNote: DEMO_REPORT_MARKER,
|
||||
createdAt: now - (i * 3 + r) * HOUR_MS,
|
||||
});
|
||||
reportsInserted += 1;
|
||||
}
|
||||
await ctx.db.patch(skill._id, {
|
||||
reportCount: count,
|
||||
lastReportedAt: now - i * HOUR_MS,
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate candidates: give a pair of skills the same latest-version fingerprint
|
||||
// so each surfaces the other as a near-duplicate.
|
||||
const duplicatePair = await findDuplicateDemoTargets(ctx, skills);
|
||||
for (const { skill, versionId } of duplicatePair) {
|
||||
await ctx.db.patch(versionId, { fingerprint: DEMO_FINGERPRINT });
|
||||
await ctx.db.insert("skillVersionFingerprints", {
|
||||
skillId: skill._id,
|
||||
versionId,
|
||||
fingerprint: DEMO_FINGERPRINT,
|
||||
kind: "source",
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reportsInserted,
|
||||
reportedSkills: reportTargets.length,
|
||||
duplicatePair: duplicatePair.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const clearManagementQueues = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ reportsDeleted: number; fingerprintsDeleted: number }> => {
|
||||
assertLocalDevSeedAllowed("Management");
|
||||
return clearDemo(ctx);
|
||||
},
|
||||
});
|
||||
@@ -30,8 +30,10 @@ import {
|
||||
listPublicPage,
|
||||
listPageForViewerInternal,
|
||||
listVersions,
|
||||
updateReleaseLlmAnalysisInternal,
|
||||
updateReleaseStaticScanInternal,
|
||||
applyAccountDeletionToOwnedPackagesBatchInternal,
|
||||
applyPublisherDeletionToOwnedPackagesBatchInternal,
|
||||
applyBanToOwnedPackagesBatchInternal,
|
||||
revokePackagePublishTokensForPackageBatchInternal,
|
||||
restoreOwnedPackagesForUnbanBatchInternal,
|
||||
@@ -79,6 +81,22 @@ const listHandler = (
|
||||
}>
|
||||
>
|
||||
)._handler;
|
||||
const applyPublisherDeletionToOwnedPackagesBatchInternalHandler = (
|
||||
applyPublisherDeletionToOwnedPackagesBatchInternal as unknown as WrappedHandler<
|
||||
{
|
||||
ownerPublisherId: string;
|
||||
actorUserId: string;
|
||||
deletedAt: number;
|
||||
cursor?: string;
|
||||
},
|
||||
{
|
||||
deletedCount: number;
|
||||
revokedTokenCount: number;
|
||||
scheduled: boolean;
|
||||
stale?: true;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
const getVersionByNameHandler = (
|
||||
getVersionByName as unknown as WrappedHandler<
|
||||
{ name: string; version: string },
|
||||
@@ -100,6 +118,7 @@ const listPublicPageHandler = (
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads";
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
|
||||
@@ -114,6 +133,7 @@ const listPageForViewerInternalHandler = (
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads";
|
||||
viewerUserId?: string;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -540,6 +560,23 @@ const updateReleaseStaticScanInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const updateReleaseLlmAnalysisInternalHandler = (
|
||||
updateReleaseLlmAnalysisInternal as unknown as WrappedHandler<
|
||||
{
|
||||
releaseId: string;
|
||||
llmAnalysis: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
confidence?: string;
|
||||
summary?: string;
|
||||
guidance?: string;
|
||||
findings?: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
},
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const softDeletePackageInternalHandler = (
|
||||
softDeletePackageInternal as unknown as WrappedHandler<
|
||||
{ userId: string; name: string },
|
||||
@@ -709,6 +746,8 @@ function makeReleaseDoc(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
softDeletedAt: undefined,
|
||||
createdBy: "users:owner",
|
||||
publishActor: { kind: "user", userId: "users:owner" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -729,6 +768,11 @@ function makeDigestCtx(options: {
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
packagePages?: Array<{
|
||||
page: Array<Record<string, unknown>>;
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
exactPackages?: Array<Record<string, unknown>>;
|
||||
exactDigests?: Array<Record<string, unknown>>;
|
||||
publisherDocs?: Record<string, Record<string, unknown>>;
|
||||
@@ -780,6 +824,7 @@ function makeDigestCtx(options: {
|
||||
setPages("packageSearchDigest", options.pages ?? []);
|
||||
setPages("packageCapabilitySearchDigest", options.capabilityPages ?? []);
|
||||
setPages("packagePluginCategorySearchDigest", options.categoryPages ?? []);
|
||||
setPages("packages", options.packagePages ?? []);
|
||||
|
||||
const paginate = vi.fn();
|
||||
const take = vi.fn();
|
||||
@@ -839,6 +884,8 @@ function makeDigestCtx(options: {
|
||||
ctx: {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
const exactPackage = (options.exactPackages ?? []).find((pkg) => pkg._id === id);
|
||||
if (exactPackage) return exactPackage;
|
||||
if (options.publisherDocs?.[id]) return options.publisherDocs[id];
|
||||
if (options.publisherMemberships?.[id]) return { _id: id, kind: "org" };
|
||||
return null;
|
||||
@@ -873,6 +920,9 @@ function makeDigestCtx(options: {
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
if (indexName === "by_active_downloads") {
|
||||
return withIndex(table, indexName);
|
||||
}
|
||||
if (indexName !== "by_name" && indexName !== "by_runtime_id") {
|
||||
throw new Error(`Unexpected packages index ${indexName}`);
|
||||
}
|
||||
@@ -1142,6 +1192,37 @@ function makeInsertReleaseCtx(
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const filters = new Map<string, unknown>();
|
||||
const query = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.set(field, value);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
buildQuery?.(query);
|
||||
const rawPublisherId = filters.get("publisherId");
|
||||
const publisherId = typeof rawPublisherId === "string" ? rawPublisherId : "";
|
||||
const publisher = recordsById[publisherId];
|
||||
return {
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
publisher?.handle === "openclaw"
|
||||
? { _id: "officialPublishers:openclaw", publisherId }
|
||||
: null,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
@@ -1256,6 +1337,38 @@ function makeTransferPackageOwnerCtx(options?: {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder: (q: unknown) => unknown) => {
|
||||
const terms: Record<string, unknown> = {};
|
||||
builder({
|
||||
eq: (field: string, value: unknown) => {
|
||||
terms[field] = value;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const ownerPublisher =
|
||||
terms.publisherId === "publishers:openclaw"
|
||||
? (options?.ownerPublisher ?? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
trustedPublisher: true,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
ownerPublisher?.handle === "openclaw"
|
||||
? { _id: "officialPublishers:openclaw", publisherId: terms.publisherId }
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
@@ -1407,6 +1520,13 @@ function makeUserTransferPackageOwnerCtx(options?: {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
@@ -1680,6 +1800,102 @@ describe("packages public queries", () => {
|
||||
expect(result.continueCursor).not.toContain("bravo summary");
|
||||
});
|
||||
|
||||
it("includes package stats on public list items", async () => {
|
||||
const stats = { downloads: 43, installs: 3, stars: 1, versions: 2 };
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [makeDigest("stats-demo", { stats })],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPublicPageHandler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
|
||||
expect((result.page[0] as { stats?: unknown }).stats).toEqual(stats);
|
||||
});
|
||||
|
||||
it("uses current package stats when digest stats are stale", async () => {
|
||||
const currentStats = { downloads: 99, installs: 7, stars: 2, versions: 3 };
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("stats-demo", {
|
||||
stats: { downloads: 1, installs: 0, stars: 0, versions: 1 },
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
exactPackages: [
|
||||
makePackageDoc({
|
||||
_id: "packages:stats-demo",
|
||||
name: "stats-demo",
|
||||
normalizedName: "stats-demo",
|
||||
displayName: "stats-demo",
|
||||
stats: currentStats,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPublicPageHandler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
|
||||
expect((result.page[0] as { stats?: unknown }).stats).toEqual(currentStats);
|
||||
});
|
||||
|
||||
it("continues scanning download-sorted pages until filtered public results are filled", async () => {
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
packagePages: [
|
||||
{
|
||||
page: [
|
||||
makePackageDoc({
|
||||
_id: "packages:bundle-plugin",
|
||||
name: "bundle-plugin",
|
||||
normalizedName: "bundle-plugin",
|
||||
displayName: "Bundle Plugin",
|
||||
family: "bundle-plugin",
|
||||
stats: { downloads: 500, installs: 0, stars: 0, versions: 1 },
|
||||
}),
|
||||
],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:next",
|
||||
},
|
||||
{
|
||||
page: [
|
||||
makePackageDoc({
|
||||
_id: "packages:code-plugin",
|
||||
name: "code-plugin",
|
||||
normalizedName: "code-plugin",
|
||||
displayName: "Code Plugin",
|
||||
family: "code-plugin",
|
||||
stats: { downloads: 200, installs: 0, stars: 0, versions: 1 },
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPublicPageHandler(ctx, {
|
||||
family: "code-plugin",
|
||||
sort: "downloads",
|
||||
paginationOpts: { cursor: null, numItems: 1 },
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin"]);
|
||||
expect(result.isDone).toBe(true);
|
||||
expect(paginate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("excludes private packages from public list pages", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
@@ -3773,7 +3989,7 @@ describe("packages public queries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects official package transfers to non-OpenClaw publishers", async () => {
|
||||
it("rejects official package transfers to non-official publishers", async () => {
|
||||
const { ctx } = makeTransferPackageOwnerCtx({
|
||||
ownerPublisher: {
|
||||
_id: "publishers:openclaw",
|
||||
@@ -4781,7 +4997,7 @@ describe("packages public queries", () => {
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("Version 1.0.0 already exists");
|
||||
).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again.");
|
||||
});
|
||||
|
||||
it("treats matching workflow duplicate package releases as idempotent", async () => {
|
||||
@@ -8354,6 +8570,383 @@ describe("package scan backfill", () => {
|
||||
expect.objectContaining({ scanStatus: "pending" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious latest plugin release and restores the previous clean latest", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const previousRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
distTags: [],
|
||||
verification: { scanStatus: "clean" },
|
||||
createdAt: 1_600_000_000_000,
|
||||
});
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-2",
|
||||
packageId: "packages:demo",
|
||||
version: "2.0.0",
|
||||
runtimeId: "demo.plugin",
|
||||
sourceRepo: "openclaw/demo-malicious",
|
||||
distTags: ["latest"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdBy: "users:member",
|
||||
publishActor: { kind: "user", userId: "users:member" },
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
runtimeId: "malicious.plugin",
|
||||
sourceRepo: "openclaw/demo-malicious",
|
||||
latestReleaseId: "packageReleases:demo-2",
|
||||
tags: { latest: "packageReleases:demo-2" },
|
||||
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "pending" } },
|
||||
verification: { scanStatus: "pending" },
|
||||
scanStatus: "pending",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-2") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([previousRelease, candidateRelease]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "pending",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-2",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-2",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-1",
|
||||
expect.objectContaining({ distTags: ["latest"] }),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
runtimeId: undefined,
|
||||
sourceRepo: undefined,
|
||||
scanStatus: "clean",
|
||||
tags: { latest: "packageReleases:demo-1" },
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "demo-plugin",
|
||||
version: "2.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious non-latest plugin release without changing the clean latest", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-beta",
|
||||
packageId: "packages:demo",
|
||||
version: "1.5.0",
|
||||
distTags: ["beta"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdBy: "users:member",
|
||||
publishActor: { kind: "user", userId: "users:member" },
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No static findings.",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
createdAt: 1_650_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
latestReleaseId: "packageReleases:demo-latest",
|
||||
tags: {
|
||||
latest: "packageReleases:demo-latest",
|
||||
beta: "packageReleases:demo-beta",
|
||||
},
|
||||
latestVersionSummary: { version: "2.0.0", verification: { scanStatus: "clean" } },
|
||||
verification: { scanStatus: "clean" },
|
||||
scanStatus: "clean",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-beta") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "clean",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-beta",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-beta",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageReleases:demo-beta",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
tags: { latest: "packageReleases:demo-latest" },
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({ latestReleaseId: "packageReleases:demo-beta" }),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "plugin",
|
||||
artifactName: "demo-plugin",
|
||||
version: "1.5.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a first malicious plugin release out of public package lists", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const candidateRelease = makeReleaseDoc({
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
distTags: ["latest"],
|
||||
verification: { scanStatus: "pending" },
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
const pkg = makePackageDoc({
|
||||
_id: "packages:demo",
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
tags: { latest: "packageReleases:demo-1" },
|
||||
latestVersionSummary: { version: "1.0.0", verification: { scanStatus: "pending" } },
|
||||
verification: { scanStatus: "pending" },
|
||||
scanStatus: "pending",
|
||||
});
|
||||
|
||||
await updateReleaseLlmAnalysisInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-1") return candidateRelease;
|
||||
if (id === "packages:demo") return pkg;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([candidateRelease]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "packageSearchDigest:demo",
|
||||
packageId: "packages:demo",
|
||||
scanStatus: "pending",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
) {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-1",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Fix locally and rescan.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
scanStatus: "malicious",
|
||||
tags: {},
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packageSearchDigest:demo",
|
||||
expect.objectContaining({
|
||||
latestVersion: undefined,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -8785,6 +9378,62 @@ describe("owned package sanction batches", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules hard deletes for packages owned by a deleted publisher", async () => {
|
||||
const orgPackage = makePackageDoc({
|
||||
_id: "packages:org-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
});
|
||||
const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx({
|
||||
publisherPackages: [orgPackage],
|
||||
releases: [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:org-plugin-1",
|
||||
packageId: "packages:org-plugin",
|
||||
}),
|
||||
],
|
||||
packageTokens: [
|
||||
{
|
||||
_id: "packagePublishTokens:org-plugin",
|
||||
packageId: "packages:org-plugin",
|
||||
version: "1.0.0",
|
||||
revokedAt: undefined,
|
||||
},
|
||||
],
|
||||
publishers: {
|
||||
"publishers:org": {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
deletedAt: 3_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await applyPublisherDeletionToOwnedPackagesBatchInternalHandler(ctx as never, {
|
||||
ownerPublisherId: "publishers:org",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:org-plugin",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 3_000,
|
||||
softDeletedReason: "publisher.deleted",
|
||||
softDeletedBy: "users:owner",
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:org-plugin",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "publisher.delete",
|
||||
});
|
||||
expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:org-plugin", expect.anything());
|
||||
});
|
||||
|
||||
it("schedules linked legacy personal publisher scans when the user row lacks the publisher id", async () => {
|
||||
const { ctx, runAfter } = makeOwnedPackageBatchCtx({
|
||||
owner: {
|
||||
@@ -9273,15 +9922,15 @@ describe("owned package sanction batches", () => {
|
||||
expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything());
|
||||
});
|
||||
|
||||
it("marks account-deleted packages separately from ban-restorable packages", async () => {
|
||||
const { ctx, patch } = makeOwnedPackageBatchCtx();
|
||||
it("hides and schedules hard deletes for account-deleted packages", async () => {
|
||||
const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx();
|
||||
|
||||
const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, {
|
||||
ownerUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false });
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
@@ -9291,15 +9940,21 @@ describe("owned package sanction batches", () => {
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:demo",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks account-deleted packages owned through the user's personal publisher", async () => {
|
||||
it("schedules hard deletes for account-deleted packages owned through the user's personal publisher", async () => {
|
||||
const personalPublisherPackage = makePackageDoc({
|
||||
_id: "packages:personal-publisher",
|
||||
ownerUserId: "users:publishing-actor",
|
||||
ownerPublisherId: "publishers:personal",
|
||||
});
|
||||
const { ctx, patch } = makeOwnedPackageBatchCtx({
|
||||
const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx({
|
||||
owner: {
|
||||
_id: "users:owner",
|
||||
deactivatedAt: 3_000,
|
||||
@@ -9329,14 +9984,22 @@ describe("owned package sanction batches", () => {
|
||||
scope: "personalPublisher",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false });
|
||||
expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 0, scheduled: false });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:personal-publisher",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: 3_000,
|
||||
softDeletedReason: "user.deactivated",
|
||||
softDeletedBy: "users:owner",
|
||||
softDeletedByRole: "user",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
||||
packageId: "packages:personal-publisher",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
source: "account.delete",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not delete org-owned packages when deleting a member account", async () => {
|
||||
|
||||
+598
-34
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ServerPackagePublishRequestSchema,
|
||||
derivePluginCategoryTags,
|
||||
getPackageScopeOwnerMismatch,
|
||||
isPluginCategorySlug,
|
||||
parseArk,
|
||||
@@ -286,10 +287,14 @@ const packageAutobanRemediationInternalRefs = internal as unknown as {
|
||||
type DbReaderCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
const BAN_USER_PACKAGES_BATCH_SIZE = 25;
|
||||
const PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE = 25;
|
||||
type PackageSoftDeletedReason = "user.banned" | "user.deactivated";
|
||||
type PackageSoftDeletedReason = "user.banned" | "user.deactivated" | "publisher.deleted";
|
||||
const ownedPackageScanScopeValidator = v.optional(
|
||||
v.union(v.literal("ownerUserId"), v.literal("personalPublisher")),
|
||||
);
|
||||
const hardDeletePackageSourceValidator = v.union(
|
||||
v.literal("account.delete"),
|
||||
v.literal("publisher.delete"),
|
||||
);
|
||||
type OwnedPackageScanScope = "ownerUserId" | "personalPublisher";
|
||||
type PackagePublishActor =
|
||||
| {
|
||||
@@ -331,6 +336,7 @@ type PublicPackageListItem = {
|
||||
capabilityTags: string[];
|
||||
executesCode: boolean;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
stats: Doc<"packages">["stats"];
|
||||
};
|
||||
type PackageReleaseScanStatus = ReturnType<typeof resolvePackageReleaseScanStatus>;
|
||||
type PackageReleaseModerationQueueDoc = Omit<Doc<"packageReleases">, "createdAt"> & {
|
||||
@@ -581,6 +587,7 @@ type PackageDigestLike = Pick<
|
||||
| "pluginCategoryTags"
|
||||
| "executesCode"
|
||||
| "verificationTier"
|
||||
| "stats"
|
||||
| "scanStatus"
|
||||
| "softDeletedAt"
|
||||
> & {
|
||||
@@ -968,6 +975,40 @@ function digestMatchesSearchFilters(
|
||||
return digestMatchesFilters(digest, args);
|
||||
}
|
||||
|
||||
function packageMatchesListFilters(
|
||||
pkg: Doc<"packages">,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
},
|
||||
) {
|
||||
if (args.family && pkg.family !== args.family) return false;
|
||||
if (args.channel && pkg.channel !== args.channel) return false;
|
||||
if (typeof args.isOfficial === "boolean" && pkg.isOfficial !== args.isOfficial) return false;
|
||||
if (typeof args.executesCode === "boolean" && Boolean(pkg.executesCode) !== args.executesCode) {
|
||||
return false;
|
||||
}
|
||||
if (args.capabilityTag && !(pkg.capabilityTags ?? []).includes(args.capabilityTag)) {
|
||||
return false;
|
||||
}
|
||||
if (args.category) {
|
||||
const categories = derivePluginCategoryTags({
|
||||
family: pkg.family,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
runtimeId: pkg.runtimeId,
|
||||
summary: pkg.summary,
|
||||
capabilityTags: pkg.capabilityTags,
|
||||
});
|
||||
if (!categories.includes(args.category as PluginCategorySlug)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function upsertPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
@@ -1003,7 +1044,22 @@ async function removePackageBadge(
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
|
||||
function defaultPackageStats(): Doc<"packages">["stats"] {
|
||||
return { downloads: 0, installs: 0, stars: 0, versions: 0 };
|
||||
}
|
||||
|
||||
async function resolvePackageListStats(
|
||||
ctx: DbReaderCtx,
|
||||
digest: PackageDigestLike,
|
||||
): Promise<Doc<"packages">["stats"]> {
|
||||
const pkg = await ctx.db.get(digest.packageId);
|
||||
return pkg?.stats ?? digest.stats ?? defaultPackageStats();
|
||||
}
|
||||
|
||||
async function toPublicPackageListItem(
|
||||
ctx: DbReaderCtx,
|
||||
digest: PackageDigestLike,
|
||||
): Promise<PublicPackageListItem> {
|
||||
return {
|
||||
name: digest.name,
|
||||
displayName: digest.displayName,
|
||||
@@ -1019,6 +1075,36 @@ function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListIt
|
||||
capabilityTags: digest.capabilityTags ?? [],
|
||||
executesCode: digest.executesCode ?? false,
|
||||
verificationTier: digest.verificationTier ?? null,
|
||||
stats: await resolvePackageListStats(ctx, digest),
|
||||
};
|
||||
}
|
||||
|
||||
async function toPublicPackageListItemFromPackage(
|
||||
ctx: DbReaderCtx,
|
||||
pkg: Doc<"packages">,
|
||||
): Promise<PublicPackageListItem> {
|
||||
const owner = toPublicPublisher(
|
||||
await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family,
|
||||
runtimeId: pkg.runtimeId ?? null,
|
||||
channel: pkg.channel,
|
||||
isOfficial: pkg.isOfficial,
|
||||
summary: pkg.summary ?? null,
|
||||
ownerHandle: owner?.handle ?? null,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
latestVersion: pkg.latestVersionSummary?.version ?? null,
|
||||
capabilityTags: pkg.capabilityTags ?? [],
|
||||
executesCode: pkg.executesCode ?? false,
|
||||
verificationTier: pkg.verification?.tier ?? null,
|
||||
stats: pkg.stats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1831,15 +1917,19 @@ async function fetchHighlightedPackagePage(
|
||||
},
|
||||
) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
const page = digests
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.isOfficial) - Number(a.isOfficial) ||
|
||||
b.updatedAt - a.updatedAt ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.slice(0, args.numItems)
|
||||
.map(toPublicPackageListItem);
|
||||
.slice(0, args.numItems);
|
||||
const items: PublicPackageListItem[] = [];
|
||||
for (const digest of page) {
|
||||
items.push(await toPublicPackageListItem(ctx, digest));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
||||
@@ -2231,6 +2321,7 @@ export const listPublicPage = query({
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
category: v.optional(v.string()),
|
||||
sort: v.optional(v.union(v.literal("updated"), v.literal("downloads"))),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -2330,6 +2421,7 @@ export const listPageForViewerInternal = internalQuery({
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
category: v.optional(v.string()),
|
||||
sort: v.optional(v.union(v.literal("updated"), v.literal("downloads"))),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
@@ -2348,6 +2440,7 @@ async function listPackagePageImpl(
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads";
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -2392,6 +2485,68 @@ async function listPackagePageImpl(
|
||||
const isOfficial = args.isOfficial;
|
||||
const category = isPluginCategorySlug(args.category) ? args.category : undefined;
|
||||
|
||||
if (args.sort === "downloads") {
|
||||
let cursor = pageCursor;
|
||||
let pageOffset = offset;
|
||||
let pageSize: number | null = decodedCursor.pageSize ?? null;
|
||||
let done = decodedCursor.done;
|
||||
|
||||
while ((pageOffset > 0 || !done) && collected.length < targetCount) {
|
||||
const scanPageSize = Math.min(
|
||||
MAX_PUBLIC_LIST_PAGE_SIZE,
|
||||
pageOffset > 0 && pageSize
|
||||
? Math.max(pageSize, pageOffset + targetCount)
|
||||
: Math.max(targetCount * 5, targetCount, 50),
|
||||
);
|
||||
const currentCursor = cursor;
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_active_downloads", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.paginate({ cursor: currentCursor, numItems: scanPageSize });
|
||||
|
||||
for (let index = pageOffset; index < page.page.length; index += 1) {
|
||||
const pkg = page.page[index];
|
||||
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue;
|
||||
if (!packageMatchesListFilters(pkg, { ...args, category })) continue;
|
||||
collected.push(await toPublicPackageListItemFromPackage(ctx, pkg));
|
||||
if (collected.length >= targetCount) {
|
||||
const nextOffset = index + 1;
|
||||
const nextState =
|
||||
nextOffset < page.page.length
|
||||
? {
|
||||
cursor: currentCursor,
|
||||
offset: nextOffset,
|
||||
pageSize: scanPageSize,
|
||||
done: page.isDone,
|
||||
}
|
||||
: {
|
||||
cursor: page.continueCursor,
|
||||
offset: 0,
|
||||
pageSize: scanPageSize,
|
||||
done: page.isDone,
|
||||
};
|
||||
return {
|
||||
page: collected,
|
||||
isDone: nextState.done && nextState.offset === 0,
|
||||
continueCursor: encodePublicPageCursor(nextState),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
pageOffset = 0;
|
||||
pageSize = scanPageSize;
|
||||
}
|
||||
|
||||
return {
|
||||
page: collected,
|
||||
isDone: done,
|
||||
continueCursor: encodePublicPageCursor({ cursor, offset: pageOffset, pageSize, done }),
|
||||
};
|
||||
}
|
||||
|
||||
const builder = category
|
||||
? buildPackagePluginCategoryDigestQuery(ctx, {
|
||||
category,
|
||||
@@ -2427,7 +2582,7 @@ async function listPackagePageImpl(
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
collected.push(toPublicPackageListItem(digest));
|
||||
collected.push(await toPublicPackageListItem(ctx, digest));
|
||||
if (collected.length >= targetCount) {
|
||||
const nextOffset = index + 1;
|
||||
const nextState =
|
||||
@@ -2533,7 +2688,7 @@ async function searchPackagesImpl(
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
if (args.highlightedOnly) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
const entries = digests
|
||||
.map((digest) => {
|
||||
const match = packageSearchMatch(digest, queryText);
|
||||
return match ? { ...match, package: digest } : null;
|
||||
@@ -2542,12 +2697,16 @@ async function searchPackagesImpl(
|
||||
Boolean(entry),
|
||||
)
|
||||
.sort(comparePackageSearchMatches)
|
||||
.slice(0, targetCount)
|
||||
.map((entry) => ({
|
||||
.slice(0, targetCount);
|
||||
const results: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
|
||||
for (const entry of entries) {
|
||||
results.push({
|
||||
score: entry.score,
|
||||
rankTier: entry.rankTier,
|
||||
package: toPublicPackageListItem(entry.package),
|
||||
}));
|
||||
package: await toPublicPackageListItem(ctx, entry.package),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const category = isPluginCategorySlug(args.category) ? args.category : undefined;
|
||||
@@ -2588,7 +2747,7 @@ async function searchPackagesImpl(
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
...match,
|
||||
package: toPublicPackageListItem(digest),
|
||||
package: await toPublicPackageListItem(ctx, digest),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2606,7 +2765,7 @@ async function searchPackagesImpl(
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
...match,
|
||||
package: toPublicPackageListItem(digest),
|
||||
package: await toPublicPackageListItem(ctx, digest),
|
||||
});
|
||||
if (matches.length >= targetCount) break;
|
||||
}
|
||||
@@ -2875,15 +3034,9 @@ async function softDeletePackageDoc(
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
let releaseCount = 0;
|
||||
const deletedReleaseIds: Array<Id<"packageReleases">> = [];
|
||||
for (const release of releases) {
|
||||
if (release.softDeletedAt) continue;
|
||||
await ctx.db.patch(release._id, { softDeletedAt: now });
|
||||
releaseCount += 1;
|
||||
deletedReleaseIds.push(release._id);
|
||||
}
|
||||
|
||||
const deletedReleaseIds = releases
|
||||
.filter((release) => !release.softDeletedAt)
|
||||
.map((release) => release._id);
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
softDeletedAt: now,
|
||||
softDeletedReason: params.reason,
|
||||
@@ -2902,6 +3055,9 @@ async function softDeletePackageDoc(
|
||||
ownerHandle: deleteOwner?.handle ?? "",
|
||||
ownerKind: deleteOwner?.kind,
|
||||
});
|
||||
for (const releaseId of deletedReleaseIds) {
|
||||
await ctx.db.patch(releaseId, { softDeletedAt: now });
|
||||
}
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: params.actorUserId,
|
||||
action: "package.delete",
|
||||
@@ -2914,7 +3070,7 @@ async function softDeletePackageDoc(
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
actorRole: params.actorRole ?? "user",
|
||||
softDeletedReason: params.reason ?? null,
|
||||
releaseCount,
|
||||
releaseCount: deletedReleaseIds.length,
|
||||
releaseIds: deletedReleaseIds,
|
||||
source: params.source,
|
||||
},
|
||||
@@ -2924,11 +3080,151 @@ async function softDeletePackageDoc(
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: pkg._id,
|
||||
releaseCount,
|
||||
releaseCount: deletedReleaseIds.length,
|
||||
alreadyDeleted: false as const,
|
||||
};
|
||||
}
|
||||
|
||||
async function deletePackageModerationEventsForReport(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
reportId: Id<"packageReports">,
|
||||
) {
|
||||
const logs = await ctx.db
|
||||
.query("packageModerationEventLogs")
|
||||
.withIndex("by_report_createdAt", (q) => q.eq("reportId", reportId))
|
||||
.collect();
|
||||
for (const log of logs) await ctx.db.delete(log._id);
|
||||
}
|
||||
|
||||
async function deletePackageModerationEventsForAppeal(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
appealId: Id<"packageAppeals">,
|
||||
) {
|
||||
const logs = await ctx.db
|
||||
.query("packageModerationEventLogs")
|
||||
.withIndex("by_appeal_createdAt", (q) => q.eq("appealId", appealId))
|
||||
.collect();
|
||||
for (const log of logs) await ctx.db.delete(log._id);
|
||||
}
|
||||
|
||||
async function hardDeletePackageDoc(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
deletedAt: number;
|
||||
source: "account.delete" | "publisher.delete";
|
||||
},
|
||||
) {
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const release of releases) {
|
||||
const jobs = await ctx.db
|
||||
.query("securityScanJobs")
|
||||
.withIndex("by_package_release", (q) => q.eq("packageReleaseId", release._id))
|
||||
.collect();
|
||||
for (const job of jobs) await ctx.db.delete(job._id);
|
||||
}
|
||||
|
||||
const reports = await ctx.db
|
||||
.query("packageReports")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const report of reports) {
|
||||
await deletePackageModerationEventsForReport(ctx, report._id);
|
||||
await ctx.db.delete(report._id);
|
||||
}
|
||||
|
||||
const appeals = await ctx.db
|
||||
.query("packageAppeals")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const appeal of appeals) {
|
||||
await deletePackageModerationEventsForAppeal(ctx, appeal._id);
|
||||
await ctx.db.delete(appeal._id);
|
||||
}
|
||||
|
||||
const badges = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const badge of badges) await ctx.db.delete(badge._id);
|
||||
|
||||
const trustedPublishers = await ctx.db
|
||||
.query("packageTrustedPublishers")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const trustedPublisher of trustedPublishers) await ctx.db.delete(trustedPublisher._id);
|
||||
|
||||
const tokens = await ctx.db
|
||||
.query("packagePublishTokens")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const token of tokens) {
|
||||
const tickets = await ctx.db
|
||||
.query("packagePublishUploadTickets")
|
||||
.withIndex("by_publish_token", (q) => q.eq("publishTokenId", token._id))
|
||||
.collect();
|
||||
for (const ticket of tickets) await ctx.db.delete(ticket._id);
|
||||
await ctx.db.delete(token._id);
|
||||
}
|
||||
|
||||
const statEvents = await ctx.db
|
||||
.query("packageStatEvents")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
for (const statEvent of statEvents) await ctx.db.delete(statEvent._id);
|
||||
|
||||
for (const release of releases) await ctx.db.delete(release._id);
|
||||
await ctx.db.delete(pkg._id);
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: params.actorUserId,
|
||||
action: "package.hard_delete",
|
||||
targetType: "package",
|
||||
targetId: pkg._id,
|
||||
metadata: {
|
||||
name: pkg.name,
|
||||
normalizedName: pkg.normalizedName,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
source: params.source,
|
||||
releases: releases.length,
|
||||
reports: reports.length,
|
||||
appeals: appeals.length,
|
||||
publishTokens: tokens.length,
|
||||
},
|
||||
createdAt: params.deletedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: pkg._id,
|
||||
releaseCount: releases.length,
|
||||
revokedTokenCount: tokens.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const hardDeletePackageInternal = internalMutation({
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
actorUserId: v.id("users"),
|
||||
deletedAt: v.number(),
|
||||
source: hardDeletePackageSourceValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const pkg = await ctx.db.get(args.packageId);
|
||||
if (!pkg) return { ok: true as const, deleted: false as const };
|
||||
const result = await hardDeletePackageDoc(ctx, pkg, {
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
source: args.source,
|
||||
});
|
||||
return { ...result, deleted: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
function comparePackageRestoreLatestCandidates(
|
||||
family: Doc<"packages">["family"],
|
||||
a: Doc<"packageReleases">,
|
||||
@@ -2983,6 +3279,28 @@ function rebuildPackageTagsFromActiveReleases(releases: Doc<"packageReleases">[]
|
||||
return tags;
|
||||
}
|
||||
|
||||
function packageLatestSummaryFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release
|
||||
? {
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
changelog: release.changelog,
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
artifact: packageArtifactSummary(release),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function packageRuntimeIdFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release?.runtimeId ?? release?.capabilities?.runtimeId;
|
||||
}
|
||||
|
||||
function packageSourceRepoFromRelease(release: Doc<"packageReleases"> | null) {
|
||||
return release?.sourceRepo ?? release?.verification?.sourceRepo;
|
||||
}
|
||||
|
||||
async function restorePackageDoc(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
@@ -3082,7 +3400,7 @@ async function restorePackageDoc(
|
||||
compatibility: nextLatest?.compatibility,
|
||||
capabilities: nextLatest?.capabilities,
|
||||
verification: nextLatest?.verification,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : undefined,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : pkg.scanStatus,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
@@ -3449,17 +3767,18 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation
|
||||
for (const pkg of page) {
|
||||
if (shouldSkipOwnedPackageScanRow(pkg, args)) continue;
|
||||
if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue;
|
||||
const revokeResult = await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt);
|
||||
revokedTokenCount += revokeResult.revokedCount;
|
||||
if (pkg.softDeletedAt) continue;
|
||||
|
||||
await softDeletePackageDoc(ctx, pkg, {
|
||||
actorUserId: args.ownerUserId,
|
||||
actorRole: "user",
|
||||
deletedAt: args.deletedAt,
|
||||
reason: "user.deactivated",
|
||||
source: "dashboard",
|
||||
});
|
||||
void ctx.scheduler.runAfter(0, internal.packages.hardDeletePackageInternal, {
|
||||
packageId: pkg._id,
|
||||
actorUserId: args.ownerUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
source: "account.delete",
|
||||
});
|
||||
deletedCount += 1;
|
||||
}
|
||||
|
||||
@@ -3476,6 +3795,67 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation
|
||||
},
|
||||
});
|
||||
|
||||
export const applyPublisherDeletionToOwnedPackagesBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
actorUserId: v.id("users"),
|
||||
deletedAt: v.number(),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await ctx.db.get(args.ownerPublisherId);
|
||||
if (publisher && publisher.deletedAt !== args.deletedAt) {
|
||||
return {
|
||||
ok: true as const,
|
||||
deletedCount: 0,
|
||||
revokedTokenCount: 0,
|
||||
scheduled: false,
|
||||
stale: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId))
|
||||
.order("desc")
|
||||
.paginate({
|
||||
cursor: args.cursor ?? null,
|
||||
numItems: BAN_USER_PACKAGES_BATCH_SIZE,
|
||||
});
|
||||
|
||||
let deletedCount = 0;
|
||||
let revokedTokenCount = 0;
|
||||
for (const pkg of page) {
|
||||
await softDeletePackageDoc(ctx, pkg, {
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
reason: "publisher.deleted",
|
||||
source: "dashboard",
|
||||
});
|
||||
void ctx.scheduler.runAfter(0, internal.packages.hardDeletePackageInternal, {
|
||||
packageId: pkg._id,
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
source: "publisher.delete",
|
||||
});
|
||||
deletedCount += 1;
|
||||
}
|
||||
|
||||
if (!isDone) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.packages.applyPublisherDeletionToOwnedPackagesBatchInternal,
|
||||
{
|
||||
...args,
|
||||
cursor: continueCursor,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone };
|
||||
},
|
||||
});
|
||||
|
||||
export const softDeletePackageInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
@@ -6175,7 +6555,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
releaseId: releaseExists._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
throw new ConvexError(
|
||||
`Version ${nextVersionLabel} already exists. Increment the version number and try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const priorReleases = existing
|
||||
@@ -6213,6 +6595,8 @@ export const insertReleaseInternal = internalMutation({
|
||||
normalizedBundleManifest: args.normalizedBundleManifest,
|
||||
compatibility: args.compatibility,
|
||||
capabilities: nextCapabilities,
|
||||
runtimeId: args.runtimeId,
|
||||
sourceRepo: args.sourceRepo,
|
||||
verification: args.verification,
|
||||
staticScan: args.staticScan,
|
||||
source: args.source,
|
||||
@@ -6284,10 +6668,186 @@ function isReleaseActive(
|
||||
return Boolean(release && !release.softDeletedAt);
|
||||
}
|
||||
|
||||
async function syncLatestPackageVerification(ctx: MutationCtx, release: Doc<"packageReleases">) {
|
||||
async function recordMaliciousPluginReleaseFinding(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
|
||||
ownerUserId: release.createdBy,
|
||||
artifactKind: "plugin",
|
||||
artifactName: pkg.normalizedName,
|
||||
version: release.version,
|
||||
trigger,
|
||||
...(release.sha256hash ? { sha256hash: release.sha256hash } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function quarantineMaliciousNonLatestPackageRelease(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const maliciousVerification = release.verification
|
||||
? { ...release.verification, scanStatus: "malicious" as const }
|
||||
: release.verification;
|
||||
await ctx.db.patch(release._id, {
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
});
|
||||
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(pkg.tags ?? {}).filter(([, releaseId]) => releaseId !== release._id),
|
||||
) as Doc<"packages">["tags"];
|
||||
if (Object.keys(nextTags).length !== Object.keys(pkg.tags ?? {}).length) {
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
tags: nextTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
await ctx.db.patch(pkg._id, packagePatch);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...extractPackageDigestFields(nextPackage),
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.scheduler) {
|
||||
await recordMaliciousPluginReleaseFinding(
|
||||
ctx as Pick<MutationCtx, "scheduler">,
|
||||
pkg,
|
||||
release,
|
||||
trigger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantineMaliciousLatestPackageRelease(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
pkg: Doc<"packages">,
|
||||
release: Doc<"packageReleases">,
|
||||
trigger: string,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const maliciousVerification = release.verification
|
||||
? { ...release.verification, scanStatus: "malicious" as const }
|
||||
: release.verification;
|
||||
const quarantinedRelease = {
|
||||
...release,
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
} as Doc<"packageReleases">;
|
||||
|
||||
await ctx.db.patch(release._id, {
|
||||
verification: maliciousVerification,
|
||||
softDeletedAt: now,
|
||||
});
|
||||
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
||||
.collect();
|
||||
const activeNonMaliciousReleases = releases
|
||||
.map((candidate) => (candidate._id === release._id ? quarantinedRelease : candidate))
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!candidate.softDeletedAt && resolvePackageReleaseScanStatus(candidate) !== "malicious",
|
||||
);
|
||||
const nextLatest = getPreferredRestoredPackageRelease(pkg.family, activeNonMaliciousReleases);
|
||||
const nextTags = rebuildPackageTagsFromActiveReleases(activeNonMaliciousReleases);
|
||||
if (nextLatest) {
|
||||
nextTags.latest = nextLatest._id;
|
||||
if (!(nextLatest.distTags ?? []).includes("latest")) {
|
||||
await ctx.db.patch(nextLatest._id, {
|
||||
distTags: [...(nextLatest.distTags ?? []), "latest"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const restoredRuntimeId = packageRuntimeIdFromRelease(nextLatest);
|
||||
const restoredSourceRepo = packageSourceRepoFromRelease(nextLatest);
|
||||
const packagePatch: Partial<Doc<"packages">> = {
|
||||
tags: nextTags,
|
||||
latestReleaseId: nextLatest?._id,
|
||||
latestVersionSummary: packageLatestSummaryFromRelease(nextLatest),
|
||||
summary: nextLatest?.summary,
|
||||
sourceRepo: restoredSourceRepo,
|
||||
runtimeId: restoredRuntimeId,
|
||||
capabilityTags: nextLatest?.capabilities?.capabilityTags,
|
||||
executesCode:
|
||||
typeof nextLatest?.capabilities?.executesCode === "boolean"
|
||||
? nextLatest.capabilities.executesCode
|
||||
: undefined,
|
||||
compatibility: nextLatest?.compatibility,
|
||||
capabilities: nextLatest?.capabilities,
|
||||
verification: nextLatest?.verification,
|
||||
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : "malicious",
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
||||
await ctx.db.patch(pkg._id, packagePatch);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...extractPackageDigestFields(nextPackage),
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
|
||||
if (ctx.scheduler) {
|
||||
await recordMaliciousPluginReleaseFinding(
|
||||
ctx as Pick<MutationCtx, "scheduler">,
|
||||
pkg,
|
||||
release,
|
||||
trigger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type SyncLatestPackageVerificationOptions = {
|
||||
quarantineMaliciousLatest?: boolean;
|
||||
maliciousTrigger?: string;
|
||||
};
|
||||
|
||||
async function syncLatestPackageVerification(
|
||||
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
||||
release: Doc<"packageReleases">,
|
||||
options: SyncLatestPackageVerificationOptions = {},
|
||||
) {
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
if (!pkg || pkg.latestReleaseId !== release._id) return;
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
if (!pkg) return;
|
||||
|
||||
if (scanStatus === "malicious" && options.quarantineMaliciousLatest) {
|
||||
if (pkg.latestReleaseId !== release._id) {
|
||||
await quarantineMaliciousNonLatestPackageRelease(
|
||||
ctx,
|
||||
pkg,
|
||||
release,
|
||||
options.maliciousTrigger ?? "malicious.llm_malicious",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await quarantineMaliciousLatestPackageRelease(
|
||||
ctx,
|
||||
pkg,
|
||||
release,
|
||||
options.maliciousTrigger ?? "malicious.llm_malicious",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pkg.latestReleaseId !== release._id) return;
|
||||
|
||||
const nextVerification = pkg.verification
|
||||
? {
|
||||
@@ -6404,7 +6964,11 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
|
||||
...release,
|
||||
llmAnalysis: args.llmAnalysis,
|
||||
} as Doc<"packageReleases">;
|
||||
await syncLatestPackageVerification(ctx, updatedRelease);
|
||||
const llmVerdict = (args.llmAnalysis.verdict ?? args.llmAnalysis.status).trim().toLowerCase();
|
||||
await syncLatestPackageVerification(ctx, updatedRelease, {
|
||||
quarantineMaliciousLatest: llmVerdict === "malicious",
|
||||
maliciousTrigger: "malicious.llm_malicious",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2671
-13
File diff suppressed because it is too large
Load Diff
+1196
-10
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_generated/server", () => ({
|
||||
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
}));
|
||||
|
||||
const publisherAbuseDevSeed = await import("./publisherAbuseDevSeed");
|
||||
|
||||
type Handler<TArgs, TResult> = (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
type Wrapped<TArgs, TResult> = { _handler: Handler<TArgs, TResult> };
|
||||
|
||||
const clearSeedHandler = (
|
||||
publisherAbuseDevSeed.clearSeed as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{
|
||||
runs: number;
|
||||
scores: number;
|
||||
nominations: number;
|
||||
events: number;
|
||||
users: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const seedHandler = (
|
||||
publisherAbuseDevSeed.seed as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ runId: string; inserted: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
type TestDoc = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: TestDoc, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(seedTables: Record<string, TestDoc[]>) {
|
||||
const tables = Object.fromEntries(
|
||||
Object.entries(seedTables).map(([name, docs]) => [name, [...docs]]),
|
||||
);
|
||||
let insertCounter = 0;
|
||||
const queryCalls: Array<{
|
||||
table: string;
|
||||
indexName: string;
|
||||
constraints: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
return {
|
||||
tables,
|
||||
queryCalls,
|
||||
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>) => {
|
||||
const id = `${table}:inserted-${insertCounter}`;
|
||||
insertCounter += 1;
|
||||
list(table).push({ ...doc, _id: id });
|
||||
return id;
|
||||
},
|
||||
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: (indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
queryCalls.push({ table, indexName, constraints });
|
||||
const matched = () => list(table).filter((doc) => matches(doc, constraints));
|
||||
return {
|
||||
collect: async () => {
|
||||
throw new Error("clearSeed must not collect whole tables");
|
||||
},
|
||||
paginate: async () => {
|
||||
throw new Error("clearSeed must not use built-in pagination");
|
||||
},
|
||||
take: async (numItems: number) => {
|
||||
return matched().slice(0, numItems);
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("publisherAbuseDevSeed.clearSeed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthEnabled = process.env.DEV_AUTH_ENABLED;
|
||||
const previousDevImpersonation = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
restoreEnv("DEV_AUTH_ENABLED", previousDevAuthEnabled);
|
||||
restoreEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION", previousDevImpersonation);
|
||||
});
|
||||
|
||||
it("rejects production deployments before reading tables", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(clearSeedHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors the explicit fallback deployment when the primary marker is blank", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(clearSeedHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes demo rows through bounded indexed pages", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, queryCalls, tables } = createDb({
|
||||
publisherAbuseScores: [
|
||||
{
|
||||
_id: "publisherAbuseScores:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
runId: "publisherAbuseScoreRuns:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseScores:real",
|
||||
ownerKey: "user:real",
|
||||
handleSnapshot: "real",
|
||||
runId: "publisherAbuseScoreRuns:real",
|
||||
},
|
||||
],
|
||||
publisherAbuseReviewNominations: [
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
openedByRunId: "publisherAbuseScoreRuns:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:real",
|
||||
ownerKey: "user:real",
|
||||
handleSnapshot: "real",
|
||||
openedByRunId: "publisherAbuseScoreRuns:real",
|
||||
},
|
||||
],
|
||||
publisherAbuseScoreRuns: [
|
||||
{ _id: "publisherAbuseScoreRuns:demo" },
|
||||
{ _id: "publisherAbuseScoreRuns:real" },
|
||||
],
|
||||
publisherAbuseReviewEvents: [
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
nominationId: "publisherAbuseReviewNominations:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:real",
|
||||
ownerKey: "user:real",
|
||||
nominationId: "publisherAbuseReviewNominations:real",
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{ _id: "users:demo", handle: "demo-abuse-pub-01" },
|
||||
{ _id: "users:real", handle: "real" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(clearSeedHandler({ db }, {})).resolves.toEqual({
|
||||
runs: 1,
|
||||
scores: 1,
|
||||
nominations: 1,
|
||||
events: 1,
|
||||
users: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
expect(tables.publisherAbuseScores.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseScores:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseReviewNominations.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseReviewNominations:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseScoreRuns.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseScoreRuns:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseReviewEvents.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseReviewEvents:real",
|
||||
]);
|
||||
expect(tables.users.map((doc) => doc._id)).toEqual(["users:real"]);
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseScores",
|
||||
indexName: "by_owner_key_and_created_at",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseReviewNominations",
|
||||
indexName: "by_owner_key_and_model_version",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseReviewEvents",
|
||||
indexName: "by_owner_key_and_created_at",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "users",
|
||||
indexName: "handle",
|
||||
constraints: { handle: "demo-abuse-pub-01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisherAbuseDevSeed.seed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
});
|
||||
|
||||
it("seeds a prod-scale nomination distribution across labels", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, tables } = createDb({});
|
||||
|
||||
const result = await seedHandler({ db }, {});
|
||||
|
||||
const nominations = tables.publisherAbuseReviewNominations ?? [];
|
||||
const pendingBan = nominations.filter(
|
||||
(doc) => doc.label === "potential_ban_candidate" && doc.status === "pending",
|
||||
);
|
||||
const pendingReview = nominations.filter(
|
||||
(doc) => doc.label === "review" && doc.status === "pending",
|
||||
);
|
||||
|
||||
expect(pendingBan).toHaveLength(16);
|
||||
expect(pendingReview).toHaveLength(124);
|
||||
expect(result.inserted).toBe(nominations.length);
|
||||
// Every ban candidate links a demo user so the inspector ban action is
|
||||
// exercisable; review nominations do not create users.
|
||||
expect(tables.users ?? []).toHaveLength(16);
|
||||
expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-download-burst")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears existing demo rows before inserting repeatable seed data", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, tables } = createDb({
|
||||
publisherAbuseScores: [
|
||||
{
|
||||
_id: "publisherAbuseScores:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
runId: "publisherAbuseScoreRuns:old-demo",
|
||||
},
|
||||
],
|
||||
publisherAbuseReviewNominations: [
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
openedByRunId: "publisherAbuseScoreRuns:old-demo",
|
||||
},
|
||||
],
|
||||
publisherAbuseScoreRuns: [{ _id: "publisherAbuseScoreRuns:old-demo" }],
|
||||
publisherAbuseReviewEvents: [
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
nominationId: "publisherAbuseReviewNominations:old-demo",
|
||||
},
|
||||
],
|
||||
users: [{ _id: "users:old-demo", handle: "demo-abuse-pub-01" }],
|
||||
});
|
||||
|
||||
await seedHandler({ db }, {});
|
||||
|
||||
expect(tables.publisherAbuseScores.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseScores:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseReviewNominations.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseReviewNominations:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseScoreRuns.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseScoreRuns:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseReviewEvents.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseReviewEvents:old-demo",
|
||||
);
|
||||
expect(tables.users.map((doc) => doc._id)).not.toContain("users:old-demo");
|
||||
expect(tables.users.filter((doc) => doc.handle === "demo-abuse-pub-01")).toHaveLength(1);
|
||||
expect(tables.users).toHaveLength(16);
|
||||
expect(tables.publisherAbuseReviewNominations).toHaveLength(146);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
// DEV-ONLY seed: use the un-wrapped mutation builder (not convex/functions.ts) so
|
||||
// inserting/deleting demo rows does NOT fire table triggers. The users digest-sync
|
||||
// trigger runs a paginated query, and Convex allows only one paginated query per
|
||||
// mutation, so deleting several linked demo users through the wrapped builder fails.
|
||||
// Demo rows have no real packages/skills, so skipping digest sync is correct here.
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { assertLocalDevSeedAllowed } from "./lib/devSeed";
|
||||
import {
|
||||
computePublisherAbuseRawScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
|
||||
type PublisherAbuseLabel,
|
||||
} from "./lib/publisherAbuseScoring";
|
||||
|
||||
// DEV-ONLY seed for the publisher-abuse review dashboard. It inserts one
|
||||
// completed score run plus a spread of synthetic scores/nominations so every
|
||||
// dashboard tab renders with realistic rows. All synthetic rows use the
|
||||
// "demo-" prefix on handle/ownerKey so `clearSeed` can remove them precisely.
|
||||
|
||||
const DEMO_HANDLE_PREFIX = "demo-abuse-pub-";
|
||||
const DEMO_OWNER_KEY_PREFIX = "user:demo-";
|
||||
const TEMPORAL_DEMO_HANDLE = `${DEMO_HANDLE_PREFIX}temporal-cohort`;
|
||||
const TEMPORAL_DEMO_OWNER_KEY = `${DEMO_OWNER_KEY_PREFIX}temporal-cohort`;
|
||||
const TEMPORAL_DEMO_SKILL_SLUG = "demo-temporal-download-burst";
|
||||
const CLEAR_SEED_BATCH_SIZE = 100;
|
||||
|
||||
type TriageStatus =
|
||||
| "pending"
|
||||
| "reviewed_no_action"
|
||||
| "false_positive"
|
||||
| "needs_policy_discussion"
|
||||
| "candidate_for_future_action";
|
||||
|
||||
type SeedPublisher = {
|
||||
index: number;
|
||||
label: PublisherAbuseLabel;
|
||||
status: TriageStatus;
|
||||
zScore: number;
|
||||
publishedSkills: number;
|
||||
totalInstalls: number;
|
||||
totalStars: number;
|
||||
totalDownloads: number;
|
||||
reasonCodes: string[];
|
||||
notes?: string;
|
||||
// When true, also create an isolated demo user account and link it so the
|
||||
// inspector's "Ban user" action is enabled and exercisable in dev.
|
||||
linkUser?: boolean;
|
||||
};
|
||||
|
||||
// Prod-scale synthetic distribution so every dashboard tab renders with realistic
|
||||
// volume: 15 potential-ban candidates and 124 review nominations (both pending),
|
||||
// plus a small resolved/pass set for the Resolved tab. Counts mirror the reported
|
||||
// production review queue. Rows are deterministic (no randomness) so tests can
|
||||
// assert the distribution and clearSeed stays reproducible.
|
||||
const BAN_CANDIDATE_COUNT = 15;
|
||||
const REVIEW_PENDING_COUNT = 124;
|
||||
|
||||
const BAN_CANDIDATE_REASON_CODES = [
|
||||
"high_catalog_volume",
|
||||
"extreme_volume_low_engagement",
|
||||
"low_installs_per_skill",
|
||||
"low_stars_per_skill",
|
||||
"low_downloads_per_skill",
|
||||
];
|
||||
|
||||
const REVIEW_REASON_VARIANTS: string[][] = [
|
||||
["high_catalog_volume", "low_installs_per_skill", "low_stars_per_skill"],
|
||||
["high_catalog_volume", "low_installs_per_skill"],
|
||||
["high_catalog_volume", "low_stars_per_skill", "low_downloads_per_skill"],
|
||||
["high_catalog_volume", "low_installs_per_skill", "low_downloads_per_skill"],
|
||||
];
|
||||
|
||||
// Resolved + pass anchors keep the Resolved tab populated and exercise the
|
||||
// inspector's notes rendering. None link a demo user, so the only seeded demo
|
||||
// users are the 15 pending ban candidates.
|
||||
const RESOLVED_AND_PASS_PUBLISHERS: Array<Omit<SeedPublisher, "index">> = [
|
||||
{
|
||||
label: "potential_ban_candidate",
|
||||
status: "needs_policy_discussion",
|
||||
zScore: 2.75,
|
||||
publishedSkills: 2600,
|
||||
totalInstalls: 210,
|
||||
totalStars: 28,
|
||||
totalDownloads: 6400,
|
||||
reasonCodes: BAN_CANDIDATE_REASON_CODES,
|
||||
notes: "Escalated to policy: borderline catalog-stuffing pattern, awaiting decision.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "false_positive",
|
||||
zScore: 1.8,
|
||||
publishedSkills: 340,
|
||||
totalInstalls: 520,
|
||||
totalStars: 40,
|
||||
totalDownloads: 48000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill"],
|
||||
notes: "Confirmed legitimate bulk publisher; cleared after manual spot-check.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "candidate_for_future_action",
|
||||
zScore: 2.0,
|
||||
publishedSkills: 480,
|
||||
totalInstalls: 360,
|
||||
totalStars: 17,
|
||||
totalDownloads: 29000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill", "low_stars_per_skill"],
|
||||
notes: "Watchlist: revisit if catalog keeps growing without engagement.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 1.6,
|
||||
publishedSkills: 290,
|
||||
totalInstalls: 470,
|
||||
totalStars: 33,
|
||||
totalDownloads: 31000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill"],
|
||||
notes: "Reviewed: engagement within acceptable range for catalog size.",
|
||||
},
|
||||
{
|
||||
label: "pass",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 0.4,
|
||||
publishedSkills: 120,
|
||||
totalInstalls: 9800,
|
||||
totalStars: 540,
|
||||
totalDownloads: 210000,
|
||||
reasonCodes: [],
|
||||
notes: "Healthy engagement per skill; no action needed.",
|
||||
},
|
||||
{
|
||||
label: "pass",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 0.2,
|
||||
publishedSkills: 64,
|
||||
totalInstalls: 7200,
|
||||
totalStars: 410,
|
||||
totalDownloads: 150000,
|
||||
reasonCodes: [],
|
||||
notes: "Strong installs and stars per skill; clearly legitimate.",
|
||||
},
|
||||
];
|
||||
|
||||
function roundToTwo(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
// Ban candidates carry the highest z-scores (3.9 → 2.55) and link demo users so
|
||||
// the inspector ban action is exercisable; review nominations span the "on the
|
||||
// brink" band (2.4 → 1.3). Metrics vary per row so the inspector looks realistic.
|
||||
function buildSeedPublishers(): SeedPublisher[] {
|
||||
const publishers: SeedPublisher[] = [];
|
||||
let index = 1;
|
||||
|
||||
for (let i = 0; i < BAN_CANDIDATE_COUNT; i += 1) {
|
||||
const fraction = i / (BAN_CANDIDATE_COUNT - 1);
|
||||
publishers.push({
|
||||
index,
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
zScore: roundToTwo(3.9 - fraction * 1.35),
|
||||
publishedSkills: 4200 - i * 170,
|
||||
totalInstalls: 130 + (i % 6) * 16,
|
||||
totalStars: 15 + (i % 8) * 2,
|
||||
totalDownloads: 9800 - i * 300,
|
||||
reasonCodes: BAN_CANDIDATE_REASON_CODES,
|
||||
linkUser: true,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < REVIEW_PENDING_COUNT; i += 1) {
|
||||
const fraction = i / (REVIEW_PENDING_COUNT - 1);
|
||||
publishers.push({
|
||||
index,
|
||||
label: "review",
|
||||
status: "pending",
|
||||
zScore: roundToTwo(2.4 - fraction * 1.1),
|
||||
publishedSkills: 650 - i * 3,
|
||||
totalInstalls: 300 + (i % 9) * 30,
|
||||
totalStars: 14 + (i % 11) * 3,
|
||||
totalDownloads: 26000 + (i % 13) * 1500,
|
||||
reasonCodes: REVIEW_REASON_VARIANTS[i % REVIEW_REASON_VARIANTS.length],
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (const publisher of RESOLVED_AND_PASS_PUBLISHERS) {
|
||||
publishers.push({ index, ...publisher });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return publishers;
|
||||
}
|
||||
|
||||
const SEED_PUBLISHERS: SeedPublisher[] = buildSeedPublishers();
|
||||
|
||||
const SCANNED_PUBLISHERS = 194_083;
|
||||
const SCORED_PUBLISHERS = 10_349;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
function paddedIndex(index: number): string {
|
||||
return index.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function isDemoHandle(handle: string): boolean {
|
||||
return handle.startsWith(DEMO_HANDLE_PREFIX);
|
||||
}
|
||||
|
||||
function isDemoOwnerKey(ownerKey: string): boolean {
|
||||
return ownerKey.startsWith(DEMO_OWNER_KEY_PREFIX);
|
||||
}
|
||||
|
||||
function demoHandle(index: number): string {
|
||||
return `${DEMO_HANDLE_PREFIX}${paddedIndex(index)}`;
|
||||
}
|
||||
|
||||
function demoOwnerKey(index: number): string {
|
||||
return `${DEMO_OWNER_KEY_PREFIX}${paddedIndex(index)}`;
|
||||
}
|
||||
|
||||
const DEMO_HANDLES = [
|
||||
...SEED_PUBLISHERS.map((publisher) => demoHandle(publisher.index)),
|
||||
TEMPORAL_DEMO_HANDLE,
|
||||
];
|
||||
const DEMO_OWNER_KEYS = [
|
||||
...SEED_PUBLISHERS.map((publisher) => demoOwnerKey(publisher.index)),
|
||||
TEMPORAL_DEMO_OWNER_KEY,
|
||||
];
|
||||
|
||||
type ClearSeedCtx = Pick<MutationCtx, "db">;
|
||||
type ClearSeedResult = {
|
||||
runs: number;
|
||||
scores: number;
|
||||
nominations: number;
|
||||
events: number;
|
||||
users: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export const seed = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ runId: Id<"publisherAbuseScoreRuns">; inserted: number }> => {
|
||||
assertLocalDevSeedAllowed("Publisher abuse");
|
||||
await clearDemoRows(ctx);
|
||||
|
||||
const now = Date.now();
|
||||
const startedAt = now - 2 * HOUR_MS;
|
||||
const completedAt = now - HOUR_MS;
|
||||
|
||||
const labelCounts: Record<PublisherAbuseLabel, number> = {
|
||||
pass: 0,
|
||||
review: 0,
|
||||
potential_ban_candidate: 0,
|
||||
};
|
||||
let nominatedPublishers = 0;
|
||||
let sumLogPressure = 0;
|
||||
let sumSquaredLogPressure = 0;
|
||||
for (const publisher of SEED_PUBLISHERS) {
|
||||
labelCounts[publisher.label] += 1;
|
||||
if (publisher.label !== "pass") nominatedPublishers += 1;
|
||||
const raw = computePublisherAbuseRawScore({
|
||||
ownerKey: demoOwnerKey(publisher.index),
|
||||
handleSnapshot: demoHandle(publisher.index),
|
||||
publishedSkills: publisher.publishedSkills,
|
||||
totalInstalls: publisher.totalInstalls,
|
||||
totalStars: publisher.totalStars,
|
||||
totalDownloads: publisher.totalDownloads,
|
||||
});
|
||||
sumLogPressure += raw.logPressure;
|
||||
sumSquaredLogPressure += raw.logPressure ** 2;
|
||||
}
|
||||
|
||||
const meanLogPressure = sumLogPressure / SEED_PUBLISHERS.length;
|
||||
const variance = Math.max(
|
||||
0,
|
||||
sumSquaredLogPressure / SEED_PUBLISHERS.length - meanLogPressure ** 2,
|
||||
);
|
||||
const stdDevLogPressure = Math.sqrt(variance);
|
||||
|
||||
const runId = await ctx.db.insert("publisherAbuseScoreRuns", {
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
modelConfig: DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
trigger: "manual",
|
||||
status: "completed",
|
||||
phase: "completed",
|
||||
startedAt,
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
scannedPublishers: SCANNED_PUBLISHERS,
|
||||
scoredPublishers: SCORED_PUBLISHERS,
|
||||
finalizedScores: SCORED_PUBLISHERS,
|
||||
nominatedPublishers,
|
||||
passCount: labelCounts.pass,
|
||||
reviewCount: labelCounts.review,
|
||||
potentialBanCandidateCount: labelCounts.potential_ban_candidate,
|
||||
sumLogPressure,
|
||||
sumSquaredLogPressure,
|
||||
meanLogPressure,
|
||||
stdDevLogPressure,
|
||||
});
|
||||
|
||||
let rank = 1;
|
||||
for (const publisher of SEED_PUBLISHERS) {
|
||||
const handle = demoHandle(publisher.index);
|
||||
const ownerKey = demoOwnerKey(publisher.index);
|
||||
const raw = computePublisherAbuseRawScore({
|
||||
ownerKey,
|
||||
handleSnapshot: handle,
|
||||
publishedSkills: publisher.publishedSkills,
|
||||
totalInstalls: publisher.totalInstalls,
|
||||
totalStars: publisher.totalStars,
|
||||
totalDownloads: publisher.totalDownloads,
|
||||
});
|
||||
|
||||
const lastScoredAt = completedAt;
|
||||
const openedAt = completedAt;
|
||||
const reviewed = publisher.status !== "pending";
|
||||
const reviewedAt = reviewed ? completedAt + publisher.index * 60_000 : undefined;
|
||||
const updatedAt = reviewedAt ?? completedAt;
|
||||
|
||||
const ownerUserId = publisher.linkUser
|
||||
? await ctx.db.insert("users", {
|
||||
handle,
|
||||
name: `Demo Abuse Publisher ${paddedIndex(publisher.index)}`,
|
||||
role: "user",
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - DAY_MS,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const scoreId = await ctx.db.insert("publisherAbuseScores", {
|
||||
runId,
|
||||
ownerKey,
|
||||
ownerPublisherId: undefined,
|
||||
ownerUserId,
|
||||
handleSnapshot: handle,
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
label: publisher.label,
|
||||
rank,
|
||||
pressure: raw.pressure,
|
||||
logPressure: raw.logPressure,
|
||||
zScore: publisher.zScore,
|
||||
publishedSkills: raw.publishedSkills,
|
||||
totalInstalls: raw.totalInstalls,
|
||||
totalStars: raw.totalStars,
|
||||
totalDownloads: raw.totalDownloads,
|
||||
installsPerSkill: raw.installsPerSkill,
|
||||
starsPerSkill: raw.starsPerSkill,
|
||||
downloadsPerSkill: raw.downloadsPerSkill,
|
||||
reasonCodes: publisher.reasonCodes,
|
||||
createdAt: now - DAY_MS,
|
||||
});
|
||||
rank += 1;
|
||||
|
||||
await ctx.db.insert("publisherAbuseReviewNominations", {
|
||||
ownerKey,
|
||||
ownerPublisherId: undefined,
|
||||
ownerUserId,
|
||||
handleSnapshot: handle,
|
||||
latestScoreId: scoreId,
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
label: publisher.label,
|
||||
status: publisher.status,
|
||||
openedAt,
|
||||
openedByRunId: runId,
|
||||
lastScoredAt,
|
||||
reviewedByUserId: undefined,
|
||||
reviewedAt,
|
||||
notes: publisher.notes,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await seedTemporalCohortDemoRows(ctx, { now });
|
||||
|
||||
return { runId, inserted: SEED_PUBLISHERS.length + 1 };
|
||||
},
|
||||
});
|
||||
|
||||
export const clearSeed = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ClearSeedResult> => {
|
||||
assertLocalDevSeedAllowed("Publisher abuse");
|
||||
return await clearDemoRows(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number }) {
|
||||
const now = args.now;
|
||||
const todayDay = Math.floor(now / DAY_MS);
|
||||
const temporalBenchmark = {
|
||||
sampleSize: 1000,
|
||||
downloads30dAverage: 180,
|
||||
downloads30dMedian: 45,
|
||||
downloads30dP95: 900,
|
||||
downloads30dP99: 3000,
|
||||
spikeMultiplier7dP95: 4,
|
||||
spikeMultiplier7dP99: 12,
|
||||
};
|
||||
const temporalUserId = await ctx.db.insert("users", {
|
||||
handle: TEMPORAL_DEMO_HANDLE,
|
||||
name: "Demo Temporal Abuse Publisher",
|
||||
role: "user",
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - DAY_MS,
|
||||
});
|
||||
const temporalPublisherId = await ctx.db.insert("publishers", {
|
||||
kind: "user",
|
||||
handle: TEMPORAL_DEMO_HANDLE,
|
||||
displayName: "Demo Temporal Abuse Publisher",
|
||||
linkedUserId: temporalUserId,
|
||||
publishedSkills: 1,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 16_200,
|
||||
totalStars: 0,
|
||||
skillTotalInstalls: 0,
|
||||
skillTotalDownloads: 16_200,
|
||||
skillTotalStars: 0,
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
const temporalSkillId = await ctx.db.insert("skills", {
|
||||
slug: TEMPORAL_DEMO_SKILL_SLUG,
|
||||
displayName: "Demo Temporal Download Burst",
|
||||
summary: "Synthetic fixture: high 30-day downloads with zero installs.",
|
||||
ownerUserId: temporalUserId,
|
||||
ownerPublisherId: temporalPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
moderationStatus: "active",
|
||||
statsDownloads: 16_200,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 16_200,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
for (let offset = 59; offset >= 30; offset -= 1) {
|
||||
await ctx.db.insert("skillDailyStats", {
|
||||
skillId: temporalSkillId,
|
||||
day: todayDay - offset,
|
||||
downloads: 4,
|
||||
installs: 0,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
}
|
||||
for (let offset = 29; offset >= 0; offset -= 1) {
|
||||
await ctx.db.insert("skillDailyStats", {
|
||||
skillId: temporalSkillId,
|
||||
day: todayDay - offset,
|
||||
downloads: 540,
|
||||
installs: 0,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const temporalStartedAt = now - 35 * 60_000;
|
||||
const temporalCompletedAt = now - 30 * 60_000;
|
||||
const temporalRunId = await ctx.db.insert("publisherAbuseScoreRuns", {
|
||||
modelVersion: PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
|
||||
modelConfig: DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
trigger: "manual",
|
||||
status: "completed",
|
||||
phase: "completed",
|
||||
startedAt: temporalStartedAt,
|
||||
completedAt: temporalCompletedAt,
|
||||
updatedAt: temporalCompletedAt,
|
||||
scannedPublishers: temporalBenchmark.sampleSize,
|
||||
scoredPublishers: 1,
|
||||
finalizedScores: 1,
|
||||
nominatedPublishers: 1,
|
||||
passCount: 0,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 1,
|
||||
sumLogPressure: 0,
|
||||
sumSquaredLogPressure: 0,
|
||||
meanLogPressure: 0,
|
||||
stdDevLogPressure: 0,
|
||||
temporalBenchmark,
|
||||
});
|
||||
const temporalScoreId = await ctx.db.insert("publisherAbuseScores", {
|
||||
runId: temporalRunId,
|
||||
ownerKey: TEMPORAL_DEMO_OWNER_KEY,
|
||||
ownerPublisherId: temporalPublisherId,
|
||||
ownerUserId: temporalUserId,
|
||||
handleSnapshot: TEMPORAL_DEMO_HANDLE,
|
||||
modelVersion: PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
|
||||
label: "potential_ban_candidate",
|
||||
rank: 1,
|
||||
pressure: 18,
|
||||
logPressure: Math.log10(18),
|
||||
zScore: 3.13,
|
||||
publishedSkills: 1,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 16_200,
|
||||
installsPerSkill: 0,
|
||||
starsPerSkill: 0,
|
||||
downloadsPerSkill: 16_200,
|
||||
reasonCodes: ["temporal_sustained_downloads_flat_installs"],
|
||||
temporalHighSkillCount: 1,
|
||||
temporalSpikeSkillCount: 0,
|
||||
temporalSustainedSkillCount: 1,
|
||||
temporalMaxPressure: 18,
|
||||
temporalBenchmark,
|
||||
temporalEvidence: [
|
||||
{
|
||||
skillId: temporalSkillId,
|
||||
slug: TEMPORAL_DEMO_SKILL_SLUG,
|
||||
displayName: "Demo Temporal Download Burst",
|
||||
spike: false,
|
||||
sustained: true,
|
||||
pressure: 18,
|
||||
recent7Downloads: 3_780,
|
||||
recent7Installs: 0,
|
||||
previous30Downloads: 120,
|
||||
baseline7Downloads: 100,
|
||||
spikeMultiplier: 8,
|
||||
recent30Downloads: 16_200,
|
||||
recent30Installs: 0,
|
||||
downloadInstallRatio30: 16_200,
|
||||
downloads30dCohortBand: "p99",
|
||||
spikeMultiplierCohortBand: "p95",
|
||||
downloads30dVsPeerP95: 18,
|
||||
spikeMultiplierVsPeerP95: 2,
|
||||
sustainedWindowStartDay: todayDay - 29,
|
||||
sustainedWindowEndDay: todayDay,
|
||||
reasonCodes: ["temporal_sustained_downloads_flat_installs"],
|
||||
},
|
||||
],
|
||||
createdAt: temporalCompletedAt,
|
||||
});
|
||||
await ctx.db.insert("publisherAbuseReviewNominations", {
|
||||
ownerKey: TEMPORAL_DEMO_OWNER_KEY,
|
||||
ownerPublisherId: temporalPublisherId,
|
||||
ownerUserId: temporalUserId,
|
||||
handleSnapshot: TEMPORAL_DEMO_HANDLE,
|
||||
latestScoreId: temporalScoreId,
|
||||
modelVersion: PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
openedAt: temporalCompletedAt,
|
||||
openedByRunId: temporalRunId,
|
||||
lastScoredAt: temporalCompletedAt,
|
||||
updatedAt: temporalCompletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDemoRows(ctx: ClearSeedCtx): Promise<ClearSeedResult> {
|
||||
let runs = 0;
|
||||
let scores = 0;
|
||||
let nominations = 0;
|
||||
let events = 0;
|
||||
let users = 0;
|
||||
let hasMore = false;
|
||||
|
||||
const demoRunIds = new Set<Id<"publisherAbuseScoreRuns">>();
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoScoresPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const score of page.page) {
|
||||
if (!isDemoOwnerKey(score.ownerKey) && !isDemoHandle(score.handleSnapshot)) continue;
|
||||
demoRunIds.add(score.runId);
|
||||
await ctx.db.delete(score._id);
|
||||
scores += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoNominationsPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const nomination of page.page) {
|
||||
if (!isDemoOwnerKey(nomination.ownerKey) && !isDemoHandle(nomination.handleSnapshot)) {
|
||||
continue;
|
||||
}
|
||||
demoRunIds.add(nomination.openedByRunId);
|
||||
await ctx.db.delete(nomination._id);
|
||||
nominations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoEventsPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const event of page.page) {
|
||||
if (!isDemoOwnerKey(event.ownerKey)) continue;
|
||||
await ctx.db.delete(event._id);
|
||||
events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await clearTemporalDemoSkillRows(ctx);
|
||||
await clearTemporalDemoPublisherRows(ctx);
|
||||
|
||||
for (const runId of demoRunIds) {
|
||||
const run = await ctx.db.get(runId);
|
||||
if (!run) continue;
|
||||
await ctx.db.delete(runId);
|
||||
runs += 1;
|
||||
}
|
||||
|
||||
for (const handle of DEMO_HANDLES) {
|
||||
const page = await queryDemoUsersPage(ctx, handle);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const user of page.page) {
|
||||
if (!user.handle || !isDemoHandle(user.handle)) continue;
|
||||
await ctx.db.delete(user._id);
|
||||
users += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { runs, scores, nominations, events, users, hasMore };
|
||||
}
|
||||
|
||||
async function clearTemporalDemoSkillRows(ctx: ClearSeedCtx) {
|
||||
const rows = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", TEMPORAL_DEMO_SKILL_SLUG))
|
||||
.take(CLEAR_SEED_BATCH_SIZE);
|
||||
for (const skill of rows) {
|
||||
const dailyStats = await ctx.db
|
||||
.query("skillDailyStats")
|
||||
.withIndex("by_skill_day", (q) => q.eq("skillId", skill._id))
|
||||
.take(CLEAR_SEED_BATCH_SIZE);
|
||||
for (const stat of dailyStats) {
|
||||
await ctx.db.delete(stat._id);
|
||||
}
|
||||
await ctx.db.delete(skill._id);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTemporalDemoPublisherRows(ctx: ClearSeedCtx) {
|
||||
const rows = await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_handle", (q) => q.eq("handle", TEMPORAL_DEMO_HANDLE))
|
||||
.take(CLEAR_SEED_BATCH_SIZE);
|
||||
for (const publisher of rows) {
|
||||
await ctx.db.delete(publisher._id);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryDemoScoresPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseScores">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseScores")
|
||||
.withIndex("by_owner_key_and_created_at", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoNominationsPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseReviewNominations">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoEventsPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseReviewEvents">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseReviewEvents")
|
||||
.withIndex("by_owner_key_and_created_at", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoUsersPage(
|
||||
ctx: ClearSeedCtx,
|
||||
handle: string,
|
||||
): Promise<{ page: Doc<"users">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
+1376
-29
File diff suppressed because it is too large
Load Diff
+599
-54
@@ -1,5 +1,6 @@
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
@@ -10,6 +11,12 @@ import {
|
||||
formatReservedPublicOwnerHandleMessage,
|
||||
isReservedPublicOwnerHandle,
|
||||
} from "./lib/publicRouteReservations";
|
||||
import {
|
||||
buildGitHubSkillCatalogDisplay,
|
||||
type GitHubSkillCatalogDisplay,
|
||||
type GitHubSkillCatalogItem,
|
||||
type GitHubSkillCatalogSource,
|
||||
} from "./lib/publisherCatalogDisplay";
|
||||
import {
|
||||
canAccessPublisherOwnerScope,
|
||||
ensurePersonalPublisherForUser,
|
||||
@@ -46,10 +53,14 @@ type PublisherPublishedItem = {
|
||||
displayName: string;
|
||||
downloads: number;
|
||||
};
|
||||
type PublisherPublishedPreviewItem = PublisherPublishedItem & {
|
||||
installs: number;
|
||||
};
|
||||
|
||||
type PublisherCatalogItem = {
|
||||
_id: Id<"skills"> | Id<"packages">;
|
||||
kind: "skill" | "plugin";
|
||||
slug?: string;
|
||||
displayName: string;
|
||||
summary: string | null;
|
||||
// Mirrors `skills.icon` for `kind: "skill"` items so the publisher
|
||||
@@ -62,6 +73,11 @@ type PublisherCatalogItem = {
|
||||
stars: number;
|
||||
isOfficial: boolean;
|
||||
updatedAt: number;
|
||||
sourceBacked?: boolean;
|
||||
sourceId?: Id<"githubSkillSources"> | null;
|
||||
sourceRepo?: string | null;
|
||||
sourcePath?: string | null;
|
||||
sourceVerifiedCommit?: string | null;
|
||||
};
|
||||
|
||||
type PublisherCatalogSort = "downloads" | "recent";
|
||||
@@ -79,8 +95,13 @@ type PublisherListItem = NonNullable<ReturnType<typeof toPublicPublisher>> & {
|
||||
type PublisherListSummary = {
|
||||
publisher: Doc<"publishers">;
|
||||
item: PublisherListItem;
|
||||
visibility?: PublicPublisherVisibility;
|
||||
};
|
||||
|
||||
function isPublicPublishedSkill(skill: Doc<"skills">) {
|
||||
return !skill.softDeletedAt && (!skill.moderationStatus || skill.moderationStatus === "active");
|
||||
}
|
||||
|
||||
type PublicPublisherKindFilter = "user" | "org";
|
||||
type PublisherListCounts = {
|
||||
all: number;
|
||||
@@ -138,6 +159,45 @@ function hasPublisherStats(publisher: Doc<"publishers">) {
|
||||
);
|
||||
}
|
||||
|
||||
type PublicPublisherVisibility = {
|
||||
publisher: Doc<"publishers">;
|
||||
linkedUser: Doc<"users"> | null;
|
||||
};
|
||||
|
||||
async function getPublicPublisherVisibility(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisher: Doc<"publishers"> | null | undefined,
|
||||
): Promise<PublicPublisherVisibility | null> {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
if (publisher.kind !== "user") {
|
||||
return { publisher, linkedUser: null };
|
||||
}
|
||||
if (!publisher.linkedUserId) {
|
||||
const legacyOwner = await getLegacyPersonalPublisherOwner(ctx, publisher._id);
|
||||
return legacyOwner ? { publisher, linkedUser: legacyOwner } : null;
|
||||
}
|
||||
|
||||
const linkedUser = await ctx.db.get(publisher.linkedUserId);
|
||||
if (!linkedUser || linkedUser.deletedAt || linkedUser.deactivatedAt) return null;
|
||||
return { publisher, linkedUser };
|
||||
}
|
||||
|
||||
async function getLegacyPersonalPublisherOwner(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisherId: Id<"publishers">,
|
||||
) {
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.collect();
|
||||
for (const membership of memberships) {
|
||||
if (membership.role !== "owner") continue;
|
||||
const user = await ctx.db.get(membership.userId);
|
||||
if (user && !user.deletedAt && !user.deactivatedAt) return user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPublisherDenormalizedStats(publisher: Doc<"publishers">): PublisherListStats {
|
||||
return {
|
||||
skills: publisher.publishedSkills ?? 0,
|
||||
@@ -171,7 +231,7 @@ async function getPublisherPublishedRows(
|
||||
)
|
||||
.collect(),
|
||||
]);
|
||||
return { skills, packages };
|
||||
return { skills: skills.filter(isPublicPublishedSkill), packages };
|
||||
}
|
||||
|
||||
async function getPublisherPublishedPreviewRows(
|
||||
@@ -181,20 +241,20 @@ async function getPublisherPublishedPreviewRows(
|
||||
const [skills, packages] = await Promise.all([
|
||||
ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher_active_downloads", (q) =>
|
||||
.withIndex("by_owner_publisher_active_installs", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher_active_downloads", (q) =>
|
||||
.withIndex("by_owner_publisher_active_installs", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
]);
|
||||
return { skills, packages };
|
||||
return { skills: skills.filter(isPublicPublishedSkill), packages };
|
||||
}
|
||||
|
||||
function getIndexedPublisherStatsFromRows(rows: PublisherPublishedRows): PublisherListStats {
|
||||
@@ -217,21 +277,37 @@ function getIndexedPublisherStatsFromRows(rows: PublisherPublishedRows): Publish
|
||||
return stats;
|
||||
}
|
||||
|
||||
function getPublisherPublishedItems(rows: PublisherPublishedRows): PublisherPublishedItem[] {
|
||||
return [
|
||||
function getPublisherPublishedItems(
|
||||
rows: PublisherPublishedRows,
|
||||
limit = PUBLISHER_LIST_PREVIEW_LIMIT,
|
||||
): PublisherPublishedItem[] {
|
||||
const items: PublisherPublishedPreviewItem[] = [
|
||||
...rows.skills.map((skill) => ({
|
||||
kind: "skill" as const,
|
||||
displayName: skill.displayName,
|
||||
downloads: readCanonicalStat(skill, "downloads"),
|
||||
installs: readCanonicalStat(skill, "installsAllTime"),
|
||||
})),
|
||||
...rows.packages.map((pkg) => ({
|
||||
kind: pkg.family === "skill" ? ("skill" as const) : ("plugin" as const),
|
||||
displayName: pkg.displayName,
|
||||
downloads: pkg.stats.downloads,
|
||||
installs: pkg.stats.installs,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => b.downloads - a.downloads || a.displayName.localeCompare(b.displayName))
|
||||
.slice(0, 3);
|
||||
];
|
||||
return items
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.installs - a.installs ||
|
||||
b.downloads - a.downloads ||
|
||||
a.displayName.localeCompare(b.displayName),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((item) => ({
|
||||
kind: item.kind,
|
||||
displayName: item.displayName,
|
||||
downloads: item.downloads,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPluginDetailHref(name: string) {
|
||||
@@ -277,6 +353,7 @@ function getPublisherCatalogItems(
|
||||
...rows.skills.map((skill) => ({
|
||||
_id: skill._id,
|
||||
kind: "skill" as const,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? null,
|
||||
icon: skill.icon ?? null,
|
||||
@@ -285,6 +362,10 @@ function getPublisherCatalogItems(
|
||||
stars: readCanonicalStat(skill, "stars"),
|
||||
isOfficial: publisherOfficial || Boolean(skill.badges?.official),
|
||||
updatedAt: skill.updatedAt,
|
||||
sourceBacked: skill.installKind === "github",
|
||||
sourceId: skill.githubSourceId ?? null,
|
||||
sourceRepo: null,
|
||||
sourcePath: skill.githubPath ?? null,
|
||||
})),
|
||||
...rows.packages.map((pkg) => ({
|
||||
_id: pkg._id,
|
||||
@@ -301,22 +382,57 @@ function getPublisherCatalogItems(
|
||||
].sort(comparePublisherCatalogItems(sort));
|
||||
}
|
||||
|
||||
function toGitHubSkillCatalogSource(source: Doc<"githubSkillSources">): GitHubSkillCatalogSource {
|
||||
return {
|
||||
_id: source._id,
|
||||
repo: source.repo,
|
||||
displayManifestStatus: source.displayManifestStatus,
|
||||
displayManifest: source.displayManifest,
|
||||
};
|
||||
}
|
||||
|
||||
function toGitHubSkillCatalogItem(
|
||||
item: PublisherCatalogItem,
|
||||
sourceById: Map<string, Doc<"githubSkillSources">>,
|
||||
): GitHubSkillCatalogItem {
|
||||
const sourceId = item.sourceId ? String(item.sourceId) : null;
|
||||
return {
|
||||
_id: String(item._id),
|
||||
kind: item.kind,
|
||||
slug: item.slug ?? null,
|
||||
displayName: item.displayName,
|
||||
summary: item.summary,
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
downloads: item.downloads,
|
||||
stars: item.stars,
|
||||
isOfficial: item.isOfficial,
|
||||
updatedAt: item.updatedAt,
|
||||
sourceBacked: item.sourceBacked ?? false,
|
||||
sourceId,
|
||||
sourceRepo: sourceId ? (sourceById.get(sourceId)?.repo ?? null) : null,
|
||||
sourcePath: item.sourcePath ?? null,
|
||||
sourceVerifiedCommit: item.sourceVerifiedCommit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function toPublisherListItem(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisher: Doc<"publishers">,
|
||||
options: {
|
||||
forceComputedStats?: boolean;
|
||||
includePublishedItems?: boolean;
|
||||
includeAllPublishedItems?: boolean;
|
||||
includeAffiliations?: boolean;
|
||||
includeStarredCount?: boolean;
|
||||
visibility?: PublicPublisherVisibility;
|
||||
} = {},
|
||||
): Promise<PublisherListItem | null> {
|
||||
const visible = options.visibility ?? (await getPublicPublisherVisibility(ctx, publisher));
|
||||
if (!visible) return null;
|
||||
const publicPublisher = await toPublicPublisherWithOfficial(ctx, publisher);
|
||||
if (!publicPublisher) return null;
|
||||
const linkedUser =
|
||||
publisher.kind === "user" && publisher.linkedUserId
|
||||
? await ctx.db.get(publisher.linkedUserId)
|
||||
: null;
|
||||
const linkedUser = visible.linkedUser;
|
||||
let publishedRows: PublisherPublishedRows | null = null;
|
||||
const getRows = async () => {
|
||||
publishedRows ??= await getPublisherPublishedRows(ctx, publisher._id);
|
||||
@@ -329,15 +445,19 @@ async function toPublisherListItem(
|
||||
? getPublisherDenormalizedStats(publisher)
|
||||
: getIndexedPublisherStatsFromRows(await getRows());
|
||||
const publishedItems = options.includePublishedItems
|
||||
? getPublisherPublishedItems(await getPreviewRows())
|
||||
? getPublisherPublishedItems(
|
||||
await (options.includeAllPublishedItems ? getRows() : getPreviewRows()),
|
||||
options.includeAllPublishedItems ? Number.POSITIVE_INFINITY : PUBLISHER_LIST_PREVIEW_LIMIT,
|
||||
)
|
||||
: [];
|
||||
const visibleUserId = publisher.kind === "user" ? linkedUser?._id : null;
|
||||
const affiliations =
|
||||
options.includeAffiliations && publisher.kind === "user" && publisher.linkedUserId
|
||||
? await getUserPublisherAffiliations(ctx, publisher.linkedUserId, publisher._id)
|
||||
options.includeAffiliations && visibleUserId
|
||||
? await getUserPublisherAffiliations(ctx, visibleUserId, publisher._id)
|
||||
: undefined;
|
||||
const starredCount =
|
||||
options.includeStarredCount && publisher.kind === "user" && publisher.linkedUserId
|
||||
? await getUserStarredCount(ctx, publisher.linkedUserId)
|
||||
options.includeStarredCount && visibleUserId
|
||||
? await getUserStarredCount(ctx, visibleUserId)
|
||||
: undefined;
|
||||
return {
|
||||
...publicPublisher,
|
||||
@@ -363,18 +483,44 @@ function toPublisherListSummary(publisher: Doc<"publishers">): PublisherListSumm
|
||||
};
|
||||
}
|
||||
|
||||
async function toVisiblePublisherListSummary(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisher: Doc<"publishers">,
|
||||
): Promise<PublisherListSummary | null> {
|
||||
const visibility = await getPublicPublisherVisibility(ctx, publisher);
|
||||
if (!visibility) return null;
|
||||
const summary = toPublisherListSummary(visibility.publisher);
|
||||
if (!summary) return null;
|
||||
return { ...summary, visibility };
|
||||
}
|
||||
|
||||
function hasPublisherListContent(summary: PublisherListSummary) {
|
||||
if (!hasPublisherStats(summary.publisher)) return true;
|
||||
return summary.item.stats.skills + summary.item.stats.packages > 0;
|
||||
}
|
||||
|
||||
async function getVisiblePublisherListSummaries(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publishers: Doc<"publishers">[],
|
||||
) {
|
||||
const summaries = await Promise.all(
|
||||
publishers.map((publisher) => toVisiblePublisherListSummary(ctx, publisher)),
|
||||
);
|
||||
return summaries
|
||||
.filter((summary): summary is PublisherListSummary => Boolean(summary))
|
||||
.filter(hasPublisherListContent);
|
||||
}
|
||||
|
||||
async function hydratePublisherListSummaries(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
summaries: PublisherListSummary[],
|
||||
) {
|
||||
const items = await Promise.all(
|
||||
summaries.map((summary) =>
|
||||
toPublisherListItem(ctx, summary.publisher, { includePublishedItems: true }),
|
||||
toPublisherListItem(ctx, summary.publisher, {
|
||||
includePublishedItems: true,
|
||||
visibility: summary.visibility,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return items
|
||||
@@ -881,6 +1027,120 @@ async function createOrgPublisherForUser(
|
||||
};
|
||||
}
|
||||
|
||||
async function hardDeletePublisherRows(ctx: MutationCtx, publisherId: Id<"publishers">) {
|
||||
const sources = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", publisherId))
|
||||
.collect();
|
||||
let sourceContents = 0;
|
||||
for (const source of sources) {
|
||||
const contents = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", source._id))
|
||||
.collect();
|
||||
sourceContents += contents.length;
|
||||
for (const content of contents) await ctx.db.delete(content._id);
|
||||
await ctx.db.delete(source._id);
|
||||
}
|
||||
|
||||
const members = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.collect();
|
||||
for (const member of members) await ctx.db.delete(member._id);
|
||||
|
||||
const official = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.unique();
|
||||
if (official) await ctx.db.delete(official._id);
|
||||
|
||||
await ctx.db.delete(publisherId);
|
||||
|
||||
return {
|
||||
sources: sources.length,
|
||||
sourceContents,
|
||||
members: members.length,
|
||||
official: Boolean(official),
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteOrgPublisherForOwner(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
publisherId: Id<"publishers">;
|
||||
deletedAt: number;
|
||||
source: "settings" | "account.delete";
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const publisher = await ctx.db.get(args.publisherId);
|
||||
if (!publisher || publisher.kind !== "org" || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError("Publisher not found");
|
||||
}
|
||||
|
||||
const membership = await getPublisherMembership(ctx, publisher._id, args.actorUserId);
|
||||
if (!membership || membership.role !== "owner") {
|
||||
throw new ConvexError("Only org owners can delete an organization");
|
||||
}
|
||||
|
||||
await ctx.db.patch(publisher._id, {
|
||||
deletedAt: args.deletedAt,
|
||||
deactivatedAt: args.deletedAt,
|
||||
updatedAt: args.deletedAt,
|
||||
});
|
||||
|
||||
const skillsResult = (await ctx.runMutation(
|
||||
internal.skills.applyPublisherDeletionToOwnedSkillsBatchInternal,
|
||||
{
|
||||
ownerPublisherId: publisher._id,
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
cursor: undefined,
|
||||
},
|
||||
)) as { hiddenCount?: number; scheduled?: boolean };
|
||||
const packagesResult = (await ctx.runMutation(
|
||||
internal.packages.applyPublisherDeletionToOwnedPackagesBatchInternal,
|
||||
{
|
||||
ownerPublisherId: publisher._id,
|
||||
actorUserId: args.actorUserId,
|
||||
deletedAt: args.deletedAt,
|
||||
cursor: undefined,
|
||||
},
|
||||
)) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean };
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.org.delete",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
handle: publisher.handle,
|
||||
source: args.source,
|
||||
hiddenSkills: skillsResult.hiddenCount ?? 0,
|
||||
deletedPackages: packagesResult.deletedCount ?? 0,
|
||||
revokedPackageTokens: packagesResult.revokedTokenCount ?? 0,
|
||||
scheduled: Boolean(skillsResult.scheduled) || Boolean(packagesResult.scheduled) || undefined,
|
||||
},
|
||||
createdAt: args.deletedAt,
|
||||
});
|
||||
const deletedPublisherRows = await hardDeletePublisherRows(ctx, publisher._id);
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
hiddenSkills: skillsResult.hiddenCount ?? 0,
|
||||
deletedPackages: packagesResult.deletedCount ?? 0,
|
||||
revokedPackageTokens: packagesResult.revokedTokenCount ?? 0,
|
||||
scheduled: Boolean(skillsResult.scheduled) || Boolean(packagesResult.scheduled),
|
||||
deletedPublisherRows,
|
||||
};
|
||||
}
|
||||
|
||||
export const getByIdInternal = internalQuery({
|
||||
args: { publisherId: v.id("publishers") },
|
||||
handler: async (ctx, args) => await ctx.db.get(args.publisherId),
|
||||
@@ -1020,7 +1280,12 @@ export const listMine = query({
|
||||
!publisher.linkedUserId && user.personalPublisherId === publisher._id;
|
||||
if (!isLinkedPersonal && !isLegacyPersonal) return null;
|
||||
}
|
||||
const publicPublisher = await toPublicPublisherWithOfficial(ctx, publisher);
|
||||
const publicPublisher = publisher
|
||||
? await toPublisherListItem(ctx, publisher, {
|
||||
includePublishedItems: true,
|
||||
includeAllPublishedItems: true,
|
||||
})
|
||||
: null;
|
||||
if (!publicPublisher) return null;
|
||||
return {
|
||||
publisher: publicPublisher,
|
||||
@@ -1029,17 +1294,15 @@ export const listMine = query({
|
||||
}),
|
||||
);
|
||||
const visiblePublishers = publishers.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
publisher: NonNullable<ReturnType<typeof toPublicPublisher>>;
|
||||
role: Doc<"publisherMembers">["role"];
|
||||
} => Boolean(item),
|
||||
);
|
||||
const personalPublisher = await toPublicPublisherWithOfficial(
|
||||
ctx,
|
||||
await getPersonalPublisherForUserOrFallback(ctx, user),
|
||||
(item): item is NonNullable<(typeof publishers)[number]> => Boolean(item),
|
||||
);
|
||||
const personalPublisherDoc = await getPersonalPublisherForUserOrFallback(ctx, user);
|
||||
const personalPublisher = personalPublisherDoc
|
||||
? await toPublisherListItem(ctx, personalPublisherDoc, {
|
||||
includePublishedItems: true,
|
||||
includeAllPublishedItems: true,
|
||||
})
|
||||
: null;
|
||||
if (
|
||||
personalPublisher &&
|
||||
!visiblePublishers.some((entry) => entry.publisher._id === personalPublisher._id)
|
||||
@@ -1081,17 +1344,12 @@ export const listStarredPage = query({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await getPublisherByHandle(ctx, args.handle);
|
||||
if (
|
||||
!publisher ||
|
||||
publisher.kind !== "user" ||
|
||||
!publisher.linkedUserId ||
|
||||
publisher.deletedAt ||
|
||||
publisher.deactivatedAt
|
||||
) {
|
||||
const visible = await getPublicPublisherVisibility(ctx, publisher);
|
||||
if (!visible?.linkedUser || visible.publisher.kind !== "user") {
|
||||
return { page: [], continueCursor: "", isDone: true };
|
||||
}
|
||||
|
||||
const linkedUserId = publisher.linkedUserId;
|
||||
const linkedUserId = visible.linkedUser._id;
|
||||
const numItems = clampInt(args.paginationOpts.numItems, 1, 24);
|
||||
const offset = args.paginationOpts.cursor ? Number(args.paginationOpts.cursor) : 0;
|
||||
const safeOffset = Number.isFinite(offset) && offset > 0 ? Math.trunc(offset) : 0;
|
||||
@@ -1151,17 +1409,19 @@ export const listPublishedPage = query({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await getPublisherByHandle(ctx, args.handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
const visible = await getPublicPublisherVisibility(ctx, publisher);
|
||||
if (!visible) {
|
||||
return { page: [], continueCursor: "", isDone: true };
|
||||
}
|
||||
const visiblePublisher = visible.publisher;
|
||||
|
||||
const numItems = clampInt(args.paginationOpts.numItems, 1, 24);
|
||||
const offset = args.paginationOpts.cursor ? Number(args.paginationOpts.cursor) : 0;
|
||||
const safeOffset = Number.isFinite(offset) && offset > 0 ? Math.trunc(offset) : 0;
|
||||
const items = getPublisherCatalogItems(
|
||||
publisher,
|
||||
await getPublisherPublishedRows(ctx, publisher._id),
|
||||
await isOfficialPublisher(ctx, publisher),
|
||||
visiblePublisher,
|
||||
await getPublisherPublishedRows(ctx, visiblePublisher._id),
|
||||
await isOfficialPublisher(ctx, visiblePublisher),
|
||||
args.sort ?? "downloads",
|
||||
).filter((item) => !args.kind || item.kind === args.kind);
|
||||
const nextOffset = safeOffset + numItems;
|
||||
@@ -1175,6 +1435,46 @@ export const listPublishedPage = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPublishedDisplayManifest = query({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
kind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
|
||||
sort: v.optional(v.union(v.literal("downloads"), v.literal("recent"))),
|
||||
},
|
||||
handler: async (ctx, args): Promise<GitHubSkillCatalogDisplay | null> => {
|
||||
if (args.kind === "plugin") return null;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, args.handle);
|
||||
const visible = await getPublicPublisherVisibility(ctx, publisher);
|
||||
if (!visible) return null;
|
||||
const visiblePublisher = visible.publisher;
|
||||
|
||||
const sources = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", visiblePublisher._id))
|
||||
.collect();
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const rows = await getPublisherPublishedRows(ctx, visiblePublisher._id);
|
||||
if (!args.kind && rows.packages.length > 0) return null;
|
||||
|
||||
const sourceById = new Map(sources.map((source) => [String(source._id), source]));
|
||||
const items = getPublisherCatalogItems(
|
||||
visiblePublisher,
|
||||
rows,
|
||||
await isOfficialPublisher(ctx, visiblePublisher),
|
||||
args.sort ?? "downloads",
|
||||
)
|
||||
.filter((item) => !args.kind || item.kind === args.kind)
|
||||
.map((item) => toGitHubSkillCatalogItem(item, sourceById));
|
||||
|
||||
return buildGitHubSkillCatalogDisplay({
|
||||
sources: sources.map(toGitHubSkillCatalogSource),
|
||||
items,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
@@ -1249,10 +1549,7 @@ export const listPublicPage = query({
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT);
|
||||
const publisherSummaries = activeRows
|
||||
.map(toPublisherListSummary)
|
||||
.filter((summary): summary is PublisherListSummary => Boolean(summary))
|
||||
.filter(hasPublisherListContent);
|
||||
const publisherSummaries = await getVisiblePublisherListSummaries(ctx, activeRows);
|
||||
const itemSummaries = publisherSummaries
|
||||
.filter(
|
||||
(summary) =>
|
||||
@@ -1261,18 +1558,16 @@ export const listPublicPage = query({
|
||||
)
|
||||
.sort((a, b) => comparePublisherListItems(a.item, b.item));
|
||||
const globalPublisherSummaries = kindFilter
|
||||
? (
|
||||
? await getVisiblePublisherListSummaries(
|
||||
ctx,
|
||||
await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_active_total_downloads", (q) =>
|
||||
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT)
|
||||
.take(MAX_PUBLIC_PUBLISHER_LIST_LIMIT),
|
||||
)
|
||||
.map(toPublisherListSummary)
|
||||
.filter((summary): summary is PublisherListSummary => Boolean(summary))
|
||||
.filter(hasPublisherListContent)
|
||||
: publisherSummaries;
|
||||
const globalCounts = getPublisherListSummaryCounts(globalPublisherSummaries);
|
||||
const counts = queryText ? getPublisherListSummaryCounts(itemSummaries) : globalCounts;
|
||||
@@ -1296,10 +1591,11 @@ export const listMembers = query({
|
||||
args: { publisherHandle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await getPublisherByHandle(ctx, args.publisherHandle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
const visible = await getPublicPublisherVisibility(ctx, publisher);
|
||||
if (!visible) return null;
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", visible.publisher._id))
|
||||
.collect();
|
||||
const items = await Promise.all(
|
||||
memberships.map(async (membership) => {
|
||||
@@ -1319,7 +1615,7 @@ export const listMembers = query({
|
||||
}),
|
||||
);
|
||||
return {
|
||||
publisher: await toPublicPublisherWithOfficial(ctx, publisher),
|
||||
publisher: await toPublicPublisherWithOfficial(ctx, visible.publisher),
|
||||
members: items.filter(Boolean),
|
||||
};
|
||||
},
|
||||
@@ -1350,6 +1646,28 @@ export const createOrg = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteOrg = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
return await deleteOrgPublisherForOwner(ctx, {
|
||||
actorUserId: userId,
|
||||
publisherId: args.publisherId,
|
||||
deletedAt: Date.now(),
|
||||
source: "settings",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const hardDeletePublisherRowsInternal = internalMutation({
|
||||
args: { publisherId: v.id("publishers") },
|
||||
handler: async (ctx, args) => {
|
||||
return await hardDeletePublisherRows(ctx, args.publisherId);
|
||||
},
|
||||
});
|
||||
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
@@ -1523,6 +1841,174 @@ export const removeOrgPublisherMemberInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const listOfficialPublishersInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_created", (q) => q)
|
||||
.order("asc")
|
||||
.collect();
|
||||
const items = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const [publisher, createdBy] = await Promise.all([
|
||||
ctx.db.get(row.publisherId),
|
||||
row.createdByUserId ? ctx.db.get(row.createdByUserId) : Promise.resolve(null),
|
||||
]);
|
||||
return {
|
||||
officialPublisherId: row._id,
|
||||
publisherId: row.publisherId,
|
||||
handle: publisher?.handle ?? null,
|
||||
displayName: publisher?.displayName ?? null,
|
||||
kind: publisher?.kind ?? null,
|
||||
active: Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt),
|
||||
reason: row.reason ?? null,
|
||||
createdByUserId: row.createdByUserId ?? null,
|
||||
createdByHandle: createdBy?.handle ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { ok: true as const, items };
|
||||
},
|
||||
});
|
||||
|
||||
export const addOfficialPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
reason: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = normalizePublisherHandle(args.handle);
|
||||
if (!handle) throw new ConvexError("Publisher handle is required");
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError(`Publisher "@${handle}" not found`);
|
||||
}
|
||||
if (publisher.kind !== "org") {
|
||||
throw new ConvexError("Only org publishers can be marked official");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
return {
|
||||
ok: true as const,
|
||||
added: false,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId: existing._id,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const officialPublisherId = await ctx.db.insert("officialPublishers", {
|
||||
publisherId: publisher._id,
|
||||
reason,
|
||||
createdByUserId: args.actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.official.add",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
handle: publisher.handle,
|
||||
reason,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
added: true,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const removeOfficialPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
reason: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = normalizePublisherHandle(args.handle);
|
||||
if (!handle) throw new ConvexError("Publisher handle is required");
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError(`Publisher "@${handle}" not found`);
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.unique();
|
||||
if (!existing) {
|
||||
return {
|
||||
ok: true as const,
|
||||
removed: false,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.delete(existing._id);
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.official.remove",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
handle: publisher.handle,
|
||||
reason,
|
||||
officialPublisherId: existing._id,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
removed: true,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId: existing._id,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createOrgPublisherForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
@@ -1532,6 +2018,65 @@ export const createOrgPublisherForUserInternal = internalMutation({
|
||||
handler: async (ctx, args) => await createOrgPublisherForUser(ctx, args),
|
||||
});
|
||||
|
||||
async function hasOtherActiveOwner(
|
||||
ctx: MutationCtx,
|
||||
members: Array<Doc<"publisherMembers">>,
|
||||
actorUserId: Id<"users">,
|
||||
) {
|
||||
for (const member of members) {
|
||||
if (member.role !== "owner" || member.userId === actorUserId) continue;
|
||||
const user = await ctx.db.get(member.userId);
|
||||
if (user && !user.deletedAt && !user.deactivatedAt) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const deleteSoleOwnerOrgsForAccountDeletionInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
deletedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.actorUserId))
|
||||
.collect();
|
||||
|
||||
let deletedOrgs = 0;
|
||||
let hiddenSkills = 0;
|
||||
let deletedPackages = 0;
|
||||
for (const membership of memberships) {
|
||||
if (membership.role !== "owner") continue;
|
||||
const publisher = await ctx.db.get(membership.publisherId);
|
||||
if (
|
||||
!publisher ||
|
||||
publisher.kind !== "org" ||
|
||||
publisher.deletedAt ||
|
||||
publisher.deactivatedAt
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const members = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.collect();
|
||||
if (await hasOtherActiveOwner(ctx, members, args.actorUserId)) continue;
|
||||
|
||||
const result = await deleteOrgPublisherForOwner(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
publisherId: publisher._id,
|
||||
deletedAt: args.deletedAt,
|
||||
source: "account.delete",
|
||||
});
|
||||
deletedOrgs += 1;
|
||||
hiddenSkills += result.hiddenSkills;
|
||||
deletedPackages += result.deletedPackages;
|
||||
}
|
||||
|
||||
return { ok: true as const, deletedOrgs, hiddenSkills, deletedPackages };
|
||||
},
|
||||
});
|
||||
|
||||
export const addMember = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
|
||||
+241
-3
@@ -191,6 +191,7 @@ const users = defineTable({
|
||||
.index("phone", ["phone"])
|
||||
.index("handle", ["handle"])
|
||||
.index("by_ban_reason_deleted_at", ["banReason", "deletedAt"])
|
||||
.index("by_deactivated_purged_at", ["deactivatedAt", "purgedAt"])
|
||||
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
|
||||
|
||||
const publishers = defineTable({
|
||||
@@ -246,6 +247,94 @@ const publisherMembers = defineTable({
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_publisher_user", ["publisherId", "userId"]);
|
||||
|
||||
const officialPublishers = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
reason: v.optional(v.string()),
|
||||
createdByUserId: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_created", ["createdAt"]);
|
||||
|
||||
const displayManifestStatusValidator = v.union(
|
||||
v.literal("ok"),
|
||||
v.literal("missing"),
|
||||
v.literal("invalid"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const displayManifestValidator = v.object({
|
||||
notGrouped: v.optional(v.union(v.literal("top"), v.literal("bottom"))),
|
||||
groupings: v.array(
|
||||
v.object({
|
||||
title: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
skills: v.array(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const githubSkillSourceInvalidSkillValidator = v.object({
|
||||
slug: v.string(),
|
||||
path: v.string(),
|
||||
displayName: v.string(),
|
||||
error: v.string(),
|
||||
});
|
||||
|
||||
const githubSkillSourceIssueValidator = v.object({
|
||||
slug: v.string(),
|
||||
path: v.string(),
|
||||
displayName: v.string(),
|
||||
kind: v.union(v.literal("invalid_slug"), v.literal("slug_conflict")),
|
||||
severity: v.union(v.literal("error"), v.literal("warning")),
|
||||
message: v.string(),
|
||||
existingOwnerHandle: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const githubSkillSources = defineTable({
|
||||
repo: v.string(),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
lastSyncStatus: v.optional(v.union(v.literal("ok"), v.literal("failed"), v.literal("skipped"))),
|
||||
lastSyncError: v.optional(v.string()),
|
||||
lastSyncErrorAt: v.optional(v.number()),
|
||||
displayManifestKind: v.optional(v.literal("skills.sh")),
|
||||
displayManifestHash: v.optional(v.string()),
|
||||
displayManifestCommit: v.optional(v.string()),
|
||||
displayManifestFetchedAt: v.optional(v.number()),
|
||||
displayManifestStatus: v.optional(displayManifestStatusValidator),
|
||||
displayManifest: v.optional(displayManifestValidator),
|
||||
lastSyncIssues: v.optional(v.array(githubSkillSourceIssueValidator)),
|
||||
// Deprecated. Use lastSyncIssues; kept optional for deployed rows and rollback safety.
|
||||
lastSyncInvalidSkills: v.optional(v.array(githubSkillSourceInvalidSkillValidator)),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_repo", ["repo"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_owner_publisher_and_repo", ["ownerPublisherId", "repo"])
|
||||
.index("by_created", ["createdAt"])
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
|
||||
const githubSkillContents = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
githubSourceId: v.id("githubSkillSources"),
|
||||
githubPath: v.string(),
|
||||
skillMarkdownPath: v.string(),
|
||||
skillMarkdown: v.string(),
|
||||
skillCardMarkdownPath: v.optional(v.string()),
|
||||
skillCardMarkdown: v.optional(v.string()),
|
||||
githubCommit: v.string(),
|
||||
githubContentHash: v.string(),
|
||||
fetchedAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_skill", ["skillId"])
|
||||
.index("by_skill_and_content_hash", ["skillId", "githubContentHash"])
|
||||
.index("by_github_source", ["githubSourceId"]);
|
||||
|
||||
// Shared validator fragments used by both `skills` and `skillSearchDigest`.
|
||||
const forkOfValidator = v.optional(
|
||||
v.object({
|
||||
@@ -292,6 +381,20 @@ const moderationStatusValidator = v.optional(
|
||||
v.union(v.literal("active"), v.literal("hidden"), v.literal("removed")),
|
||||
);
|
||||
|
||||
const githubSkillScanStatusValidator = v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const githubSkillCurrentStatusValidator = v.union(
|
||||
v.literal("present"),
|
||||
v.literal("missing"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
const packageFamilyValidator = v.union(
|
||||
v.literal("skill"),
|
||||
v.literal("code-plugin"),
|
||||
@@ -324,6 +427,7 @@ const publisherAbuseDryRunLabelValidator = v.union(
|
||||
|
||||
const publisherAbuseTriageStatusValidator = v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("banned"),
|
||||
v.literal("reviewed_no_action"),
|
||||
v.literal("false_positive"),
|
||||
v.literal("needs_policy_discussion"),
|
||||
@@ -510,6 +614,16 @@ const skills = defineTable({
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
canonicalSkillId: v.optional(v.id("skills")),
|
||||
forkOf: forkOfValidator,
|
||||
installKind: v.optional(v.literal("github")),
|
||||
githubSourceId: v.optional(v.id("githubSkillSources")),
|
||||
githubPath: v.optional(v.string()),
|
||||
githubHasSkillCard: v.optional(v.boolean()),
|
||||
githubCurrentCommit: v.optional(v.string()),
|
||||
githubCurrentContentHash: v.optional(v.string()),
|
||||
githubCurrentStatus: v.optional(githubSkillCurrentStatusValidator),
|
||||
githubCurrentCheckedAt: v.optional(v.number()),
|
||||
githubScanStatus: v.optional(githubSkillScanStatusValidator),
|
||||
githubRemovedAt: v.optional(v.number()),
|
||||
latestVersionId: v.optional(v.id("skillVersions")),
|
||||
latestVersionSummary: v.optional(
|
||||
v.object({
|
||||
@@ -605,6 +719,12 @@ const skills = defineTable({
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_owner_publisher_active_installs", [
|
||||
"ownerPublisherId",
|
||||
"softDeletedAt",
|
||||
"statsInstallsAllTime",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_stats_downloads", ["statsDownloads", "updatedAt"])
|
||||
.index("by_stats_stars", ["statsStars", "updatedAt"])
|
||||
@@ -624,6 +744,7 @@ const skills = defineTable({
|
||||
.index("by_canonical", ["canonicalSkillId"])
|
||||
.index("by_fork_of", ["forkOf.skillId"])
|
||||
.index("by_moderation", ["moderationStatus", "moderationReason"])
|
||||
.index("by_github_source", ["githubSourceId"])
|
||||
.index("by_nonsuspicious_updated", ["softDeletedAt", "isSuspicious", "updatedAt"])
|
||||
.index("by_nonsuspicious_created", ["softDeletedAt", "isSuspicious", "createdAt"])
|
||||
.index("by_nonsuspicious_name", ["softDeletedAt", "isSuspicious", "displayName"])
|
||||
@@ -697,6 +818,7 @@ const skillVersions = defineTable({
|
||||
),
|
||||
changelog: v.string(),
|
||||
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
|
||||
icon: v.optional(v.string()),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -871,6 +993,8 @@ const skillEmbeddings = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
ownerId: v.id("users"),
|
||||
// Deprecated compatibility field. Ownership lives on skills/search digests;
|
||||
// keep this optional until old rows are pruned or migrated away.
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
embedding: v.array(v.number()),
|
||||
isLatest: v.boolean(),
|
||||
@@ -919,6 +1043,10 @@ const skillSearchDigest = defineTable({
|
||||
forkOf: forkOfValidator,
|
||||
latestVersionId: v.optional(v.id("skillVersions")),
|
||||
latestVersionSkillId: v.optional(v.id("skills")),
|
||||
installKind: v.optional(v.literal("github")),
|
||||
githubHasSkillCard: v.optional(v.boolean()),
|
||||
githubCurrentStatus: v.optional(githubSkillCurrentStatusValidator),
|
||||
githubScanStatus: v.optional(githubSkillScanStatusValidator),
|
||||
latestVersionSummary: v.optional(
|
||||
v.object({
|
||||
version: v.string(),
|
||||
@@ -1055,7 +1183,13 @@ const packages = defineTable({
|
||||
reportCount: v.optional(v.number()),
|
||||
lastReportedAt: v.optional(v.number()),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
softDeletedReason: v.optional(v.union(v.literal("user.banned"), v.literal("user.deactivated"))),
|
||||
softDeletedReason: v.optional(
|
||||
v.union(
|
||||
v.literal("user.banned"),
|
||||
v.literal("user.deactivated"),
|
||||
v.literal("publisher.deleted"),
|
||||
),
|
||||
),
|
||||
softDeletedBy: v.optional(v.id("users")),
|
||||
softDeletedByRole: v.optional(
|
||||
v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")),
|
||||
@@ -1073,6 +1207,12 @@ const packages = defineTable({
|
||||
"stats.downloads",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_owner_publisher_active_installs", [
|
||||
"ownerPublisherId",
|
||||
"softDeletedAt",
|
||||
"stats.installs",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_family_updated", ["family", "updatedAt"])
|
||||
.index("by_family_channel_updated", ["family", "channel", "updatedAt"])
|
||||
.index("by_family_official_updated", ["family", "isOfficial", "updatedAt"])
|
||||
@@ -1103,6 +1243,8 @@ const packageReleases = defineTable({
|
||||
normalizedBundleManifest: v.optional(v.any()),
|
||||
compatibility: packageCompatibilityValidator,
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
runtimeId: v.optional(v.string()),
|
||||
sourceRepo: v.optional(v.string()),
|
||||
verification: packageVerificationValidator,
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
@@ -1270,7 +1412,9 @@ const packageStatEvents = defineTable({
|
||||
kind: v.union(v.literal("download"), v.literal("install")),
|
||||
occurredAt: v.number(),
|
||||
processedAt: v.optional(v.number()),
|
||||
}).index("by_unprocessed", ["processedAt"]);
|
||||
})
|
||||
.index("by_unprocessed", ["processedAt"])
|
||||
.index("by_package", ["packageId"]);
|
||||
|
||||
const packageTrustedPublishers = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
@@ -1325,7 +1469,7 @@ const packagePublishUploadTickets = defineTable({
|
||||
expiresAt: v.number(),
|
||||
usedAt: v.optional(v.number()),
|
||||
storageId: v.optional(v.id("_storage")),
|
||||
});
|
||||
}).index("by_publish_token", ["publishTokenId"]);
|
||||
|
||||
const packageSearchDigest = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
@@ -1346,6 +1490,7 @@ const packageSearchDigest = defineTable({
|
||||
pluginCategoryTags: v.optional(v.array(v.string())),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
stats: v.optional(packageStatsValidator),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -1432,6 +1577,7 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
capabilityTag: v.string(),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
stats: v.optional(packageStatsValidator),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -1545,6 +1691,7 @@ const packagePluginCategorySearchDigest = defineTable({
|
||||
pluginCategory: v.string(),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
stats: v.optional(packageStatsValidator),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -1864,6 +2011,7 @@ const packageAppeals = defineTable({
|
||||
actionTaken: v.optional(v.union(v.literal("none"), v.literal("approve"))),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_release_status_createdAt", ["releaseId", "status", "createdAt"])
|
||||
.index("by_createdAt", ["createdAt"])
|
||||
.index("by_status_createdAt", ["status", "createdAt"])
|
||||
@@ -1977,9 +2125,21 @@ const publisherAbuseScoreRuns = defineTable({
|
||||
sumSquaredLogPressure: v.number(),
|
||||
meanLogPressure: v.optional(v.number()),
|
||||
stdDevLogPressure: v.optional(v.number()),
|
||||
temporalBenchmark: v.optional(
|
||||
v.object({
|
||||
sampleSize: v.number(),
|
||||
downloads30dAverage: v.number(),
|
||||
downloads30dMedian: v.number(),
|
||||
downloads30dP95: v.number(),
|
||||
downloads30dP99: v.number(),
|
||||
spikeMultiplier7dP95: v.number(),
|
||||
spikeMultiplier7dP99: v.number(),
|
||||
}),
|
||||
),
|
||||
errorMessage: v.optional(v.string()),
|
||||
})
|
||||
.index("by_status_and_updated_at", ["status", "updatedAt"])
|
||||
.index("by_model_version_and_started_at", ["modelVersion", "startedAt"])
|
||||
.index("by_started_at", ["startedAt"]);
|
||||
|
||||
const publisherAbuseScores = defineTable({
|
||||
@@ -2002,10 +2162,56 @@ const publisherAbuseScores = defineTable({
|
||||
starsPerSkill: v.number(),
|
||||
downloadsPerSkill: v.number(),
|
||||
reasonCodes: v.array(v.string()),
|
||||
temporalHighSkillCount: v.optional(v.number()),
|
||||
temporalSpikeSkillCount: v.optional(v.number()),
|
||||
temporalSustainedSkillCount: v.optional(v.number()),
|
||||
temporalMaxPressure: v.optional(v.number()),
|
||||
temporalBenchmark: v.optional(
|
||||
v.object({
|
||||
sampleSize: v.number(),
|
||||
downloads30dAverage: v.number(),
|
||||
downloads30dMedian: v.number(),
|
||||
downloads30dP95: v.number(),
|
||||
downloads30dP99: v.number(),
|
||||
spikeMultiplier7dP95: v.number(),
|
||||
spikeMultiplier7dP99: v.number(),
|
||||
}),
|
||||
),
|
||||
temporalEvidence: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
skillId: v.id("skills"),
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
spike: v.boolean(),
|
||||
sustained: v.boolean(),
|
||||
pressure: v.number(),
|
||||
recent7Downloads: v.number(),
|
||||
recent7Installs: v.number(),
|
||||
previous30Downloads: v.number(),
|
||||
baseline7Downloads: v.number(),
|
||||
spikeMultiplier: v.number(),
|
||||
recent30Downloads: v.number(),
|
||||
recent30Installs: v.number(),
|
||||
downloadInstallRatio30: v.number(),
|
||||
downloads30dCohortBand: v.optional(v.union(v.literal("p95"), v.literal("p99"))),
|
||||
spikeMultiplierCohortBand: v.optional(v.union(v.literal("p95"), v.literal("p99"))),
|
||||
downloads30dVsPeerP95: v.optional(v.number()),
|
||||
spikeMultiplierVsPeerP95: v.optional(v.number()),
|
||||
spikeWindowStartDay: v.optional(v.number()),
|
||||
spikeWindowEndDay: v.optional(v.number()),
|
||||
sustainedWindowStartDay: v.optional(v.number()),
|
||||
sustainedWindowEndDay: v.optional(v.number()),
|
||||
reasonCodes: v.array(v.string()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_run_and_rank", ["runId", "rank"])
|
||||
.index("by_run_and_label_and_rank", ["runId", "label", "rank"])
|
||||
.index("by_run_and_pressure", ["runId", "pressure"])
|
||||
.index("by_run_and_owner_key", ["runId", "ownerKey"])
|
||||
.index("by_owner_key_and_created_at", ["ownerKey", "createdAt"])
|
||||
.index("by_owner_key_and_model_version", ["ownerKey", "modelVersion"])
|
||||
.index("by_label_and_z_score", ["label", "zScore"]);
|
||||
@@ -2029,7 +2235,15 @@ const publisherAbuseReviewNominations = defineTable({
|
||||
})
|
||||
.index("by_owner_key_and_model_version", ["ownerKey", "modelVersion"])
|
||||
.index("by_status_and_last_scored_at", ["status", "lastScoredAt"])
|
||||
.index("by_status_and_updated_at", ["status", "updatedAt"])
|
||||
.index("by_status_and_reviewed_at", ["status", "reviewedAt"])
|
||||
.index("by_status_and_label_and_last_scored_at", ["status", "label", "lastScoredAt"])
|
||||
.index("by_status_and_model_version_and_label_and_last_scored_at", [
|
||||
"status",
|
||||
"modelVersion",
|
||||
"label",
|
||||
"lastScoredAt",
|
||||
])
|
||||
.index("by_label_and_status_and_last_scored_at", ["label", "status", "lastScoredAt"])
|
||||
.index("by_last_scored_at", ["lastScoredAt"]);
|
||||
|
||||
@@ -2140,6 +2354,26 @@ const downloadDedupes = defineTable({
|
||||
.index("by_skill_identity_hour", ["skillId", "identityHash", "hourStart"])
|
||||
.index("by_hour", ["hourStart"]);
|
||||
|
||||
const downloadMetricTargetKind = v.union(v.literal("skill"), v.literal("package"));
|
||||
const downloadMetricIdentityKind = v.union(v.literal("user"), v.literal("ip"));
|
||||
|
||||
const downloadMetricDedupes = defineTable({
|
||||
targetKind: downloadMetricTargetKind,
|
||||
targetId: v.string(),
|
||||
identityKind: downloadMetricIdentityKind,
|
||||
identityHash: v.string(),
|
||||
dayStart: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_target_identity_day", [
|
||||
"targetKind",
|
||||
"targetId",
|
||||
"identityKind",
|
||||
"identityHash",
|
||||
"dayStart",
|
||||
])
|
||||
.index("by_day", ["dayStart"]);
|
||||
|
||||
const reservedSlugs = defineTable({
|
||||
slug: v.string(),
|
||||
originalOwnerUserId: v.id("users"),
|
||||
@@ -2238,6 +2472,9 @@ export default defineSchema({
|
||||
users,
|
||||
publishers,
|
||||
publisherMembers,
|
||||
officialPublishers,
|
||||
githubSkillSources,
|
||||
githubSkillContents,
|
||||
skills,
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
@@ -2293,6 +2530,7 @@ export default defineSchema({
|
||||
rateLimits,
|
||||
rateLimitShards,
|
||||
downloadDedupes,
|
||||
downloadMetricDedupes,
|
||||
reservedSlugs,
|
||||
reservedHandles,
|
||||
githubBackupSyncState,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
enqueueBulkSkillRescanBatchForAdminInternal,
|
||||
failCodexScanJob,
|
||||
getBulkSkillRescanBatchStatusForAdminInternal,
|
||||
getSkillScanRequestForUserInternal,
|
||||
getStoredScanReportForUserInternal,
|
||||
pruneExpiredSkillScanRequestsInternal,
|
||||
requestPackageRescanForUserInternal,
|
||||
requestPackageRescan,
|
||||
@@ -102,6 +104,7 @@ type ScanJob = {
|
||||
targetKind: string;
|
||||
skillVersionId?: string;
|
||||
packageReleaseId?: string;
|
||||
skillScanRequestId?: string;
|
||||
source: string;
|
||||
priority: number;
|
||||
hasMaliciousSignal: boolean;
|
||||
@@ -199,6 +202,48 @@ const getBulkSkillRescanBatchStatusForAdminInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getSkillScanRequestForUserInternalHandler = (
|
||||
getSkillScanRequestForUserInternal as unknown as WrappedHandler<
|
||||
{ actorUserId: string; scanId: string },
|
||||
{
|
||||
ok: true;
|
||||
scanId: string;
|
||||
jobId?: string;
|
||||
status: string;
|
||||
queue: {
|
||||
queuedAhead: number;
|
||||
queuedAheadIsEstimate?: boolean;
|
||||
position: number | null;
|
||||
running: number;
|
||||
runningIsEstimate?: boolean;
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStoredScanReportForUserInternalHandler = (
|
||||
getStoredScanReportForUserInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
kind: "skill" | "plugin";
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
{
|
||||
ok: true;
|
||||
status: string;
|
||||
artifact: Record<string, unknown>;
|
||||
report: {
|
||||
clawscan: Record<string, unknown> | null;
|
||||
skillspector: Record<string, unknown> | null;
|
||||
staticAnalysis: Record<string, unknown> | null;
|
||||
virustotal: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const claimedJob = {
|
||||
_id: "securityScanJobs:1",
|
||||
_creationTime: 1,
|
||||
@@ -586,6 +631,154 @@ function makeClaimCtx(jobs: ScanJob[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkillScanStatusCtx(options: {
|
||||
actor: Record<string, unknown>;
|
||||
request: Record<string, unknown>;
|
||||
jobs: ScanJob[];
|
||||
}) {
|
||||
const docs = new Map<string, Record<string, unknown>>([
|
||||
[String(options.actor._id), options.actor],
|
||||
[String(options.request._id), options.request],
|
||||
...options.jobs.map((job) => [job._id, job] as const),
|
||||
]);
|
||||
const get = vi.fn(async (id: string) => docs.get(id) ?? null);
|
||||
const query = vi.fn((tableName: string) => {
|
||||
expect(tableName).toBe("securityScanJobs");
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
buildRange: (q: {
|
||||
eq: (field: string, value: unknown) => unknown;
|
||||
lte: (field: string, value: number) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
const eqFilters = new Map<string, unknown>();
|
||||
const lteFilters = new Map<string, number>();
|
||||
const indexBuilder = {
|
||||
eq(field: string, value: unknown) {
|
||||
eqFilters.set(field, value);
|
||||
return indexBuilder;
|
||||
},
|
||||
lte(field: string, value: number) {
|
||||
lteFilters.set(field, value);
|
||||
return indexBuilder;
|
||||
},
|
||||
};
|
||||
buildRange(indexBuilder);
|
||||
const select = () =>
|
||||
options.jobs
|
||||
.filter((job) => {
|
||||
for (const [field, value] of eqFilters) {
|
||||
if ((job as unknown as Record<string, unknown>)[field] !== value) return false;
|
||||
}
|
||||
for (const [field, value] of lteFilters) {
|
||||
const fieldValue = (job as unknown as Record<string, unknown>)[field];
|
||||
if (typeof fieldValue !== "number" || fieldValue > value) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (indexName.includes("next_run_at")) {
|
||||
if (a.nextRunAt !== b.nextRunAt) return a.nextRunAt - b.nextRunAt;
|
||||
if (a._creationTime !== b._creationTime) {
|
||||
return a._creationTime - b._creationTime;
|
||||
}
|
||||
return a._id.localeCompare(b._id);
|
||||
}
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
const collect = vi.fn(async () => select());
|
||||
const take = vi.fn(async (limit: number) => select().slice(0, limit));
|
||||
return {
|
||||
collect,
|
||||
take,
|
||||
order: vi.fn(() => ({ collect, take })),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
get,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeStoredScanReportCtx(options: {
|
||||
actor: Record<string, unknown>;
|
||||
docs: Record<string, Record<string, unknown>>;
|
||||
membership?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const docs = new Map<string, Record<string, unknown>>([
|
||||
[String(options.actor._id), options.actor],
|
||||
...Object.entries(options.docs),
|
||||
]);
|
||||
const get = vi.fn(async (id: string) => docs.get(id) ?? null);
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((_indexName: string, buildRange: (q: { eq: typeof eq }) => unknown) => {
|
||||
const equals = new Map<string, unknown>();
|
||||
function eq(field: string, value: unknown) {
|
||||
equals.set(field, value);
|
||||
return { eq };
|
||||
}
|
||||
buildRange({ eq });
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (table === "publisherMembers") return options.membership ?? null;
|
||||
if (table === "skills") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) => String(doc._id).startsWith("skills:") && doc.slug === equals.get("slug"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("skillVersions:") &&
|
||||
doc.skillId === equals.get("skillId") &&
|
||||
doc.version === equals.get("version"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "packages") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("packages:") &&
|
||||
doc.normalizedName === equals.get("normalizedName"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
if (table === "packageReleases") {
|
||||
return (
|
||||
Array.from(docs.values()).find(
|
||||
(doc) =>
|
||||
String(doc._id).startsWith("packageReleases:") &&
|
||||
doc.packageId === equals.get("packageId") &&
|
||||
doc.version === equals.get("version"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
db: {
|
||||
get,
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("securityScan", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -644,6 +837,269 @@ describe("securityScan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden skill versions to the owner", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
docs: {
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["network.exfiltration"],
|
||||
findings: [],
|
||||
summary: "Credential exfiltration pattern.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
status: "succeeded",
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
report: {
|
||||
clawscan: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
},
|
||||
staticAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Credential exfiltration pattern.",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden org skill versions to publisher-role uploaders", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
membership: {
|
||||
_id: "publisherMembers:member",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:member",
|
||||
role: "publisher",
|
||||
},
|
||||
docs: {
|
||||
"publishers:org": {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
},
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Attempts to exfiltrate credentials.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:member",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
artifact: {
|
||||
kind: "skill",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
version: "1.2.3",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("denies stored scan reports to non-owners", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:intruder", role: "user" },
|
||||
docs: {
|
||||
"skills:hidden": {
|
||||
_id: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"skillVersions:hidden": {
|
||||
_id: "skillVersions:hidden",
|
||||
skillId: "skills:hidden",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: 1,
|
||||
files: [],
|
||||
llmAnalysis: { status: "malicious", checkedAt: 1 },
|
||||
createdAt: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:intruder",
|
||||
kind: "skill",
|
||||
name: "hidden-skill",
|
||||
version: "1.2.3",
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden plugin releases to platform moderators", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:moderator", role: "moderator" },
|
||||
docs: {
|
||||
"packages:plugin": {
|
||||
_id: "packages:plugin",
|
||||
name: "@scope/demo",
|
||||
normalizedName: "@scope/demo",
|
||||
displayName: "Demo Plugin",
|
||||
ownerUserId: "users:owner",
|
||||
},
|
||||
"packageReleases:hidden": {
|
||||
_id: "packageReleases:hidden",
|
||||
packageId: "packages:plugin",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
integritySha256: "def456",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:moderator",
|
||||
kind: "plugin",
|
||||
name: "@scope/demo",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
status: "succeeded",
|
||||
artifact: {
|
||||
kind: "plugin",
|
||||
name: "@scope/demo",
|
||||
displayName: "Demo Plugin",
|
||||
version: "2.0.0",
|
||||
},
|
||||
report: {
|
||||
clawscan: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stored scan reports for hidden org plugin releases to publisher-role uploaders", async () => {
|
||||
const ctx = makeStoredScanReportCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
membership: {
|
||||
_id: "publisherMembers:member",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:member",
|
||||
role: "publisher",
|
||||
},
|
||||
docs: {
|
||||
"publishers:org": {
|
||||
_id: "publishers:org",
|
||||
kind: "org",
|
||||
handle: "org",
|
||||
},
|
||||
"packages:plugin": {
|
||||
_id: "packages:plugin",
|
||||
name: "@org/demo",
|
||||
normalizedName: "@org/demo",
|
||||
displayName: "Org Plugin",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
},
|
||||
"packageReleases:hidden": {
|
||||
_id: "packageReleases:hidden",
|
||||
packageId: "packages:plugin",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: 1_700_000_100_000,
|
||||
files: [],
|
||||
integritySha256: "def456",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
summary: "Runs unexpected shell commands.",
|
||||
checkedAt: 1_700_000_000_000,
|
||||
},
|
||||
createdAt: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await getStoredScanReportForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:member",
|
||||
kind: "plugin",
|
||||
name: "@org/demo",
|
||||
version: "2.0.0",
|
||||
});
|
||||
|
||||
expect(report).toMatchObject({
|
||||
ok: true,
|
||||
artifact: {
|
||||
kind: "plugin",
|
||||
name: "@org/demo",
|
||||
displayName: "Org Plugin",
|
||||
version: "2.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets skill owners request skill rescans through the API helper", async () => {
|
||||
const { ctx, inserts } = makeRescanCtx({
|
||||
actorId: "users:owner",
|
||||
@@ -1488,6 +1944,177 @@ describe("securityScan", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports queued scan position for manual scan requests", async () => {
|
||||
const targetJob = makeScanJob({
|
||||
_id: "securityScanJobs:target",
|
||||
targetKind: "skillScanRequest",
|
||||
skillScanRequestId: "skillScanRequests:target",
|
||||
source: "manual",
|
||||
createdAt: 300,
|
||||
nextRunAt: 300,
|
||||
});
|
||||
const ctx = makeSkillScanStatusCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
request: {
|
||||
_id: "skillScanRequests:target",
|
||||
actorUserId: "users:owner",
|
||||
sourceKind: "upload",
|
||||
update: false,
|
||||
writtenBack: false,
|
||||
status: "queued",
|
||||
securityScanJobId: targetJob._id,
|
||||
files: [],
|
||||
expiresAt: 1000,
|
||||
createdAt: 300,
|
||||
updatedAt: 300,
|
||||
},
|
||||
jobs: [
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:older",
|
||||
source: "manual",
|
||||
createdAt: 100,
|
||||
nextRunAt: 100,
|
||||
}),
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:running",
|
||||
status: "running",
|
||||
source: "manual",
|
||||
createdAt: 200,
|
||||
nextRunAt: 200,
|
||||
}),
|
||||
targetJob,
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:bulk",
|
||||
source: "bulk-rescan",
|
||||
createdAt: 1,
|
||||
nextRunAt: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const status = await getSkillScanRequestForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
scanId: "skillScanRequests:target",
|
||||
});
|
||||
|
||||
expect(status.queue).toEqual({
|
||||
queuedAhead: 1,
|
||||
queuedAheadIsEstimate: false,
|
||||
position: 2,
|
||||
running: 1,
|
||||
runningIsEstimate: false,
|
||||
note: "Scans are asynchronous and may take time to complete.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses claim-order tie-breaks for same-timestamp queued scan positions", async () => {
|
||||
const targetJob = makeScanJob({
|
||||
_id: "securityScanJobs:target",
|
||||
_creationTime: 2,
|
||||
targetKind: "skillScanRequest",
|
||||
skillScanRequestId: "skillScanRequests:target",
|
||||
source: "manual",
|
||||
createdAt: 300,
|
||||
nextRunAt: 300,
|
||||
});
|
||||
const ctx = makeSkillScanStatusCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
request: {
|
||||
_id: "skillScanRequests:target",
|
||||
actorUserId: "users:owner",
|
||||
sourceKind: "upload",
|
||||
update: false,
|
||||
writtenBack: false,
|
||||
status: "queued",
|
||||
securityScanJobId: targetJob._id,
|
||||
files: [],
|
||||
expiresAt: 1000,
|
||||
createdAt: 300,
|
||||
updatedAt: 300,
|
||||
},
|
||||
jobs: [
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:first",
|
||||
_creationTime: 1,
|
||||
source: "manual",
|
||||
createdAt: 300,
|
||||
nextRunAt: 300,
|
||||
}),
|
||||
targetJob,
|
||||
makeScanJob({
|
||||
_id: "securityScanJobs:last",
|
||||
_creationTime: 3,
|
||||
source: "manual",
|
||||
createdAt: 300,
|
||||
nextRunAt: 300,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const status = await getSkillScanRequestForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
scanId: "skillScanRequests:target",
|
||||
});
|
||||
|
||||
expect(status.queue).toMatchObject({
|
||||
queuedAhead: 1,
|
||||
queuedAheadIsEstimate: false,
|
||||
position: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds large queue position scans and marks the count as estimated", async () => {
|
||||
const targetJob = makeScanJob({
|
||||
_id: "securityScanJobs:target",
|
||||
targetKind: "skillScanRequest",
|
||||
skillScanRequestId: "skillScanRequests:target",
|
||||
source: "manual",
|
||||
createdAt: 1_000,
|
||||
nextRunAt: 1_000,
|
||||
});
|
||||
const ctx = makeSkillScanStatusCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
request: {
|
||||
_id: "skillScanRequests:target",
|
||||
actorUserId: "users:owner",
|
||||
sourceKind: "upload",
|
||||
update: false,
|
||||
writtenBack: false,
|
||||
status: "queued",
|
||||
securityScanJobId: targetJob._id,
|
||||
files: [],
|
||||
expiresAt: 1000,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
},
|
||||
jobs: [
|
||||
...Array.from({ length: 300 }, (_, index) =>
|
||||
makeScanJob({
|
||||
_id: `securityScanJobs:older-${index}`,
|
||||
source: "manual",
|
||||
createdAt: index,
|
||||
nextRunAt: index,
|
||||
}),
|
||||
),
|
||||
targetJob,
|
||||
],
|
||||
});
|
||||
|
||||
const status = await getSkillScanRequestForUserInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
scanId: "skillScanRequests:target",
|
||||
});
|
||||
|
||||
expect(status.queue).toEqual({
|
||||
queuedAhead: 250,
|
||||
queuedAheadIsEstimate: true,
|
||||
position: null,
|
||||
running: 0,
|
||||
runningIsEstimate: false,
|
||||
note: "Scans are asynchronous and may take time to complete.",
|
||||
});
|
||||
});
|
||||
|
||||
it("caps SkillSpector findings before storing completed scan results", async () => {
|
||||
vi.stubEnv("SECURITY_SCAN_WORKER_TOKEN", "worker-secret");
|
||||
const longSnippet = "sensitive SkillSpector artifact text ".repeat(200);
|
||||
|
||||
+275
-2
@@ -28,6 +28,9 @@ const MAX_STORED_SKILLSPECTOR_ISSUES = 25;
|
||||
const MAX_STORED_SKILLSPECTOR_TEXT_CHARS = 2_000;
|
||||
const MAX_STORED_SKILLSPECTOR_SHORT_TEXT_CHARS = 512;
|
||||
const DEFAULT_SKILL_SCAN_REQUEST_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_SKILL_SCAN_QUEUE_POSITION_READS = 250;
|
||||
const MAX_SKILL_SCAN_RUNNING_COUNT_READS = 512;
|
||||
const SKILL_SCAN_ASYNC_NOTE = "Scans are asynchronous and may take time to complete.";
|
||||
|
||||
const finalLlmAnalysisStatuses = new Set(["clean", "suspicious", "malicious"]);
|
||||
const artifactBackedLlmAnalysisStatuses = new Set(["clean", "benign", "suspicious", "malicious"]);
|
||||
@@ -85,6 +88,8 @@ type SkillSpectorAnalysisForStorage = {
|
||||
checkedAt: number;
|
||||
};
|
||||
|
||||
type StoredScanArtifactKind = "skill" | "plugin";
|
||||
|
||||
const jobSourceValidator = v.union(
|
||||
v.literal("publish"),
|
||||
v.literal("clawscan-note"),
|
||||
@@ -790,6 +795,54 @@ function skillScanReportFromRequest(request: Doc<"skillScanRequests">) {
|
||||
};
|
||||
}
|
||||
|
||||
function storedScanReportFromArtifact(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
clawscan: artifact.llmAnalysis ?? null,
|
||||
skillspector: artifact.skillSpectorAnalysis ?? null,
|
||||
staticAnalysis: artifact.staticScan ?? null,
|
||||
virustotal: artifact.vtAnalysis
|
||||
? {
|
||||
...artifact.vtAnalysis,
|
||||
...artifact.vtAnalysis.engineStats,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasStoredScanReport(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
artifact.llmAnalysis ||
|
||||
artifact.skillSpectorAnalysis ||
|
||||
artifact.staticScan ||
|
||||
artifact.vtAnalysis,
|
||||
);
|
||||
}
|
||||
|
||||
function completedAtFromStoredScanReport(
|
||||
artifact: Pick<
|
||||
Doc<"skillVersions"> | Doc<"packageReleases">,
|
||||
"llmAnalysis" | "skillSpectorAnalysis" | "staticScan" | "vtAnalysis"
|
||||
>,
|
||||
) {
|
||||
const checkedAtValues = [
|
||||
artifact.llmAnalysis?.checkedAt,
|
||||
artifact.skillSpectorAnalysis?.checkedAt,
|
||||
artifact.staticScan?.checkedAt,
|
||||
artifact.vtAnalysis?.checkedAt,
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
return checkedAtValues.length > 0 ? Math.max(...checkedAtValues) : undefined;
|
||||
}
|
||||
|
||||
function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) {
|
||||
return {
|
||||
...(request.slug ? { slug: request.slug } : {}),
|
||||
@@ -800,7 +853,84 @@ function skillScanArtifactFromRequest(request: Doc<"skillScanRequests">) {
|
||||
};
|
||||
}
|
||||
|
||||
function skillScanStatusResponse(
|
||||
async function countSecurityScanJobs(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
status: Doc<"securityScanJobs">["status"],
|
||||
source: SecurityScanJobSource,
|
||||
) {
|
||||
const jobs = await ctx.db
|
||||
.query("securityScanJobs")
|
||||
.withIndex("by_status_source_created_at", (q) => q.eq("status", status).eq("source", source))
|
||||
.take(MAX_SKILL_SCAN_RUNNING_COUNT_READS + 1);
|
||||
return {
|
||||
count: Math.min(jobs.length, MAX_SKILL_SCAN_RUNNING_COUNT_READS),
|
||||
isEstimate: jobs.length > MAX_SKILL_SCAN_RUNNING_COUNT_READS,
|
||||
};
|
||||
}
|
||||
|
||||
function compareQueuedScanClaimOrder(a: Doc<"securityScanJobs">, b: Doc<"securityScanJobs">) {
|
||||
if (a.nextRunAt !== b.nextRunAt) return a.nextRunAt - b.nextRunAt;
|
||||
if (a._creationTime !== b._creationTime) return a._creationTime - b._creationTime;
|
||||
return a._id.localeCompare(b._id);
|
||||
}
|
||||
|
||||
async function countQueuedJobsAhead(ctx: QueryCtx | MutationCtx, job: Doc<"securityScanJobs">) {
|
||||
const candidates = await ctx.db
|
||||
.query("securityScanJobs")
|
||||
.withIndex("by_status_source_next_run_at", (q) =>
|
||||
q.eq("status", "queued").eq("source", job.source).lte("nextRunAt", job.nextRunAt),
|
||||
)
|
||||
.order("asc")
|
||||
.take(MAX_SKILL_SCAN_QUEUE_POSITION_READS + 1);
|
||||
|
||||
const queuedAhead = candidates.reduce((count, candidate) => {
|
||||
if (candidate._id === job._id) return count;
|
||||
return compareQueuedScanClaimOrder(candidate, job) < 0 ? count + 1 : count;
|
||||
}, 0);
|
||||
const sawTarget = candidates.some((candidate) => candidate._id === job._id);
|
||||
const isEstimate =
|
||||
!sawTarget ||
|
||||
candidates.length > MAX_SKILL_SCAN_QUEUE_POSITION_READS ||
|
||||
queuedAhead > MAX_SKILL_SCAN_QUEUE_POSITION_READS;
|
||||
|
||||
return {
|
||||
queuedAhead: Math.min(queuedAhead, MAX_SKILL_SCAN_QUEUE_POSITION_READS),
|
||||
isEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
async function skillScanQueueState(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
job: Doc<"securityScanJobs"> | null,
|
||||
) {
|
||||
if (!job) {
|
||||
return {
|
||||
queuedAhead: 0,
|
||||
position: null,
|
||||
running: 0,
|
||||
note: SKILL_SCAN_ASYNC_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
const running = await countSecurityScanJobs(ctx, "running", job.source);
|
||||
const queuedAhead =
|
||||
job.status === "queued"
|
||||
? await countQueuedJobsAhead(ctx, job)
|
||||
: { queuedAhead: 0, isEstimate: false };
|
||||
|
||||
return {
|
||||
queuedAhead: queuedAhead.queuedAhead,
|
||||
queuedAheadIsEstimate: queuedAhead.isEstimate,
|
||||
position:
|
||||
job.status === "queued" && !queuedAhead.isEstimate ? queuedAhead.queuedAhead + 1 : null,
|
||||
running: running.count,
|
||||
runningIsEstimate: running.isEstimate,
|
||||
note: SKILL_SCAN_ASYNC_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
async function skillScanStatusResponse(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
request: Doc<"skillScanRequests">,
|
||||
job: Doc<"securityScanJobs"> | null,
|
||||
) {
|
||||
@@ -818,6 +948,7 @@ function skillScanStatusResponse(
|
||||
writtenBack: request.writtenBack,
|
||||
artifact: skillScanArtifactFromRequest(request),
|
||||
report: skillScanReportFromRequest(request),
|
||||
queue: await skillScanQueueState(ctx, job),
|
||||
lastError: request.lastError ?? job?.lastError,
|
||||
createdAt: request.createdAt,
|
||||
updatedAt: Math.max(request.updatedAt, job?.updatedAt ?? request.updatedAt),
|
||||
@@ -904,6 +1035,7 @@ export const createUploadedSkillScanRequestInternal = internalMutation({
|
||||
sourceKind: "upload" as const,
|
||||
update: false,
|
||||
alreadyQueued: false,
|
||||
queue: await skillScanQueueState(ctx, await ctx.db.get(jobId)),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1006,6 +1138,7 @@ export const createPublishedSkillScanRequestInternal = internalMutation({
|
||||
sourceKind: "published" as const,
|
||||
update,
|
||||
alreadyQueued: false,
|
||||
queue: await skillScanQueueState(ctx, await ctx.db.get(jobId)),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1024,10 +1157,150 @@ export const getSkillScanRequestForUserInternal = internalQuery({
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
const job = request.securityScanJobId ? await ctx.db.get(request.securityScanJobId) : null;
|
||||
return skillScanStatusResponse(request, job);
|
||||
return await skillScanStatusResponse(ctx, request, job);
|
||||
},
|
||||
});
|
||||
|
||||
export const getStoredScanReportForUserInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
name: v.string(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const name = args.name.trim();
|
||||
const versionLabel = args.version.trim();
|
||||
if (!name) throw new ConvexError("Name required");
|
||||
if (!versionLabel) throw new ConvexError("Version required");
|
||||
|
||||
return args.kind === "plugin"
|
||||
? await getStoredPackageScanReportForUser(ctx, {
|
||||
actor,
|
||||
kind: args.kind,
|
||||
name,
|
||||
version: versionLabel,
|
||||
})
|
||||
: await getStoredSkillScanReportForUser(ctx, {
|
||||
actor,
|
||||
kind: args.kind,
|
||||
name,
|
||||
version: versionLabel,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function getStoredSkillScanReportForUser(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
actor: Doc<"users">;
|
||||
kind: StoredScanArtifactKind;
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
) {
|
||||
const slug = args.name.toLowerCase();
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: args.actor,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
allowedPublisherRoles: ["publisher"],
|
||||
allowPlatformModerator: true,
|
||||
});
|
||||
|
||||
const version = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (!version) throw new ConvexError("Skill version not found");
|
||||
if (!hasStoredScanReport(version)) throw new ConvexError("Scan results not found");
|
||||
|
||||
const completedAt = completedAtFromStoredScanReport(version);
|
||||
return {
|
||||
ok: true as const,
|
||||
scanId: `skill:${skill.slug}:${version.version}`,
|
||||
status: "succeeded" as const,
|
||||
sourceKind: "published" as const,
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: args.kind,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
version: version.version,
|
||||
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
|
||||
fileCount: version.files.length,
|
||||
},
|
||||
report: storedScanReportFromArtifact(version),
|
||||
createdAt: version.createdAt,
|
||||
updatedAt: Math.max(version.createdAt, completedAt ?? version.createdAt),
|
||||
completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function getStoredPackageScanReportForUser(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
actor: Doc<"users">;
|
||||
kind: StoredScanArtifactKind;
|
||||
name: string;
|
||||
version: string;
|
||||
},
|
||||
) {
|
||||
const normalizedName = normalizePackageName(args.name);
|
||||
const pkg = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", normalizedName))
|
||||
.unique();
|
||||
if (!pkg || pkg.family === "skill") throw new ConvexError("Plugin not found");
|
||||
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: args.actor,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
allowedPublisherRoles: ["publisher"],
|
||||
allowPlatformModerator: true,
|
||||
});
|
||||
|
||||
const release = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (!release) throw new ConvexError("Plugin version not found");
|
||||
if (!hasStoredScanReport(release)) throw new ConvexError("Scan results not found");
|
||||
|
||||
const completedAt = completedAtFromStoredScanReport(release);
|
||||
return {
|
||||
ok: true as const,
|
||||
scanId: `plugin:${pkg.normalizedName}:${release.version}`,
|
||||
status: "succeeded" as const,
|
||||
sourceKind: "published" as const,
|
||||
update: false,
|
||||
writtenBack: true,
|
||||
artifact: {
|
||||
kind: args.kind,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
version: release.version,
|
||||
...(release.integritySha256 ? { sha256hash: release.integritySha256 } : {}),
|
||||
fileCount: release.files.length,
|
||||
},
|
||||
report: storedScanReportFromArtifact(release),
|
||||
createdAt: release.createdAt,
|
||||
updatedAt: Math.max(release.createdAt, completedAt ?? release.createdAt),
|
||||
completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const recordSkillScanRequestSucceededInternal = internalMutation({
|
||||
args: {
|
||||
scanId: v.id("skillScanRequests"),
|
||||
|
||||
@@ -152,7 +152,11 @@ type Captured = {
|
||||
allPatches: Array<{ id: string; value: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
function buildDb(
|
||||
skill: SkillDoc,
|
||||
captured: Captured,
|
||||
existingVersion?: Record<string, unknown> | null,
|
||||
) {
|
||||
// Trigger-driven code (syncSkillSearchDigestForSkill -> getOwnerPublisher)
|
||||
// will ask for publishers via `db.get(ownerPublisherId)`. Return null so
|
||||
// getOwnerPublisher falls back to resolving the publisher from the owner user.
|
||||
@@ -229,7 +233,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
if (name !== "by_skill_version") {
|
||||
throw new Error(`unexpected skillVersions index ${name}`);
|
||||
}
|
||||
return { unique: async () => null };
|
||||
return { unique: async () => existingVersion ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -377,7 +381,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
|
||||
return db;
|
||||
}
|
||||
|
||||
function buildCtx(skill: SkillDoc) {
|
||||
function buildCtx(skill: SkillDoc, existingVersion?: Record<string, unknown> | null) {
|
||||
const captured: Captured = {
|
||||
skillPatches: [],
|
||||
embeddingInserts: [],
|
||||
@@ -385,7 +389,7 @@ function buildCtx(skill: SkillDoc) {
|
||||
versionInserted: null,
|
||||
allPatches: [],
|
||||
};
|
||||
const db = buildDb(skill, captured);
|
||||
const db = buildDb(skill, captured, existingVersion);
|
||||
const ctx = {
|
||||
db,
|
||||
scheduler: { runAfter: vi.fn() },
|
||||
@@ -394,6 +398,20 @@ function buildCtx(skill: SkillDoc) {
|
||||
}
|
||||
|
||||
describe("skills.insertVersion latest-tag protection", () => {
|
||||
it("tells authors to increment the version when publishing a duplicate skill version", async () => {
|
||||
const skill = buildExistingSkill();
|
||||
const { ctx, captured } = buildCtx(skill, {
|
||||
_id: "skillVersions:existing",
|
||||
skillId: SKILL_ID,
|
||||
version: "1.0.1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never),
|
||||
).rejects.toThrow("Version 1.0.1 already exists. Increment the version number and try again.");
|
||||
expect(captured.versionInserted).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale clawScanNote values when inserting skill versions", async () => {
|
||||
const skill = buildExistingSkill();
|
||||
const { ctx, captured } = buildCtx(skill);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyBanToOwnedSkillsBatchInternal,
|
||||
applyPublisherDeletionToOwnedSkillsBatchInternal,
|
||||
restoreOwnedSkillsForUnbanBatchInternal,
|
||||
} from "./skills";
|
||||
|
||||
@@ -22,6 +23,13 @@ const applyBanHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const applyPublisherDeletionHandler = (
|
||||
applyPublisherDeletionToOwnedSkillsBatchInternal as unknown as WrappedHandler<
|
||||
{ ownerPublisherId: string; actorUserId: string; deletedAt: number; cursor?: string },
|
||||
{ hiddenCount: number; scheduled: boolean; stale?: boolean }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeCtx({
|
||||
user,
|
||||
skills = [],
|
||||
@@ -40,6 +48,13 @@ function makeCtx({
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
@@ -47,13 +62,24 @@ function makeCtx({
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
return {
|
||||
ctx: {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "users:owner" ? user : null)),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return user;
|
||||
if (id === "publishers:org") return { _id: id, kind: "org", deletedAt: 3_000 };
|
||||
return null;
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
@@ -70,6 +96,64 @@ function makeCtx({
|
||||
}
|
||||
|
||||
describe("skills ban/unban batches", () => {
|
||||
it("starts hard-delete cleanup for active skills owned by a deleted publisher", async () => {
|
||||
const { ctx, patch, scheduler } = makeCtx({
|
||||
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:org-skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
softDeletedAt: undefined,
|
||||
hiddenAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyPublisherDeletionHandler(ctx, {
|
||||
ownerPublisherId: "publishers:org",
|
||||
actorUserId: "users:owner",
|
||||
deletedAt: 3_000,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
hiddenCount: 1,
|
||||
scheduled: false,
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:org-skill",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: expect.any(Number),
|
||||
hiddenAt: expect.any(Number),
|
||||
moderationStatus: "removed",
|
||||
hiddenBy: "users:owner",
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
skillId: "skills:org-skill",
|
||||
actorUserId: "users:owner",
|
||||
phase: "fingerprints",
|
||||
source: "publisher.delete",
|
||||
ownerPublisherId: "publishers:org",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retimestamps earlier ban-hidden skills during a later ban", async () => {
|
||||
const { ctx, patch, scheduler } = makeCtx({
|
||||
user: { _id: "users:owner", deletedAt: 2_000 },
|
||||
|
||||
+535
-18
@@ -198,6 +198,7 @@ describe("skills anti-spam guards", () => {
|
||||
_id: "skills:1",
|
||||
slug: "taken-skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
@@ -278,6 +279,7 @@ describe("skills anti-spam guards", () => {
|
||||
_id: "skills:1",
|
||||
slug: "taken-skill",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
@@ -384,7 +386,7 @@ describe("skills anti-spam guards", () => {
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1491,11 +1493,44 @@ describe("skills anti-spam guards", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("schedules owner autoban when the latest version ClawScan verdict becomes malicious", async () => {
|
||||
const version = {
|
||||
it("quarantines a malicious latest skill version and restores the previous clean latest", async () => {
|
||||
const previousVersion = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now() - 10_000,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Clean version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["automation"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
llmAnalysis: { status: "clean", checkedAt: Date.now() },
|
||||
};
|
||||
const version = {
|
||||
_id: "skillVersions:2",
|
||||
skillId: "skills:1",
|
||||
version: "2.0.0",
|
||||
createdBy: "users:member",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Bad release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Bad Skill", description: "Bad version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
@@ -1509,13 +1544,37 @@ describe("skills anti-spam guards", () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "spam-skill",
|
||||
displayName: "Bad Skill",
|
||||
summary: "Bad version",
|
||||
icon: "lucide:Sparkles",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:2",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Bad release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: {
|
||||
latest: "skillVersions:2",
|
||||
beta: "skillVersions:2",
|
||||
stable: "skillVersions:1",
|
||||
},
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
@@ -1523,6 +1582,348 @@ describe("skills anti-spam guards", () => {
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skillVersions:1") return previousVersion;
|
||||
if (id === "skillVersions:2") return version;
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name === "by_owner") {
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected skills index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version, previousVersion],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return {
|
||||
collect: async () => [
|
||||
{
|
||||
_id: "skillEmbeddings:old",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
isApproved: true,
|
||||
isLatest: false,
|
||||
},
|
||||
{
|
||||
_id: "skillEmbeddings:new",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:2",
|
||||
isApproved: true,
|
||||
isLatest: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await updateVersionLlmAnalysisHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
versionId: "skillVersions:2",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Do not install.",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:2",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:2",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
displayName: "spam-skill",
|
||||
summary: "Clean version",
|
||||
icon: "lucide:Sparkles",
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {
|
||||
latest: "skillVersions:1",
|
||||
stable: "skillVersions:1",
|
||||
},
|
||||
latestVersionSummary: expect.objectContaining({
|
||||
version: "1.0.0",
|
||||
changelog: "Initial release",
|
||||
}),
|
||||
capabilityTags: ["automation"],
|
||||
moderationStatus: "active",
|
||||
moderationReason: "scanner.llm.clean",
|
||||
moderationVerdict: "clean",
|
||||
moderationFlags: undefined,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillSearchDigest",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:1",
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSkillId: "skills:1",
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "skill",
|
||||
artifactName: "spam-skill",
|
||||
version: "2.0.0",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious non-latest skill version without changing the clean latest", async () => {
|
||||
const latestVersion = {
|
||||
_id: "skillVersions:latest",
|
||||
skillId: "skills:1",
|
||||
version: "2.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Latest release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Clean Latest", description: "Clean latest version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["automation"],
|
||||
llmAnalysis: { status: "clean", checkedAt: Date.now() },
|
||||
};
|
||||
const backportVersion = {
|
||||
_id: "skillVersions:backport",
|
||||
skillId: "skills:1",
|
||||
version: "1.5.0",
|
||||
createdBy: "users:member",
|
||||
createdAt: Date.now() - 1_000,
|
||||
changelog: "Backport release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { name: "Backport", description: "Backport version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
sha256hash: "b".repeat(64),
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "spam-skill",
|
||||
displayName: "Clean Latest",
|
||||
summary: "Clean latest version",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:latest",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: latestVersion.createdAt,
|
||||
changelog: "Latest release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: {
|
||||
latest: "skillVersions:latest",
|
||||
beta: "skillVersions:backport",
|
||||
},
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 2 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skillVersions:latest") return latestVersion;
|
||||
if (id === "skillVersions:backport") return backportVersion;
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await updateVersionLlmAnalysisHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
versionId: "skillVersions:backport",
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
confidence: "high",
|
||||
summary: "ClawScan found malicious behavior.",
|
||||
guidance: "Do not install.",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:backport",
|
||||
expect.objectContaining({
|
||||
llmAnalysis: expect.objectContaining({ verdict: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:backport",
|
||||
expect.objectContaining({ softDeletedAt: expect.any(Number) }),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
tags: { latest: "skillVersions:latest" },
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({ latestVersionId: "skillVersions:backport" }),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:member",
|
||||
artifactKind: "skill",
|
||||
artifactName: "spam-skill",
|
||||
version: "1.5.0",
|
||||
sha256hash: "b".repeat(64),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quarantines a malicious first skill version without publishing a latest version", async () => {
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Bad first version" },
|
||||
metadata: {},
|
||||
clawdis: { tools: [] },
|
||||
},
|
||||
capabilityTags: ["network"],
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "v2.2.0",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
sha256hash: "h".repeat(64),
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "new-spam-skill",
|
||||
displayName: "New Spam Skill",
|
||||
summary: "Bad first version",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
clawdis: { tools: [] },
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: { latest: "skillVersions:1" },
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runAfter = vi.fn();
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
@@ -1548,10 +1949,40 @@ describe("skills anti-spam guards", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return {
|
||||
collect: async () => [
|
||||
{
|
||||
_id: "skillEmbeddings:1",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:1",
|
||||
isApproved: true,
|
||||
isLatest: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert: vi.fn(),
|
||||
insert,
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -1570,32 +2001,63 @@ describe("skills anti-spam guards", () => {
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"globalStats:1",
|
||||
expect.objectContaining({
|
||||
activeSkillsCount: 99,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillSearchDigest",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:1",
|
||||
latestVersionId: undefined,
|
||||
latestVersionSkillId: undefined,
|
||||
}),
|
||||
);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.autobanMalwareAuthorInternal,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:owner",
|
||||
slug: "spam-skill",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
artifactKind: "skill",
|
||||
artifactName: "new-spam-skill",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists ClawScan malware lock when malicious lands on an existing moderation hold", async () => {
|
||||
it("quarantines ClawScan malware while preserving an existing moderation hold", async () => {
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.now(),
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
parsed: {
|
||||
frontmatter: { description: "Held for review" },
|
||||
metadata: {},
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
@@ -1609,14 +2071,36 @@ describe("skills anti-spam guards", () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "quality-held-spam",
|
||||
displayName: "Quality Held Spam",
|
||||
summary: "Held for review",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: version.createdAt,
|
||||
changelog: "Initial release",
|
||||
changelogSource: "user",
|
||||
clawdis: undefined,
|
||||
apiKeyRequired: undefined,
|
||||
},
|
||||
tags: { latest: "skillVersions:1" },
|
||||
badges: {},
|
||||
softDeletedAt: undefined,
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1 },
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "user.moderation",
|
||||
moderationFlags: undefined,
|
||||
createdAt: Date.now() - 20_000,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
role: "user",
|
||||
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
||||
@@ -1635,6 +2119,26 @@ describe("skills anti-spam guards", () => {
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillVersions index ${name}`);
|
||||
return {
|
||||
collect: async () => [version],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillEmbeddings") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_skill") throw new Error(`unexpected skillEmbeddings index ${name}`);
|
||||
return { collect: async () => [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
@@ -1659,21 +2163,34 @@ describe("skills anti-spam guards", () => {
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("skillVersions:1", expect.any(Object));
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
"skillVersions:1",
|
||||
expect.objectContaining({
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReasonCodes: ["malicious.llm_malicious"],
|
||||
softDeletedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
patch.mock.calls.some(
|
||||
([id, value]) =>
|
||||
id === "skills:1" &&
|
||||
(value as Record<string, unknown>).moderationReason === "scanner.llm.malicious",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.users.autobanMalwareAuthorInternal,
|
||||
internal.users.recordMaliciousArtifactFindingInternal,
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:owner",
|
||||
slug: "quality-held-spam",
|
||||
artifactKind: "skill",
|
||||
artifactName: "quality-held-spam",
|
||||
version: "1.0.0",
|
||||
sha256hash: "h".repeat(64),
|
||||
trigger: "malicious.llm_malicious",
|
||||
}),
|
||||
|
||||
@@ -947,7 +947,7 @@ describe("skills.checkSlugAvailability", () => {
|
||||
reason: "taken",
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
});
|
||||
});
|
||||
@@ -985,7 +985,7 @@ describe("skills.checkSlugAvailability", () => {
|
||||
reason: "taken",
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
});
|
||||
});
|
||||
|
||||
+496
-54
@@ -135,7 +135,7 @@ const MAX_OWNER_SUMMARY_LENGTH = 500;
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
|
||||
type ReadmeResult = { path: string; text: string };
|
||||
type ReadmeResult = { path: string; text: string; sourceBaseUrl?: string };
|
||||
type FileTextResult = {
|
||||
path: string;
|
||||
text: string;
|
||||
@@ -487,7 +487,7 @@ async function patchStructuredModerationFromVersion(
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<
|
||||
Doc<"skillVersions">,
|
||||
"_id" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
|
||||
"_id" | "version" | "staticScan" | "vtAnalysis" | "llmAnalysis" | "sha256hash"
|
||||
>,
|
||||
) {
|
||||
const now = Date.now();
|
||||
@@ -507,11 +507,17 @@ async function patchStructuredModerationFromVersion(
|
||||
const shouldPersistClawScanMalwareBlock =
|
||||
patch.moderationVerdict === "malicious" && isClawScanMaliciousAnalysis(version.llmAnalysis);
|
||||
|
||||
if (shouldPersistClawScanMalwareBlock) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
await quarantineMaliciousLatestSkillVersion(ctx, skill, version, owner, now, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
// A ClawScan-malicious result is itself a security lock. Persist it even
|
||||
// when the skill was already hidden by a user or quality hold so a later
|
||||
// hold lift cannot restore a latest-version malware verdict.
|
||||
if (shouldPreserveExistingModerationLock(skill) && !shouldPersistClawScanMalwareBlock) {
|
||||
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
|
||||
if (shouldPreserveExistingModerationLock(skill)) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,13 +525,206 @@ async function patchStructuredModerationFromVersion(
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await scheduleClawScanAutobanForMalware(ctx, skill, version, patch);
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, version, patch);
|
||||
}
|
||||
|
||||
async function scheduleClawScanAutobanForMalware(
|
||||
function latestVersionSummaryFromSkillVersion(
|
||||
version: Pick<
|
||||
Doc<"skillVersions">,
|
||||
"version" | "createdAt" | "changelog" | "changelogSource" | "parsed" | "apiKeyRequired"
|
||||
>,
|
||||
): NonNullable<Doc<"skills">["latestVersionSummary"]> {
|
||||
return {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource,
|
||||
clawdis: version.parsed?.clawdis,
|
||||
apiKeyRequired: version.apiKeyRequired,
|
||||
};
|
||||
}
|
||||
|
||||
function skillSummaryFromSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
|
||||
) {
|
||||
return version?.parsed?.frontmatter
|
||||
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function skillDisplayNameFromSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "parsed"> | null | undefined,
|
||||
) {
|
||||
return version?.parsed?.frontmatter
|
||||
? getFrontmatterValue(version.parsed.frontmatter, "name")?.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function skillIconFromSkillVersion(version: Pick<Doc<"skillVersions">, "icon"> | null | undefined) {
|
||||
return version && "icon" in version ? version.icon : undefined;
|
||||
}
|
||||
|
||||
function isKnownMaliciousSkillVersion(
|
||||
version: Pick<Doc<"skillVersions">, "_id" | "staticScan" | "vtAnalysis" | "llmAnalysis">,
|
||||
) {
|
||||
const patch = buildStructuredModerationPatch({
|
||||
staticScan: version.staticScan,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
llmAnalysis: version.llmAnalysis,
|
||||
vtStatus: version.vtAnalysis?.status,
|
||||
llmStatus: version.llmAnalysis?.status,
|
||||
sourceVersionId: version._id,
|
||||
});
|
||||
return patch.moderationVerdict === "malicious";
|
||||
}
|
||||
|
||||
function compareSkillVersionsForRestore(
|
||||
left: Pick<Doc<"skillVersions">, "version" | "createdAt">,
|
||||
right: Pick<Doc<"skillVersions">, "version" | "createdAt">,
|
||||
) {
|
||||
const leftValid = semver.valid(left.version);
|
||||
const rightValid = semver.valid(right.version);
|
||||
if (leftValid && rightValid) return semver.rcompare(leftValid, rightValid);
|
||||
if (leftValid) return -1;
|
||||
if (rightValid) return 1;
|
||||
return right.createdAt - left.createdAt;
|
||||
}
|
||||
|
||||
async function findReplacementLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
quarantinedVersionId: Id<"skillVersions">,
|
||||
) {
|
||||
const versions = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
|
||||
.collect();
|
||||
return (
|
||||
versions
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate._id !== quarantinedVersionId &&
|
||||
!candidate.softDeletedAt &&
|
||||
!isKnownMaliciousSkillVersion(candidate),
|
||||
)
|
||||
.sort(compareSkillVersionsForRestore)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function clearSkillEmbeddingsLatestVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
now: number,
|
||||
) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId);
|
||||
for (const embedding of embeddings) {
|
||||
if (
|
||||
!embedding.isLatest &&
|
||||
embedding.visibility === embeddingVisibilityFor(false, embedding.isApproved)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest: false,
|
||||
visibility: embeddingVisibilityFor(false, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantineMaliciousLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash">,
|
||||
version: Pick<Doc<"skillVersions">, "_id">,
|
||||
owner: Doc<"users"> | null | undefined,
|
||||
now: number,
|
||||
maliciousPatch: SkillModerationPatch,
|
||||
) {
|
||||
await ctx.db.patch(version._id, { softDeletedAt: now });
|
||||
|
||||
const replacement = await findReplacementLatestSkillVersion(ctx, skill._id, version._id);
|
||||
const nextTags: Record<string, Id<"skillVersions">> = {};
|
||||
for (const [tag, versionId] of Object.entries(skill.tags ?? {})) {
|
||||
if (versionId === version._id || tag === "latest") continue;
|
||||
nextTags[tag] = versionId;
|
||||
}
|
||||
if (replacement) {
|
||||
nextTags.latest = replacement._id;
|
||||
}
|
||||
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
displayName: replacement
|
||||
? (skillDisplayNameFromSkillVersion(replacement) ?? skill.slug)
|
||||
: skill.displayName,
|
||||
summary: replacement ? skillSummaryFromSkillVersion(replacement) : skill.summary,
|
||||
icon: replacement ? (skillIconFromSkillVersion(replacement) ?? skill.icon) : skill.icon,
|
||||
latestVersionId: replacement?._id,
|
||||
latestVersionSummary: replacement
|
||||
? latestVersionSummaryFromSkillVersion(replacement)
|
||||
: undefined,
|
||||
tags: nextTags,
|
||||
capabilityTags: replacement?.capabilityTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (!shouldPreserveExistingModerationLock(skill)) {
|
||||
const basePatch = replacement
|
||||
? buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: replacement,
|
||||
now,
|
||||
})
|
||||
: maliciousPatch;
|
||||
Object.assign(
|
||||
patch,
|
||||
applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch,
|
||||
now,
|
||||
stripUpdatedAt: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (replacement) {
|
||||
await setSkillEmbeddingsLatestVersion(ctx, skill._id, replacement._id, now);
|
||||
} else {
|
||||
await clearSkillEmbeddingsLatestVersion(ctx, skill._id, now);
|
||||
}
|
||||
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
|
||||
}
|
||||
|
||||
async function quarantineMaliciousNonLatestSkillVersion(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
versionId: Id<"skillVersions">,
|
||||
now: number,
|
||||
) {
|
||||
await ctx.db.patch(versionId, { softDeletedAt: now });
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(skill.tags ?? {}).filter(([, taggedVersionId]) => taggedVersionId !== versionId),
|
||||
) as Record<string, Id<"skillVersions">>;
|
||||
if (Object.keys(nextTags).length === Object.keys(skill.tags ?? {}).length) return;
|
||||
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
tags: nextTags,
|
||||
updatedAt: now,
|
||||
};
|
||||
const nextSkill = { ...skill, ...patch } as Doc<"skills">;
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await syncSkillSearchDigestForSkillDoc(ctx, nextSkill);
|
||||
}
|
||||
|
||||
async function scheduleClawScanMaliciousArtifactFinding(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<"skills">,
|
||||
version: Pick<Doc<"skillVersions">, "llmAnalysis" | "sha256hash" | "version"> &
|
||||
Partial<Pick<Doc<"skillVersions">, "createdBy">>,
|
||||
patch: SkillModerationPatch,
|
||||
) {
|
||||
if (
|
||||
@@ -533,9 +732,11 @@ async function scheduleClawScanAutobanForMalware(
|
||||
skill.ownerUserId &&
|
||||
isClawScanMaliciousAnalysis(version.llmAnalysis)
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.autobanMalwareAuthorInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
|
||||
ownerUserId: version.createdBy ?? skill.ownerUserId,
|
||||
artifactKind: "skill",
|
||||
artifactName: skill.slug,
|
||||
version: version.version,
|
||||
...(version.sha256hash ? { sha256hash: version.sha256hash } : {}),
|
||||
trigger:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.llm_")) ??
|
||||
@@ -648,14 +849,16 @@ const NONSUSPICIOUS_SORT_INDEXES = {
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES = 12;
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS = 500;
|
||||
|
||||
// Convex document IDs are opaque strings (e.g. "r97c0xws..."), not "table:id" —
|
||||
// so just confirm the schema-typed id is actually present before ctx.db.get.
|
||||
function isSkillVersionId(
|
||||
value: Id<"skillVersions"> | null | undefined,
|
||||
): value is Id<"skillVersions"> {
|
||||
return typeof value === "string" && value.startsWith("skillVersions:");
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isUserId(value: Id<"users"> | null | undefined): value is Id<"users"> {
|
||||
return typeof value === "string" && value.startsWith("users:");
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
type OwnerTrustSignals = {
|
||||
@@ -834,7 +1037,7 @@ function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it."
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new."
|
||||
);
|
||||
}
|
||||
const base = "Slug is already taken. Choose a different slug.";
|
||||
@@ -1371,6 +1574,15 @@ const HARD_DELETE_PHASES = [
|
||||
] as const;
|
||||
|
||||
type HardDeletePhase = (typeof HARD_DELETE_PHASES)[number];
|
||||
type HardDeleteSource = "admin" | "account.delete" | "publisher.delete";
|
||||
type HardDeleteScope = {
|
||||
source?: HardDeleteSource;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
};
|
||||
|
||||
const hardDeleteSourceValidator = v.optional(
|
||||
v.union(v.literal("admin"), v.literal("account.delete"), v.literal("publisher.delete")),
|
||||
);
|
||||
|
||||
function isHardDeletePhase(value: string | undefined): value is HardDeletePhase {
|
||||
if (!value) return false;
|
||||
@@ -1382,11 +1594,14 @@ async function scheduleHardDelete(
|
||||
skillId: Id<"skills">,
|
||||
actorUserId: Id<"users">,
|
||||
phase: HardDeletePhase,
|
||||
scope: HardDeleteScope = {},
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId,
|
||||
actorUserId,
|
||||
phase,
|
||||
source: scope.source,
|
||||
ownerPublisherId: scope.ownerPublisherId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1395,6 +1610,7 @@ async function hardDeleteSkillStep(
|
||||
skill: Doc<"skills">,
|
||||
actorUserId: Id<"users">,
|
||||
phase: HardDeletePhase,
|
||||
scope: HardDeleteScope = {},
|
||||
) {
|
||||
const now = Date.now();
|
||||
const patch: Partial<Doc<"skills">> = {};
|
||||
@@ -1421,10 +1637,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(version._id);
|
||||
}
|
||||
if (versions.length === HARD_DELETE_VERSION_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "versions");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "versions", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints", scope);
|
||||
return;
|
||||
}
|
||||
case "fingerprints": {
|
||||
@@ -1436,10 +1652,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(fingerprint._id);
|
||||
}
|
||||
if (fingerprints.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "fingerprints", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs", scope);
|
||||
return;
|
||||
}
|
||||
case "skillCardJobs": {
|
||||
@@ -1451,10 +1667,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(job._id);
|
||||
}
|
||||
if (jobs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "skillCardJobs", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "embeddings");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "embeddings", scope);
|
||||
return;
|
||||
}
|
||||
case "embeddings": {
|
||||
@@ -1463,13 +1679,18 @@ async function hardDeleteSkillStep(
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE);
|
||||
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);
|
||||
}
|
||||
if (embeddings.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "embeddings");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "embeddings", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "comments");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "comments", scope);
|
||||
return;
|
||||
}
|
||||
case "comments": {
|
||||
@@ -1481,10 +1702,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(comment._id);
|
||||
}
|
||||
if (comments.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "comments");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "comments", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "commentReports");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "commentReports", scope);
|
||||
return;
|
||||
}
|
||||
case "commentReports": {
|
||||
@@ -1496,10 +1717,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(report._id);
|
||||
}
|
||||
if (commentReports.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "commentReports");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "commentReports", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "reports");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "reports", scope);
|
||||
return;
|
||||
}
|
||||
case "reports": {
|
||||
@@ -1511,10 +1732,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(report._id);
|
||||
}
|
||||
if (reports.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "reports");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "reports", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "stars");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "stars", scope);
|
||||
return;
|
||||
}
|
||||
case "stars": {
|
||||
@@ -1526,10 +1747,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(star._id);
|
||||
}
|
||||
if (stars.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "stars");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "stars", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "badges");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "badges", scope);
|
||||
return;
|
||||
}
|
||||
case "badges": {
|
||||
@@ -1541,10 +1762,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(badge._id);
|
||||
}
|
||||
if (badges.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "badges");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "badges", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "dailyStats");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "dailyStats", scope);
|
||||
return;
|
||||
}
|
||||
case "dailyStats": {
|
||||
@@ -1556,10 +1777,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(stat._id);
|
||||
}
|
||||
if (dailyStats.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "dailyStats");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "dailyStats", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "statEvents");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "statEvents", scope);
|
||||
return;
|
||||
}
|
||||
case "statEvents": {
|
||||
@@ -1571,10 +1792,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(statEvent._id);
|
||||
}
|
||||
if (statEvents.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "statEvents");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "statEvents", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "installs");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "installs", scope);
|
||||
return;
|
||||
}
|
||||
case "installs": {
|
||||
@@ -1586,10 +1807,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(install._id);
|
||||
}
|
||||
if (installs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "installs");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "installs", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "rootInstalls");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "rootInstalls", scope);
|
||||
return;
|
||||
}
|
||||
case "rootInstalls": {
|
||||
@@ -1601,10 +1822,10 @@ async function hardDeleteSkillStep(
|
||||
await ctx.db.delete(rootInstall._id);
|
||||
}
|
||||
if (rootInstalls.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "rootInstalls");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "rootInstalls", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "leaderboards");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "leaderboards", scope);
|
||||
return;
|
||||
}
|
||||
case "leaderboards": {
|
||||
@@ -1618,10 +1839,10 @@ async function hardDeleteSkillStep(
|
||||
}
|
||||
}
|
||||
if (leaderboards.length === HARD_DELETE_LEADERBOARD_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "leaderboards");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "leaderboards", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "canonical");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "canonical", scope);
|
||||
return;
|
||||
}
|
||||
case "canonical": {
|
||||
@@ -1636,10 +1857,10 @@ async function hardDeleteSkillStep(
|
||||
});
|
||||
}
|
||||
if (canonicalRefs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "canonical");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "canonical", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "forks");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "forks", scope);
|
||||
return;
|
||||
}
|
||||
case "forks": {
|
||||
@@ -1654,10 +1875,10 @@ async function hardDeleteSkillStep(
|
||||
});
|
||||
}
|
||||
if (forkRefs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "forks");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "forks", scope);
|
||||
return;
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "finalize");
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, "finalize", scope);
|
||||
return;
|
||||
}
|
||||
case "finalize": {
|
||||
@@ -2305,6 +2526,8 @@ export const getBySlug = query({
|
||||
|
||||
const forkOf = await loadPublicSkillReference(ctx, skill.forkOf?.skillId);
|
||||
const canonical = await loadPublicSkillReference(ctx, skill.canonicalSkillId);
|
||||
const githubSource = skill.githubSourceId ? await ctx.db.get(skill.githubSourceId) : null;
|
||||
const githubSourceRepo = githubSource?.repo;
|
||||
|
||||
const publicSkill = toPublicSkill({ ...skill, badges });
|
||||
|
||||
@@ -2331,9 +2554,16 @@ export const getBySlug = query({
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
installKind: skill.installKind,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentCommit: skill.githubCurrentCommit,
|
||||
githubCurrentStatus: skill.githubCurrentStatus,
|
||||
githubScanStatus: skill.githubScanStatus,
|
||||
githubHasSkillCard: skill.githubHasSkillCard,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
@@ -2344,6 +2574,7 @@ export const getBySlug = query({
|
||||
...skillData,
|
||||
canonicalSkillId: canonical ? skillData.canonicalSkillId : undefined,
|
||||
forkOf: forkOf ? skillData.forkOf : undefined,
|
||||
...(githubSourceRepo ? { githubSourceRepo } : {}),
|
||||
};
|
||||
|
||||
// Moderation info - visible to owners for all states, or anyone for flagged skills (transparency)
|
||||
@@ -2593,7 +2824,7 @@ export const checkSlugAvailability = query({
|
||||
reason: "taken" as const,
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
@@ -5410,6 +5641,7 @@ type PublicSkillCatalogItem = {
|
||||
capabilityTags: string[];
|
||||
executesCode: false;
|
||||
verificationTier: null;
|
||||
stats: { downloads: number; installs: number; stars: number; versions: number };
|
||||
};
|
||||
|
||||
type SkillCatalogCursorState = {
|
||||
@@ -5503,6 +5735,12 @@ async function toPublicSkillCatalogItem(
|
||||
capabilityTags: digest.capabilityTags ?? [],
|
||||
executesCode: false,
|
||||
verificationTier: null,
|
||||
stats: {
|
||||
downloads: readDigestRankStat(digest, "downloads"),
|
||||
installs: readDigestRankStat(digest, "installsAllTime"),
|
||||
stars: readDigestRankStat(digest, "stars"),
|
||||
versions: digest.stats.versions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5596,6 +5834,7 @@ export const listPackageCatalogPage = query({
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
sort: v.optional(v.union(v.literal("updated"), v.literal("downloads"))),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -5633,9 +5872,11 @@ export const listPackageCatalogPage = query({
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const pageCursor = cursor;
|
||||
const indexName =
|
||||
args.sort === "downloads" ? "by_active_stats_downloads" : "by_active_updated";
|
||||
const page = await paginator(ctx.db, schema)
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.withIndex(indexName, (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.paginate({ cursor: pageCursor, numItems: effectivePageSize });
|
||||
|
||||
@@ -7415,6 +7656,87 @@ export const applyUserModerationToOwnedSkillsBatchInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const applyPublisherDeletionToOwnedSkillsBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
actorUserId: v.id("users"),
|
||||
deletedAt: v.number(),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await ctx.db.get(args.ownerPublisherId);
|
||||
if (publisher && publisher.deletedAt !== args.deletedAt) {
|
||||
return { ok: true as const, hiddenCount: 0, scheduled: false, stale: true as const };
|
||||
}
|
||||
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId))
|
||||
.order("desc")
|
||||
.paginate({
|
||||
cursor: args.cursor ?? null,
|
||||
numItems: BAN_USER_SKILLS_BATCH_SIZE,
|
||||
});
|
||||
|
||||
let hiddenCount = 0;
|
||||
for (const skill of page) {
|
||||
await hardDeleteSkillStep(ctx, skill, args.actorUserId, "versions", {
|
||||
source: "publisher.delete",
|
||||
ownerPublisherId: args.ownerPublisherId,
|
||||
});
|
||||
hiddenCount += 1;
|
||||
}
|
||||
|
||||
scheduleNextBatchIfNeeded(
|
||||
ctx.scheduler,
|
||||
internal.skills.applyPublisherDeletionToOwnedSkillsBatchInternal,
|
||||
args,
|
||||
isDone,
|
||||
continueCursor,
|
||||
);
|
||||
|
||||
return { ok: true as const, hiddenCount, scheduled: !isDone };
|
||||
},
|
||||
});
|
||||
|
||||
export const applyAccountDeletionToOwnedSkillsBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
deletedAt: v.number(),
|
||||
hiddenBy: v.optional(v.id("users")),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId))
|
||||
.order("desc")
|
||||
.paginate({
|
||||
cursor: args.cursor ?? null,
|
||||
numItems: BAN_USER_SKILLS_BATCH_SIZE,
|
||||
});
|
||||
|
||||
let hiddenCount = 0;
|
||||
for (const skill of page) {
|
||||
if (skill.ownerPublisherId) continue;
|
||||
await hardDeleteSkillStep(ctx, skill, args.hiddenBy ?? args.ownerUserId, "versions", {
|
||||
source: "account.delete",
|
||||
});
|
||||
hiddenCount += 1;
|
||||
}
|
||||
|
||||
scheduleNextBatchIfNeeded(
|
||||
ctx.scheduler,
|
||||
internal.skills.applyAccountDeletionToOwnedSkillsBatchInternal,
|
||||
args,
|
||||
isDone,
|
||||
continueCursor,
|
||||
);
|
||||
|
||||
return { ok: true as const, hiddenCount, scheduled: !isDone };
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
@@ -7921,7 +8243,30 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
|
||||
if (args.moderationMode === "preserve") return;
|
||||
|
||||
const skill = await ctx.db.get(version.skillId);
|
||||
if (!skill || skill.latestVersionId !== version._id) return;
|
||||
if (!skill) return;
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: nextVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch,
|
||||
now,
|
||||
stripUpdatedAt: true,
|
||||
});
|
||||
if (
|
||||
patch.moderationVerdict === "malicious" &&
|
||||
isClawScanMaliciousAnalysis(args.llmAnalysis)
|
||||
) {
|
||||
await scheduleClawScanMaliciousArtifactFinding(ctx, skill, nextVersion, patch);
|
||||
await quarantineMaliciousNonLatestSkillVersion(ctx, skill, version._id, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await patchStructuredModerationFromVersion(ctx, skill, nextVersion);
|
||||
},
|
||||
});
|
||||
@@ -8370,6 +8715,82 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
|
||||
return Boolean(toPublicSkill(skill));
|
||||
}
|
||||
|
||||
async function canReadGitHubSkillContent(ctx: QueryCtx, skill: Doc<"skills">) {
|
||||
const authUserId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (authUserId) {
|
||||
if (isDirectSkillOwner(skill, authUserId) && !skill.softDeletedAt) return true;
|
||||
if (skill.ownerPublisherId && !skill.softDeletedAt) {
|
||||
const canAccessOwnerScope = await canAccessPublisherOwnerScope(ctx, {
|
||||
publisher: await ctx.db.get(skill.ownerPublisherId),
|
||||
userId: authUserId,
|
||||
legacyOwnerUserId: skill.ownerUserId,
|
||||
});
|
||||
if (canAccessOwnerScope) return true;
|
||||
}
|
||||
const actor = await ctx.db.get(authUserId);
|
||||
if (actor?.role === "admin" || actor?.role === "moderator") return true;
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) return false;
|
||||
return Boolean(toPublicSkill(skill));
|
||||
}
|
||||
|
||||
export const getGitHubSkillContent = query({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
kind: v.union(v.literal("readme"), v.literal("skill-card")),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ReadmeResult | null> => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.installKind !== "github") return null;
|
||||
if (skill.githubCurrentStatus !== "present") return null;
|
||||
if (!(await canReadGitHubSkillContent(ctx, skill))) return null;
|
||||
|
||||
const content = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
|
||||
.unique();
|
||||
if (!content) return null;
|
||||
if (content.githubContentHash !== skill.githubCurrentContentHash) return null;
|
||||
|
||||
const source = await ctx.db.get(content.githubSourceId);
|
||||
const resultSource = source
|
||||
? buildGitHubMarkdownSourceBaseUrl(source.repo, content.githubCommit, content.githubPath)
|
||||
: undefined;
|
||||
|
||||
if (args.kind === "skill-card") {
|
||||
if (!content.skillCardMarkdown || !content.skillCardMarkdownPath) return null;
|
||||
return {
|
||||
path: content.skillCardMarkdownPath,
|
||||
text: content.skillCardMarkdown,
|
||||
...(resultSource ? { sourceBaseUrl: resultSource } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: content.skillMarkdownPath,
|
||||
text: content.skillMarkdown,
|
||||
...(resultSource ? { sourceBaseUrl: resultSource } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function buildGitHubMarkdownSourceBaseUrl(repo: string, commit: string, githubPath: string) {
|
||||
if (!repo || !commit) return undefined;
|
||||
const encodedRepo = repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
const normalizedPath = githubPath.replace(/^\/+|\/+$/g, "");
|
||||
const encodedPath = normalizedPath
|
||||
? `/${normalizedPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")}`
|
||||
: "";
|
||||
return `https://github.com/${encodedRepo}/blob/${encodeURIComponent(commit)}${encodedPath}`;
|
||||
}
|
||||
|
||||
export const getReadme: ReturnType<typeof action> = action({
|
||||
args: { versionId: v.id("skillVersions") },
|
||||
handler: async (ctx, args): Promise<ReadmeResult> => {
|
||||
@@ -9948,15 +10369,33 @@ export const hardDeleteInternal = internalMutation({
|
||||
skillId: v.id("skills"),
|
||||
actorUserId: v.id("users"),
|
||||
phase: v.optional(v.string()),
|
||||
source: hardDeleteSourceValidator,
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
||||
assertAdmin(actor);
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return;
|
||||
const source = args.source ?? "admin";
|
||||
if (source === "admin") {
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
||||
assertAdmin(actor);
|
||||
} else if (source === "account.delete") {
|
||||
if (!actor) throw new Error("User not found");
|
||||
if (skill.ownerUserId !== args.actorUserId || skill.ownerPublisherId) {
|
||||
throw new Error("Skill is outside account deletion scope");
|
||||
}
|
||||
} else {
|
||||
if (!actor) throw new Error("User not found");
|
||||
if (!args.ownerPublisherId || skill.ownerPublisherId !== args.ownerPublisherId) {
|
||||
throw new Error("Skill is outside publisher deletion scope");
|
||||
}
|
||||
}
|
||||
const phase = isHardDeletePhase(args.phase) ? args.phase : "versions";
|
||||
await hardDeleteSkillStep(ctx, skill, actor._id, phase);
|
||||
await hardDeleteSkillStep(ctx, skill, args.actorUserId, phase, {
|
||||
source,
|
||||
ownerPublisherId: args.ownerPublisherId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10468,13 +10907,16 @@ export const insertVersion = internalMutation({
|
||||
}
|
||||
|
||||
if (!skill) throw new Error("Skill creation failed");
|
||||
const versionIcon = args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
|
||||
|
||||
const existingVersion = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_skill_version", (q) => q.eq("skillId", skill._id).eq("version", args.version))
|
||||
.unique();
|
||||
if (existingVersion) {
|
||||
throw new ConvexError("Version already exists");
|
||||
throw new ConvexError(
|
||||
`Version ${args.version} already exists. Increment the version number and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
@@ -10484,6 +10926,7 @@ export const insertVersion = internalMutation({
|
||||
sourceProvenance: args.sourceProvenance,
|
||||
changelog: args.changelog,
|
||||
changelogSource: args.changelogSource,
|
||||
icon: versionIcon,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
capabilityTags: args.capabilityTags,
|
||||
@@ -10542,8 +10985,7 @@ export const insertVersion = internalMutation({
|
||||
// displayName / summary so backport publishes can't surprise the card.
|
||||
// Only update when the publisher explicitly picked one this time —
|
||||
// omitting `args.icon` keeps the previously stored value.
|
||||
const nextIcon =
|
||||
isNewLatest && args.icon !== undefined ? normalizeSkillIconValue(args.icon) : skill.icon;
|
||||
const nextIcon = isNewLatest ? versionIcon : skill.icon;
|
||||
const derivedFlags = deriveModerationFlags({
|
||||
skill: {
|
||||
slug: skill.slug,
|
||||
|
||||
+1150
-5
File diff suppressed because it is too large
Load Diff
+703
-21
@@ -1,4 +1,4 @@
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPersonalPublisherForUser,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
@@ -44,10 +45,135 @@ const AUTOBAN_AUDIT_MATCH_WINDOW_MS = 5_000;
|
||||
const BAN_AUDIT_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
const BAN_APPEAL_AUTH_ACCOUNT_MATCH_LIMIT = 20;
|
||||
const AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE = 100;
|
||||
const MALICIOUS_ARTIFACT_FINDING_ACTION = "user.malicious_artifact.finding";
|
||||
const MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD = 2;
|
||||
const MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD = 3;
|
||||
const MALICIOUS_ARTIFACT_AUDIT_LOOKBACK = 100;
|
||||
const DEV_PERSONA_BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
|
||||
const ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT = 25;
|
||||
const ACCOUNT_RECOVERY_PURGE_LIMIT_MAX = 100;
|
||||
const accountRecoveryPurgeModeValidator = v.optional(
|
||||
v.union(v.literal("deactivated"), v.literal("legacyDeleted")),
|
||||
);
|
||||
const autobanPackageScanScopeValidator = v.optional(
|
||||
v.union(v.literal("ownerUserId"), v.literal("personalPublisher")),
|
||||
);
|
||||
type AutobanPackageScanScope = "ownerUserId" | "personalPublisher";
|
||||
type DeletedAccountCleanupResult = {
|
||||
authAccounts: number;
|
||||
authVerificationCodes: number;
|
||||
authSessions: number;
|
||||
authRefreshTokens: number;
|
||||
apiTokens: number;
|
||||
personalPublisherDeleted: boolean;
|
||||
};
|
||||
type AccountRecoveryPurgeEligibilityReason =
|
||||
| "self_delete_audit"
|
||||
| "auth_locked_purged_user"
|
||||
| "auth_locked_legacy_deleted_user";
|
||||
type AccountRecoveryPurgeEligibility =
|
||||
| {
|
||||
eligible: true;
|
||||
reason: AccountRecoveryPurgeEligibilityReason;
|
||||
selfDeleteAuditLog: Doc<"auditLogs"> | null;
|
||||
authAccountCount: number | null;
|
||||
}
|
||||
| {
|
||||
eligible: false;
|
||||
selfDeleteAuditLog: null;
|
||||
};
|
||||
type AccountRecoveryPurgeCandidate = {
|
||||
userId: Id<"users">;
|
||||
eligibilityReason: AccountRecoveryPurgeEligibilityReason;
|
||||
handle: string | null;
|
||||
displayName: string | null;
|
||||
emailPresent: boolean;
|
||||
personalPublisherId: Id<"publishers"> | null;
|
||||
authAccountCount: number | null;
|
||||
deletedAt: number | null;
|
||||
deactivatedAt: number | null;
|
||||
purgedAt: number | null;
|
||||
selfDeleteAuditLogId: Id<"auditLogs"> | null;
|
||||
selfDeleteAuditCreatedAt: number | null;
|
||||
};
|
||||
|
||||
type BanEmailTarget = Pick<Doc<"users">, "_id" | "email" | "handle">;
|
||||
type MaliciousArtifactKind = "skill" | "plugin";
|
||||
type MaliciousArtifactFinding = {
|
||||
artifactKind: MaliciousArtifactKind;
|
||||
artifactName: string;
|
||||
};
|
||||
|
||||
async function scheduleBanNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
bannedAt: number;
|
||||
source: "manual" | "autoban";
|
||||
reason?: string;
|
||||
trigger?: string;
|
||||
artifact?: { kind: "skill" | "plugin"; name: string };
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendBanNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
bannedAt: args.bannedAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
source: args.source,
|
||||
reason: args.reason,
|
||||
trigger: args.trigger,
|
||||
artifact: args.artifact,
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleRestoredAccountNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
restoredAt: number;
|
||||
restoredListings?: Array<{ kind: "skill" | "plugin"; name: string }>;
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendRestoredAccountNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
restoredAt: args.restoredAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
restoredListings: args.restoredListings,
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleMaliciousArtifactNotificationEmail(
|
||||
ctx: Pick<MutationCtx, "scheduler">,
|
||||
args: {
|
||||
target: BanEmailTarget;
|
||||
findingAt: number;
|
||||
artifact: { kind: MaliciousArtifactKind; name: string };
|
||||
version?: string;
|
||||
trigger?: string;
|
||||
},
|
||||
) {
|
||||
const to = args.target.email?.trim();
|
||||
if (!to) return;
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emailsNode.sendMaliciousArtifactNotificationInternal, {
|
||||
userId: args.target._id,
|
||||
findingAt: args.findingAt,
|
||||
to,
|
||||
handle: args.target.handle,
|
||||
artifact: args.artifact,
|
||||
version: args.version,
|
||||
trigger: args.trigger,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAutobanPersonalPublisherId(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
@@ -78,6 +204,125 @@ async function isOwnedPersonalAutobanPackage(
|
||||
const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId);
|
||||
return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id;
|
||||
}
|
||||
|
||||
async function purgeAuthStateForUser(ctx: MutationCtx, userId: Id<"users">) {
|
||||
const accounts = await ctx.db
|
||||
.query("authAccounts")
|
||||
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
let authVerificationCodes = 0;
|
||||
for (const account of accounts) {
|
||||
const codes = await ctx.db
|
||||
.query("authVerificationCodes")
|
||||
.withIndex("accountId", (q) => q.eq("accountId", account._id))
|
||||
.collect();
|
||||
authVerificationCodes += codes.length;
|
||||
for (const code of codes) await ctx.db.delete(code._id);
|
||||
await ctx.db.delete(account._id);
|
||||
}
|
||||
|
||||
const sessions = await ctx.db
|
||||
.query("authSessions")
|
||||
.withIndex("userId", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
let authRefreshTokens = 0;
|
||||
for (const session of sessions) {
|
||||
const refreshTokens = await ctx.db
|
||||
.query("authRefreshTokens")
|
||||
.withIndex("sessionId", (q) => q.eq("sessionId", session._id))
|
||||
.collect();
|
||||
authRefreshTokens += refreshTokens.length;
|
||||
for (const refreshToken of refreshTokens) await ctx.db.delete(refreshToken._id);
|
||||
await ctx.db.delete(session._id);
|
||||
}
|
||||
|
||||
return {
|
||||
authAccounts: accounts.length,
|
||||
authVerificationCodes,
|
||||
authSessions: sessions.length,
|
||||
authRefreshTokens,
|
||||
};
|
||||
}
|
||||
|
||||
async function hardDeleteSelfDeletedAccountState(
|
||||
ctx: MutationCtx,
|
||||
user: Doc<"users">,
|
||||
deletedAt: number,
|
||||
): Promise<DeletedAccountCleanupResult> {
|
||||
const tokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
for (const token of tokens) await ctx.db.delete(token._id);
|
||||
|
||||
const personalPublisher = user.personalPublisherId
|
||||
? await ctx.db.get(user.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, user._id);
|
||||
let personalPublisherDeleted = false;
|
||||
if (personalPublisher) {
|
||||
const publisherDeletedAt = personalPublisher.deletedAt ?? deletedAt;
|
||||
if (!personalPublisher.deletedAt || !personalPublisher.deactivatedAt) {
|
||||
await ctx.db.patch(personalPublisher._id, {
|
||||
deletedAt: publisherDeletedAt,
|
||||
deactivatedAt: publisherDeletedAt,
|
||||
updatedAt: deletedAt,
|
||||
});
|
||||
}
|
||||
await ctx.runMutation(internal.skills.applyPublisherDeletionToOwnedSkillsBatchInternal, {
|
||||
ownerPublisherId: personalPublisher._id,
|
||||
actorUserId: user._id,
|
||||
deletedAt: publisherDeletedAt,
|
||||
cursor: undefined,
|
||||
});
|
||||
await ctx.runMutation(internal.packages.applyPublisherDeletionToOwnedPackagesBatchInternal, {
|
||||
ownerPublisherId: personalPublisher._id,
|
||||
actorUserId: user._id,
|
||||
deletedAt: publisherDeletedAt,
|
||||
cursor: undefined,
|
||||
});
|
||||
await ctx.runMutation(internal.publishers.hardDeletePublisherRowsInternal, {
|
||||
publisherId: personalPublisher._id,
|
||||
});
|
||||
personalPublisherDeleted = true;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, {
|
||||
ownerUserId: user._id,
|
||||
deletedAt,
|
||||
cursor: undefined,
|
||||
});
|
||||
await ctx.runMutation(internal.skills.applyAccountDeletionToOwnedSkillsBatchInternal, {
|
||||
ownerUserId: user._id,
|
||||
hiddenBy: user._id,
|
||||
deletedAt,
|
||||
cursor: undefined,
|
||||
});
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: user._id });
|
||||
const authState = await purgeAuthStateForUser(ctx, user._id);
|
||||
return { ...authState, apiTokens: tokens.length, personalPublisherDeleted };
|
||||
}
|
||||
|
||||
async function scrubDeletedUserTombstone(ctx: MutationCtx, user: Doc<"users">, deletedAt: number) {
|
||||
await ctx.db.patch(user._id, {
|
||||
deactivatedAt: user.deactivatedAt ?? deletedAt,
|
||||
purgedAt: user.purgedAt ?? deletedAt,
|
||||
deletedAt: undefined,
|
||||
banReason: undefined,
|
||||
role: "user",
|
||||
handle: undefined,
|
||||
displayName: undefined,
|
||||
name: undefined,
|
||||
image: undefined,
|
||||
email: undefined,
|
||||
emailVerificationTime: undefined,
|
||||
phone: undefined,
|
||||
phoneVerificationTime: undefined,
|
||||
isAnonymous: undefined,
|
||||
bio: undefined,
|
||||
githubCreatedAt: undefined,
|
||||
updatedAt: deletedAt,
|
||||
});
|
||||
}
|
||||
const autobanRemediationInternalRefs = internal as unknown as {
|
||||
users: {
|
||||
countRestorableAutobanSkillsPageInternal: unknown;
|
||||
@@ -126,10 +371,35 @@ const DEV_PERSONAS = {
|
||||
displayName: "Local Admin",
|
||||
role: "admin",
|
||||
},
|
||||
officialOrgMember: {
|
||||
handle: "local-official-member",
|
||||
displayName: "Local Official Org Member",
|
||||
role: "user",
|
||||
},
|
||||
abusePublisher: {
|
||||
handle: "local-abuse",
|
||||
displayName: "Local Abuse Test Publisher",
|
||||
email: "local-abuse@example.test",
|
||||
role: "user",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const DEV_OFFICIAL_ORG = {
|
||||
handle: "local-official-org",
|
||||
displayName: "Local Official Org",
|
||||
reason: "dev-persona.official-org-member",
|
||||
} as const;
|
||||
|
||||
type DevPersona = keyof typeof DEV_PERSONAS;
|
||||
|
||||
async function hasBlockingBanAudit(ctx: Pick<MutationCtx, "db">, userId: Id<"users">) {
|
||||
const banRecords = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", userId.toString()))
|
||||
.collect();
|
||||
return banRecords.some((record) => BAN_AUDIT_ACTIONS.has(record.action));
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)),
|
||||
@@ -141,9 +411,20 @@ export const getByIdInternal = internalQuery({
|
||||
});
|
||||
|
||||
export const upsertDevPersonaInternal = internalMutation({
|
||||
args: { persona: v.union(v.literal("owner"), v.literal("user"), v.literal("admin")) },
|
||||
args: {
|
||||
persona: v.union(
|
||||
v.literal("owner"),
|
||||
v.literal("user"),
|
||||
v.literal("admin"),
|
||||
v.literal("officialOrgMember"),
|
||||
v.literal("abusePublisher"),
|
||||
),
|
||||
devAuthSecret: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<"users">> => {
|
||||
if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled");
|
||||
if (!isLocalDevAuthEnabled(process.env, args.devAuthSecret)) {
|
||||
throw new Error("Dev auth is disabled");
|
||||
}
|
||||
|
||||
const persona = DEV_PERSONAS[args.persona as DevPersona];
|
||||
const now = Date.now();
|
||||
@@ -152,6 +433,7 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
handle: persona.handle,
|
||||
displayName: persona.displayName,
|
||||
name: persona.displayName,
|
||||
email: "email" in persona ? persona.email : undefined,
|
||||
role: persona.role,
|
||||
githubCreatedAt: DEV_PERSONA_GITHUB_CREATED_AT,
|
||||
deletedAt: undefined,
|
||||
@@ -160,6 +442,13 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
banReason: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (
|
||||
existing &&
|
||||
(existing.deletedAt || existing.deactivatedAt) &&
|
||||
(await hasBlockingBanAudit(ctx, existing._id))
|
||||
) {
|
||||
throw new ConvexError(DEV_PERSONA_BANNED_REAUTH_MESSAGE);
|
||||
}
|
||||
const userId =
|
||||
existing?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
@@ -175,10 +464,66 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
actorUserId: user._id,
|
||||
source: "dev_persona.upsert",
|
||||
});
|
||||
if (args.persona === "officialOrgMember") {
|
||||
await ensureDevOfficialOrgMembership(ctx, user, now);
|
||||
}
|
||||
return userId;
|
||||
},
|
||||
});
|
||||
|
||||
async function ensureDevOfficialOrgMembership(ctx: MutationCtx, user: Doc<"users">, now: number) {
|
||||
let publisher = await getPublisherByHandle(ctx, DEV_OFFICIAL_ORG.handle);
|
||||
let publisherId = publisher?._id;
|
||||
|
||||
if (!publisherId) {
|
||||
publisherId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: DEV_OFFICIAL_ORG.handle,
|
||||
displayName: DEV_OFFICIAL_ORG.displayName,
|
||||
bio: undefined,
|
||||
image: undefined,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (publisher?.deletedAt || publisher?.deactivatedAt) {
|
||||
await ctx.db.patch(publisherId, {
|
||||
displayName: DEV_OFFICIAL_ORG.displayName,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const existingOfficial = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.unique();
|
||||
if (!existingOfficial) {
|
||||
await ctx.db.insert("officialPublishers", {
|
||||
publisherId,
|
||||
reason: DEV_OFFICIAL_ORG.reason,
|
||||
createdByUserId: user._id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await getPublisherMembership(ctx, publisherId, user._id);
|
||||
if (!membership) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId: user._id,
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (membership.role === "publisher") {
|
||||
await ctx.db.patch(membership._id, { role: "admin", updatedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -616,24 +961,15 @@ export const deleteAccount = mutation({
|
||||
handler: async (ctx) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
const tokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
for (const token of tokens) {
|
||||
if (!token.revokedAt) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, {
|
||||
ownerUserId: userId,
|
||||
deletedAt: now,
|
||||
cursor: undefined,
|
||||
});
|
||||
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
await ctx.runMutation(internal.publishers.deleteSoleOwnerOrgsForAccountDeletionInternal, {
|
||||
actorUserId: userId,
|
||||
deletedAt: now,
|
||||
});
|
||||
const cleanup = await hardDeleteSelfDeletedAccountState(ctx, user, now);
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
deactivatedAt: now,
|
||||
purgedAt: now,
|
||||
@@ -653,7 +989,6 @@ export const deleteAccount = mutation({
|
||||
githubCreatedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "user.delete",
|
||||
@@ -668,12 +1003,202 @@ export const deleteAccount = mutation({
|
||||
emailPresent: Boolean(user?.email),
|
||||
personalPublisherId: user?.personalPublisherId ?? null,
|
||||
},
|
||||
cleanup,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function optionalPublisherId(value: unknown): Id<"publishers"> | null {
|
||||
return typeof value === "string" && value.startsWith("publishers:")
|
||||
? (value as Id<"publishers">)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getSelfDeletePreviousMetadata(log: Doc<"auditLogs"> | null) {
|
||||
return asRecord(asRecord(log?.metadata)?.previous);
|
||||
}
|
||||
|
||||
function buildAccountRecoveryPurgeCandidate(
|
||||
user: Doc<"users">,
|
||||
eligibility: {
|
||||
reason: AccountRecoveryPurgeEligibilityReason;
|
||||
selfDeleteAuditLog: Doc<"auditLogs"> | null;
|
||||
authAccountCount: number | null;
|
||||
},
|
||||
): AccountRecoveryPurgeCandidate {
|
||||
const previous = getSelfDeletePreviousMetadata(eligibility.selfDeleteAuditLog);
|
||||
return {
|
||||
userId: user._id,
|
||||
eligibilityReason: eligibility.reason,
|
||||
handle: optionalString(user.handle) ?? optionalString(previous?.handle),
|
||||
displayName:
|
||||
optionalString(user.displayName) ??
|
||||
optionalString(user.name) ??
|
||||
optionalString(previous?.displayName) ??
|
||||
optionalString(previous?.name),
|
||||
emailPresent: Boolean(user.email) || previous?.emailPresent === true,
|
||||
personalPublisherId:
|
||||
user.personalPublisherId ?? optionalPublisherId(previous?.personalPublisherId),
|
||||
authAccountCount: eligibility.authAccountCount,
|
||||
deletedAt: user.deletedAt ?? null,
|
||||
deactivatedAt: user.deactivatedAt ?? null,
|
||||
purgedAt: user.purgedAt ?? null,
|
||||
selfDeleteAuditLogId: eligibility.selfDeleteAuditLog?._id ?? null,
|
||||
selfDeleteAuditCreatedAt: eligibility.selfDeleteAuditLog?.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSelfDeletedAccountEligibility(
|
||||
ctx: MutationCtx,
|
||||
user: Doc<"users">,
|
||||
): Promise<AccountRecoveryPurgeEligibility> {
|
||||
const hasModernTombstone = Boolean(user.deactivatedAt && user.purgedAt && !user.deletedAt);
|
||||
const hasLegacySelfDeleteMarker = Boolean(user.deletedAt && !user.banReason);
|
||||
if ((!hasModernTombstone && !hasLegacySelfDeleteMarker) || user.banReason) {
|
||||
return { eligible: false, selfDeleteAuditLog: null };
|
||||
}
|
||||
const logs = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", user._id.toString()))
|
||||
.collect();
|
||||
const selfDeleteAuditLog =
|
||||
logs.find((log) => log.action === "user.delete" && log.actorUserId === user._id) ?? null;
|
||||
const hasBanAudit = logs.some((log) => BAN_AUDIT_ACTIONS.has(log.action));
|
||||
if (selfDeleteAuditLog && !hasBanAudit) {
|
||||
return {
|
||||
eligible: true,
|
||||
reason: "self_delete_audit" as const,
|
||||
selfDeleteAuditLog,
|
||||
authAccountCount: null,
|
||||
};
|
||||
}
|
||||
if (hasBanAudit) {
|
||||
return { eligible: false, selfDeleteAuditLog: null };
|
||||
}
|
||||
|
||||
const authAccounts = await ctx.db
|
||||
.query("authAccounts")
|
||||
.withIndex("userIdAndProvider", (q) => q.eq("userId", user._id))
|
||||
.collect();
|
||||
if (authAccounts.length === 0) return { eligible: false, selfDeleteAuditLog: null };
|
||||
|
||||
if (hasLegacySelfDeleteMarker) {
|
||||
return {
|
||||
eligible: true,
|
||||
reason: "auth_locked_legacy_deleted_user" as const,
|
||||
selfDeleteAuditLog: null,
|
||||
authAccountCount: authAccounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasModernTombstone) return { eligible: false, selfDeleteAuditLog: null };
|
||||
|
||||
const profileIdentityScrubbed = !user.handle && !user.email && !user.name && !user.displayName;
|
||||
if (!profileIdentityScrubbed) return { eligible: false, selfDeleteAuditLog: null };
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
reason: "auth_locked_purged_user" as const,
|
||||
selfDeleteAuditLog: null,
|
||||
authAccountCount: authAccounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const purgeSelfDeletedAccountRecoveryBatchInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
mode: accountRecoveryPurgeModeValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(
|
||||
args.limit ?? ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT,
|
||||
1,
|
||||
ACCOUNT_RECOVERY_PURGE_LIMIT_MAX,
|
||||
);
|
||||
const dryRun = args.dryRun !== false;
|
||||
const mode = args.mode ?? "deactivated";
|
||||
const { page, isDone, continueCursor } =
|
||||
mode === "legacyDeleted"
|
||||
? await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_ban_reason_deleted_at", (q) =>
|
||||
q.eq("banReason", undefined).gte("deletedAt", 0),
|
||||
)
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
: await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_deactivated_purged_at", (q) => q.gte("deactivatedAt", 0))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit });
|
||||
|
||||
let eligible = 0;
|
||||
let purged = 0;
|
||||
const skipped: Array<{ userId: Id<"users">; reason: string }> = [];
|
||||
const candidates: AccountRecoveryPurgeCandidate[] = [];
|
||||
const cleaned: Array<
|
||||
DeletedAccountCleanupResult & { userId: Id<"users">; deactivatedAt: number }
|
||||
> = [];
|
||||
|
||||
for (const user of page) {
|
||||
const eligibility = await getSelfDeletedAccountEligibility(ctx, user);
|
||||
if (!eligibility.eligible) {
|
||||
skipped.push({ userId: user._id, reason: "not_self_deleted_or_security_blocked" });
|
||||
continue;
|
||||
}
|
||||
eligible += 1;
|
||||
candidates.push(buildAccountRecoveryPurgeCandidate(user, eligibility));
|
||||
if (dryRun) continue;
|
||||
const deletedAt = user.deactivatedAt ?? user.deletedAt ?? Date.now();
|
||||
const cleanup = await hardDeleteSelfDeletedAccountState(ctx, user, deletedAt);
|
||||
await scrubDeletedUserTombstone(ctx, user, deletedAt);
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "user.recovery_purge",
|
||||
targetType: "user",
|
||||
targetId: user._id,
|
||||
metadata: {
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
purgedAt: user.purgedAt,
|
||||
deletedAt: user.deletedAt,
|
||||
cleanup,
|
||||
mode,
|
||||
source: "backfill",
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
cleaned.push({ userId: user._id, deactivatedAt: deletedAt, ...cleanup });
|
||||
purged += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
dryRun,
|
||||
mode,
|
||||
scanned: page.length,
|
||||
eligible,
|
||||
purged,
|
||||
skipped,
|
||||
candidates,
|
||||
cleaned,
|
||||
isDone,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1838,6 +2363,13 @@ async function banUserWithActor(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleBanNotificationEmail(ctx, {
|
||||
target,
|
||||
bannedAt: now,
|
||||
source: "manual",
|
||||
reason,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyBanned: false,
|
||||
@@ -1916,6 +2448,11 @@ async function unbanUserForBanAppealService(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleRestoredAccountNotificationEmail(ctx, {
|
||||
target,
|
||||
restoredAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyUnbanned: false,
|
||||
@@ -1995,6 +2532,11 @@ async function unbanUserWithActor(
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await scheduleRestoredAccountNotificationEmail(ctx, {
|
||||
target,
|
||||
restoredAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyUnbanned: false,
|
||||
@@ -2286,6 +2828,132 @@ export const ensurePublisherHandleInternal = internalMutation({
|
||||
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
function normalizeMaliciousArtifactName(name: string) {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function readMaliciousArtifactFindingFromAudit(
|
||||
log: Doc<"auditLogs">,
|
||||
): MaliciousArtifactFinding | null {
|
||||
if (log.action !== MALICIOUS_ARTIFACT_FINDING_ACTION) return null;
|
||||
const metadata = log.metadata as
|
||||
| {
|
||||
artifactKind?: unknown;
|
||||
artifactName?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const artifactKind = metadata?.artifactKind;
|
||||
const artifactName = typeof metadata?.artifactName === "string" ? metadata.artifactName : "";
|
||||
if ((artifactKind !== "skill" && artifactKind !== "plugin") || !artifactName.trim()) {
|
||||
return null;
|
||||
}
|
||||
return { artifactKind, artifactName };
|
||||
}
|
||||
|
||||
function getMaliciousArtifactEscalationReason(findings: MaliciousArtifactFinding[]) {
|
||||
const distinctArtifacts = new Set<string>();
|
||||
const attemptsByArtifact = new Map<string, number>();
|
||||
|
||||
for (const finding of findings) {
|
||||
const artifactKey = `${finding.artifactKind}:${normalizeMaliciousArtifactName(
|
||||
finding.artifactName,
|
||||
)}`;
|
||||
distinctArtifacts.add(artifactKey);
|
||||
attemptsByArtifact.set(artifactKey, (attemptsByArtifact.get(artifactKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (distinctArtifacts.size >= MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD) {
|
||||
return "distinct_artifact_threshold" as const;
|
||||
}
|
||||
for (const attempts of attemptsByArtifact.values()) {
|
||||
if (attempts >= MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD) {
|
||||
return "attempt_threshold" as const;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const recordMaliciousArtifactFindingInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
artifactKind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
artifactName: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
trigger: v.optional(v.string()),
|
||||
sha256hash: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const target = await ctx.db.get(args.ownerUserId);
|
||||
if (!target) return { ok: false as const, reason: "user_not_found" as const };
|
||||
if (target.deletedAt || target.deactivatedAt) return { ok: true as const, alreadyBanned: true };
|
||||
|
||||
const artifactName = args.artifactName.trim();
|
||||
if (!artifactName) {
|
||||
return { ok: false as const, reason: "missing_artifact" as const };
|
||||
}
|
||||
const now = Date.now();
|
||||
const trigger = args.trigger?.trim() || "scanner.malicious";
|
||||
const version = args.version?.trim() || undefined;
|
||||
const sha256hash = args.sha256hash?.trim() || undefined;
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.ownerUserId,
|
||||
action: MALICIOUS_ARTIFACT_FINDING_ACTION,
|
||||
targetType: "user",
|
||||
targetId: args.ownerUserId,
|
||||
metadata: {
|
||||
artifactKind: args.artifactKind,
|
||||
artifactName,
|
||||
version,
|
||||
trigger,
|
||||
sha256hash,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (target.role === "admin" || target.role === "moderator") {
|
||||
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
||||
target,
|
||||
findingAt: now,
|
||||
artifact: { kind: args.artifactKind, name: artifactName },
|
||||
version,
|
||||
trigger,
|
||||
});
|
||||
return { ok: true as const, escalated: false as const, reason: "protected_role" as const };
|
||||
}
|
||||
|
||||
const auditLogs = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", args.ownerUserId))
|
||||
.order("desc")
|
||||
.take(MALICIOUS_ARTIFACT_AUDIT_LOOKBACK);
|
||||
const priorFindings = auditLogs
|
||||
.map(readMaliciousArtifactFindingFromAudit)
|
||||
.filter((finding): finding is MaliciousArtifactFinding => Boolean(finding));
|
||||
const escalationReason = getMaliciousArtifactEscalationReason(priorFindings);
|
||||
if (!escalationReason) {
|
||||
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
||||
target,
|
||||
findingAt: now,
|
||||
artifact: { kind: args.artifactKind, name: artifactName },
|
||||
version,
|
||||
trigger,
|
||||
});
|
||||
return { ok: true as const, escalated: false as const };
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.users.autobanMalwareAuthorInternal, {
|
||||
ownerUserId: args.ownerUserId,
|
||||
slug: artifactName,
|
||||
trigger,
|
||||
...(sha256hash ? { sha256hash } : {}),
|
||||
artifactKind: args.artifactKind,
|
||||
artifactName,
|
||||
});
|
||||
|
||||
return { ok: true as const, escalated: true as const, reason: escalationReason };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by a scanner.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
@@ -2296,6 +2964,8 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
sha256hash: v.optional(v.string()),
|
||||
slug: v.string(),
|
||||
trigger: v.optional(v.string()),
|
||||
artifactKind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
|
||||
artifactName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const target = await ctx.db.get(args.ownerUserId);
|
||||
@@ -2388,6 +3058,18 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const trigger = args.trigger?.trim() || "scanner.malicious";
|
||||
const artifactKind = args.artifactKind ?? "skill";
|
||||
const artifactName = args.artifactName?.trim() || args.slug;
|
||||
await scheduleBanNotificationEmail(ctx, {
|
||||
target,
|
||||
bannedAt: now,
|
||||
source: "autoban",
|
||||
reason: trigger,
|
||||
trigger,
|
||||
artifact: { kind: artifactKind, name: artifactName },
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
||||
);
|
||||
|
||||
@@ -16,10 +16,15 @@ vi.mock("./lib/soulPublish", () => ({
|
||||
}));
|
||||
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { getReadme: getSkillReadme, getFileText: getSkillFileText } = await import("./skills");
|
||||
const {
|
||||
getReadme: getSkillReadme,
|
||||
getFileText: getSkillFileText,
|
||||
getGitHubSkillContent,
|
||||
} = await import("./skills");
|
||||
const { getReadme: getSoulReadme, getFileText: getSoulFileText } = await import("./souls");
|
||||
const getSkillReadmeHandler = getSkillReadme as unknown as { _handler: Function };
|
||||
const getSkillFileTextHandler = getSkillFileText as unknown as { _handler: Function };
|
||||
const getGitHubSkillContentHandler = getGitHubSkillContent as unknown as { _handler: Function };
|
||||
const getSoulReadmeHandler = getSoulReadme as unknown as { _handler: Function };
|
||||
const getSoulFileTextHandler = getSoulFileText as unknown as { _handler: Function };
|
||||
|
||||
@@ -294,6 +299,54 @@ describe("version file access actions", () => {
|
||||
).rejects.toThrow("Version not available");
|
||||
});
|
||||
|
||||
it("returns null instead of throwing for public reads from malware-blocked GitHub skill content", async () => {
|
||||
const skill = {
|
||||
_id: "skills:github",
|
||||
_creationTime: 1,
|
||||
slug: "github-demo",
|
||||
displayName: "GitHub Demo",
|
||||
summary: "Summary",
|
||||
ownerUserId: "users:owner",
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
installKind: "github",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: "hash-a",
|
||||
tags: {},
|
||||
badges: undefined,
|
||||
stats: {
|
||||
downloads: 1,
|
||||
installsCurrent: 1,
|
||||
installsAllTime: 1,
|
||||
stars: 1,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReason: "scanner.vt.malicious",
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "skills:github" ? skill : null)),
|
||||
query: vi.fn(() => {
|
||||
throw new Error("Content should not be read when the skill is not publicly readable");
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
getGitHubSkillContentHandler._handler(ctx, {
|
||||
skillId: "skills:github",
|
||||
kind: "readme",
|
||||
} as never),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("still allows public access to visible skill files", async () => {
|
||||
const ctx = makeActionCtx({
|
||||
version: makeSkillVersion(),
|
||||
|
||||
+6
-1
@@ -17,7 +17,9 @@ Use GitHub to sign in at [clawhub.ai](https://clawhub.ai).
|
||||
|
||||
Deleted, banned, or disabled accounts cannot complete normal ClawHub sign-in.
|
||||
If sign-in returns you to a logged-out state, your account may not be in good
|
||||
standing.
|
||||
standing. If your account was banned or disabled, use the
|
||||
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
|
||||
mistake.
|
||||
|
||||
## CLI login
|
||||
|
||||
@@ -86,3 +88,6 @@ Revoked, invalid, or missing tokens return `401 Unauthorized`. Sign in again
|
||||
with `clawhub login` or provide a fresh token with `clawhub login --token`.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot continue using existing API tokens.
|
||||
If your account was banned or disabled, use the
|
||||
[ClawHub appeal form](https://appeals.openclaw.ai/) if you believe this is a
|
||||
mistake.
|
||||
|
||||
+17
-4
@@ -189,24 +189,37 @@ Stores your API token + cached registry URL.
|
||||
clawhub skill publish ./my-skill --version 1.0.0
|
||||
```
|
||||
|
||||
### `scan [path]`
|
||||
### `scan --slug <slug>`
|
||||
|
||||
- Requires `clawhub login`.
|
||||
- Runs ClawHub ClawScan through `POST /api/v1/skills/-/scan`, then polls until the scan is terminal.
|
||||
- Local path scans are always ephemeral. They upload the local skill bundle for scanning, print the security report, and never create or update a published skill/version.
|
||||
- Scans are asynchronous and may take time to complete. While queued, the terminal spinner shows the current prioritized scan position and how many scans are ahead.
|
||||
- Published scans require ownership or publisher management access. Moderators/admins can use the same backend through `clawhub-mod`.
|
||||
- `--update` is valid only with `--slug`; it writes successful published scan results back to the selected version.
|
||||
- `--output <file.zip>` downloads the full report archive with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
|
||||
- `--json` prints the full poll response for automation.
|
||||
- Local path scans are no longer supported. Upload a new version, then use `scan download` to retrieve the stored scan results for that submitted version.
|
||||
|
||||
```bash
|
||||
clawhub scan ./my-skill
|
||||
clawhub scan ./my-skill --output report.zip
|
||||
clawhub scan --slug gifgrep
|
||||
clawhub scan --slug gifgrep --version 1.2.3
|
||||
clawhub scan --slug gifgrep --update --output report.zip
|
||||
```
|
||||
|
||||
### `scan download <name>`
|
||||
|
||||
- Requires `clawhub login`.
|
||||
- Downloads the stored scan report ZIP for a submitted skill or plugin version, including versions that were blocked or hidden by ClawHub security checks.
|
||||
- Skill downloads use the skill slug and default to `--kind skill`.
|
||||
- Plugin downloads use the package name and require `--kind plugin`.
|
||||
- `--version` is required so authors inspect the exact submitted version that ClawHub blocked.
|
||||
- `--output <file.zip>` chooses the destination path.
|
||||
|
||||
```bash
|
||||
clawhub scan download gifgrep --version 1.2.3
|
||||
clawhub scan download @scope/demo --version 2.0.0 --kind plugin --output report.zip
|
||||
```
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub ships an official reusable workflow at
|
||||
|
||||
+14
-7
@@ -373,10 +373,8 @@ Notes:
|
||||
|
||||
Authenticated submit endpoint for new ClawScan jobs.
|
||||
|
||||
Local upload scans use `multipart/form-data`:
|
||||
|
||||
- `payload`: JSON string, usually `{ "source": { "kind": "upload" }, "update": false }`
|
||||
- `files`: repeated local skill files
|
||||
Local upload scans are no longer supported. Requests using
|
||||
`multipart/form-data` or `{ "source": { "kind": "upload" } }` return `410`.
|
||||
|
||||
Published scans use JSON:
|
||||
|
||||
@@ -389,18 +387,18 @@ Published scans use JSON:
|
||||
|
||||
Notes:
|
||||
|
||||
- Local upload scans require auth but are ephemeral. They never mutate public skill, version, moderation, or trust state.
|
||||
- Scan request payloads and downloadable reports expire from the scan-request store after the retention window.
|
||||
- Local upload scans reject `update: true`.
|
||||
- Published scans require owner/publisher management access, or platform moderator/admin authority.
|
||||
- Published scans write back only when `update: true` and the scan completes successfully.
|
||||
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "upload|published", "update": false }`.
|
||||
- Response is `202` with `{ "ok": true, "scanId": "...", "jobId": "...", "status": "queued", "sourceKind": "published", "update": false, "queue": { "queuedAhead": 0, "queuedAheadIsEstimate": false, "position": 1, "running": 0, "runningIsEstimate": false, "note": "Scans are asynchronous and may take time to complete." } }`.
|
||||
- Scan jobs are asynchronous. Manual scan requests are prioritized ahead of normal publish/backfill work, but completion still depends on worker availability.
|
||||
|
||||
### `GET /api/v1/skills/-/scan/{scanId}`
|
||||
|
||||
Authenticated poll endpoint for a submitted scan.
|
||||
|
||||
- Returns queued/running/succeeded/failed status.
|
||||
- Returns `queue.queuedAhead` and `queue.position` while queued so clients can show how many prioritized manual scans are ahead of the request. Very large queues are bounded and reported with `queuedAheadIsEstimate: true`.
|
||||
- When available, `report` contains `clawscan`, `skillspector`, `staticAnalysis`, and `virustotal` sections.
|
||||
- Failed scan jobs return `status: "failed"` with `lastError`.
|
||||
|
||||
@@ -411,6 +409,15 @@ Authenticated report archive endpoint.
|
||||
- Requires a succeeded scan; non-terminal scans return `409`.
|
||||
- Returns a ZIP with `manifest.json`, `clawscan.json`, `skillspector.json`, `static-analysis.json`, `virustotal.json`, and `README.md`.
|
||||
|
||||
### `GET /api/v1/skills/-/scan/download/{name}?version=<version>&kind=skill|plugin`
|
||||
|
||||
Authenticated stored report archive endpoint for submitted versions.
|
||||
|
||||
- Requires owner/publisher management access to the skill or plugin, or platform moderator/admin authority.
|
||||
- Returns stored scan results for the exact submitted version, including blocked or hidden versions.
|
||||
- `kind` defaults to `skill`; use `kind=plugin` for plugin/package scans.
|
||||
- Returns the same ZIP shape as scan-request downloads.
|
||||
|
||||
### `POST /api/v1/skills/-/scan/batch`
|
||||
|
||||
Admin-only canonical batch rescan route. It accepts the same payload shape as legacy `POST /api/v1/skills/-/rescan-batch`.
|
||||
|
||||
+8
-2
@@ -79,8 +79,14 @@ result in account bans, token revocation, hidden content, or removed listings.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot use ClawHub API tokens. If CLI auth
|
||||
starts failing after account action, sign in to the web UI to review account
|
||||
state. If sign-in or normal CLI access is blocked, contact security@openclaw.ai
|
||||
for recovery review.
|
||||
state. If sign-in or normal CLI access is blocked by a ban or disabled account,
|
||||
use the [ClawHub appeal form](https://appeals.openclaw.ai/) for recovery review.
|
||||
|
||||
If a scanner-triggered email names a skill or plugin version as malicious,
|
||||
download the stored scan results for the blocked submitted version:
|
||||
`clawhub scan download <slug> --version <version>`. For plugins, add
|
||||
`--kind plugin`. Review the scan output, fix the listing, increment the version
|
||||
number, and upload the fixed version.
|
||||
|
||||
## Publisher guidance
|
||||
|
||||
|
||||
+375
-3
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1WhoamiResponseSchema,
|
||||
LegacyApiRoutes,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { unzipSync } from "fflate";
|
||||
import { strToU8, unzipSync, zipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readGlobalConfig } from "../packages/clawhub/src/config";
|
||||
import { hashSkillFiles } from "../packages/clawhub/src/skills";
|
||||
@@ -131,13 +132,142 @@ describe("clawhub e2e", () => {
|
||||
);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/API response:/);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cli scan rejects local folders before submitting a scan", async () => {
|
||||
let requestCount = 0;
|
||||
const server = createServer(async (_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-"));
|
||||
try {
|
||||
const skillDir = join(workdir, "my-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Local Skill\n", "utf8");
|
||||
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"scan",
|
||||
"./my-skill",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(requestCount).toBe(0);
|
||||
expect(result.stderr).toContain("Local folder scans are no longer supported");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("cli scan download fetches a stored submitted-version scan report", async () => {
|
||||
const reportZip = zipSync({
|
||||
"manifest.json": strToU8(
|
||||
`${JSON.stringify({
|
||||
scanId: "skill:demo-skill:1.2.3",
|
||||
sourceKind: "published",
|
||||
status: "succeeded",
|
||||
})}\n`,
|
||||
),
|
||||
"clawscan.json": strToU8(`${JSON.stringify({ status: "malicious" })}\n`),
|
||||
});
|
||||
let requestedAuthorization = "";
|
||||
let requestedPath = "";
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestedPath = `${url.pathname}${url.search}`;
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
url.pathname === `${ApiRoutes.skillScans}/download/demo-skill` &&
|
||||
url.searchParams.get("version") === "1.2.3" &&
|
||||
url.searchParams.get("kind") === "skill"
|
||||
) {
|
||||
requestedAuthorization = req.headers.authorization ?? "";
|
||||
res.writeHead(200, { "Content-Type": "application/zip" });
|
||||
res.end(Buffer.from(reportZip));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-scan-download-"));
|
||||
try {
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"scan",
|
||||
"download",
|
||||
"demo-skill",
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--output",
|
||||
"report.zip",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(requestedAuthorization).toBe("Bearer test-token");
|
||||
expect(requestedPath).toBe(
|
||||
`${ApiRoutes.skillScans}/download/demo-skill?version=1.2.3&kind=skill`,
|
||||
);
|
||||
expect(result.stdout).toContain("Report ZIP:");
|
||||
const downloaded = await readFile(join(workdir, "report.zip"));
|
||||
expect(unzipSync(downloaded)).toHaveProperty("manifest.json");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("assumes a logged-in user (whoami succeeds)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
@@ -459,6 +589,128 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a GitHub-backed skill through the install resolver and reports install telemetry", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
const telemetryBodies: unknown[] = [];
|
||||
const requestLog: string[] = [];
|
||||
const githubZipBytes = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": strToU8("# AIQ Deploy\n"),
|
||||
"skills-main/skills/aiq-deploy/skill-card.md": strToU8("# Card\n"),
|
||||
"skills-main/skills/other/SKILL.md": strToU8("# Other\n"),
|
||||
});
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestLog.push(`${req.method ?? "GET"} ${url.pathname}`);
|
||||
if (req.method === "GET" && url.pathname === `${ApiRoutes.skills}/aiq-deploy`) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
skill: {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
moderation: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `${ApiRoutes.skills}/aiq-deploy/install`) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/NVIDIA/skills/zip/${commit}`) {
|
||||
res.writeHead(200, { "Content-Type": "application/zip" });
|
||||
res.end(Buffer.from(githubZipBytes));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === LegacyApiRoutes.cliTelemetryInstall) {
|
||||
telemetryBodies.push(JSON.parse(await readRequestBody(req)) as unknown);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-github-install-"));
|
||||
try {
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"install",
|
||||
"aiq-deploy",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "",
|
||||
CLAWDHUB_DISABLE_TELEMETRY: "",
|
||||
CLAWHUB_GITHUB_CODELOAD_BASE_URL: registry,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "SKILL.md"), "utf8"),
|
||||
).resolves.toContain("# AIQ Deploy");
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "skill-card.md"), "utf8"),
|
||||
).resolves.toContain("# Card");
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "other", "SKILL.md")),
|
||||
).rejects.toThrow();
|
||||
if (telemetryBodies.length !== 1) {
|
||||
throw new Error(`Expected one install telemetry request, saw: ${requestLog.join(", ")}`);
|
||||
}
|
||||
expect(telemetryBodies[0]).toMatchObject({
|
||||
roots: [
|
||||
{
|
||||
skills: [{ slug: "aiq-deploy", version: commit }],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from clawdbot.json roots", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
@@ -601,6 +853,126 @@ describe("clawhub e2e", () => {
|
||||
expect(result.stdout).not.toMatch(/--json/);
|
||||
});
|
||||
|
||||
it("skill verify accepts the legacy json flag with flattened verification responses", async () => {
|
||||
const requestLog: string[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestLog.push(`${req.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||
if (req.method === "GET" && url.pathname === `${ApiRoutes.skills}/fulcra-context/verify`) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
schema: "clawhub.skill.verify.v1",
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: "fulcra-context",
|
||||
displayName: "Fulcra Context",
|
||||
pageUrl: "https://clawhub.ai/arc-claw-bot/fulcra-context",
|
||||
publisherHandle: "arc-claw-bot",
|
||||
publisherDisplayName: "Arc Claw Bot",
|
||||
publisherProfileUrl: "https://clawhub.ai/user/arc-claw-bot",
|
||||
version: "1.4.10",
|
||||
resolvedFrom: "version",
|
||||
tag: null,
|
||||
createdAt: 1780075196459,
|
||||
card: {
|
||||
available: true,
|
||||
path: "skill-card.md",
|
||||
url: `${registry}/api/v1/skills/fulcra-context/card?version=1.4.10`,
|
||||
sha256: "f6d6dc3701e5fea5116526261c73031030b06a6110e57fdc3ed4de7df8f315dd",
|
||||
size: 3285,
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
artifact: {
|
||||
sourceFingerprint: "source-fingerprint",
|
||||
bundleFingerprints: ["generated-bundle-fingerprint"],
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 5929,
|
||||
sha256: "db813e41699340098840b6ba2ed958f1ae53fb620e4cc1cb0aa03aabfd1a4dbc",
|
||||
},
|
||||
],
|
||||
},
|
||||
provenance: { source: "unavailable" },
|
||||
security: {
|
||||
status: "clean",
|
||||
passed: true,
|
||||
rawStatus: "clean",
|
||||
verdict: "benign",
|
||||
confidence: "high",
|
||||
summary: "Docs-only skill with bounded Fulcra read workflows.",
|
||||
model: "gpt-5.5",
|
||||
checkedAt: 1780082252374,
|
||||
signals: {
|
||||
staticScan: { status: "clean", rawStatus: "clean", reasonCodes: [] },
|
||||
virusTotal: { status: "clean", rawStatus: "clean", source: "engines" },
|
||||
skillSpector: {
|
||||
status: "clean",
|
||||
rawStatus: "clean",
|
||||
score: 0,
|
||||
severity: "LOW",
|
||||
recommendation: "SAFE",
|
||||
issueCount: 0,
|
||||
},
|
||||
dependencyRegistry: { status: "clean" },
|
||||
},
|
||||
},
|
||||
signature: { status: "unsigned" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
try {
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"skill",
|
||||
"verify",
|
||||
"fulcra-context",
|
||||
"--version",
|
||||
"1.4.10",
|
||||
"--json",
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/unknown option|API response:/i);
|
||||
const output = JSON.parse(result.stdout) as Record<string, unknown>;
|
||||
expect(output).toMatchObject({
|
||||
ok: true,
|
||||
decision: "pass",
|
||||
reasons: [],
|
||||
slug: "fulcra-context",
|
||||
publisherHandle: "arc-claw-bot",
|
||||
version: "1.4.10",
|
||||
});
|
||||
expect(output).not.toHaveProperty("skill");
|
||||
expect(output).not.toHaveProperty("publisher");
|
||||
expect(requestLog).toEqual(["GET /api/v1/skills/fulcra-context/verify?version=1.4.10"]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itIfLiveMutations(
|
||||
"publishes, deletes, and undeletes a skill (logged-in)",
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expectHealthyPage,
|
||||
expectNoFatalErrorUi,
|
||||
trackRuntimeErrors,
|
||||
waitForHydration,
|
||||
} from "../helpers/runtimeErrors";
|
||||
import { escapeRegExp, signInAsLocalPersona } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"local-auth account deletion tests require the local dev auth runner",
|
||||
);
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
function localConvexDeployment() {
|
||||
const raw = readFileSync(".convex/local/default/config.json", "utf8");
|
||||
const parsed = JSON.parse(raw) as { deploymentName?: unknown };
|
||||
if (typeof parsed.deploymentName !== "string" || !parsed.deploymentName) {
|
||||
throw new Error("Local Convex deployment name was not available");
|
||||
}
|
||||
return `local:${parsed.deploymentName}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Convex can print status lines before the JSON payload.
|
||||
}
|
||||
}
|
||||
throw new Error(`No JSON object in convex run output:\n${output}`);
|
||||
}
|
||||
|
||||
function runDevSeed<T>(functionName: string, args: Record<string, unknown>) {
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
[
|
||||
"convex",
|
||||
"run",
|
||||
"--typecheck",
|
||||
"disable",
|
||||
"--codegen",
|
||||
"disable",
|
||||
functionName,
|
||||
JSON.stringify(args),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CONVEX_DEPLOYMENT: localConvexDeployment() },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
[`Failed to run ${functionName}.`, result.stdout.trim(), result.stderr.trim()].join("\n"),
|
||||
);
|
||||
}
|
||||
return JSON.parse(extractLastJsonObject(result.stdout)) as T;
|
||||
}
|
||||
|
||||
type AccountDeletionFixture = {
|
||||
userId: string;
|
||||
publisherId: string;
|
||||
handle: string;
|
||||
skillId: string;
|
||||
packageId: string;
|
||||
};
|
||||
|
||||
type AccountDeletionFixtureState = {
|
||||
user:
|
||||
| {
|
||||
exists: true;
|
||||
handle: string | null;
|
||||
deactivatedAt: number | null;
|
||||
purgedAt: number | null;
|
||||
deletedAt: number | null;
|
||||
}
|
||||
| { exists: false };
|
||||
publisherExists: boolean;
|
||||
skillExists: boolean;
|
||||
skillActive: boolean;
|
||||
skillSoftDeletedAt: number | null;
|
||||
packageExists: boolean;
|
||||
skillPubliclyVisible: boolean;
|
||||
packagePubliclyVisible: boolean;
|
||||
packageActive: boolean;
|
||||
packageSoftDeletedAt: number | null;
|
||||
authAccountCount: number;
|
||||
authSessionCount: number;
|
||||
};
|
||||
|
||||
type AccountRecreationState = {
|
||||
previousUser:
|
||||
| {
|
||||
exists: true;
|
||||
handle: string | null;
|
||||
deactivatedAt: number | null;
|
||||
purgedAt: number | null;
|
||||
deletedAt: number | null;
|
||||
}
|
||||
| { exists: false };
|
||||
previousPublisherExists: boolean;
|
||||
previousSkillActive: boolean;
|
||||
previousPackageActive: boolean;
|
||||
activeUser: {
|
||||
userId: string;
|
||||
handle: string;
|
||||
deactivatedAt: number | null;
|
||||
purgedAt: number | null;
|
||||
deletedAt: number | null;
|
||||
personalPublisherId: string | null;
|
||||
} | null;
|
||||
activePublisher: {
|
||||
publisherId: string;
|
||||
handle: string;
|
||||
linkedUserId: string | null;
|
||||
deactivatedAt: number | null;
|
||||
deletedAt: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function seedAccountDeletionFixture(args: {
|
||||
skillSlug: string;
|
||||
skillDisplayName: string;
|
||||
packageName: string;
|
||||
packageDisplayName: string;
|
||||
}) {
|
||||
return runDevSeed<AccountDeletionFixture>("devSeed:seedAccountDeletionFixture", args);
|
||||
}
|
||||
|
||||
function getAccountDeletionFixtureState(fixture: AccountDeletionFixture) {
|
||||
return runDevSeed<AccountDeletionFixtureState>("devSeed:getAccountDeletionFixtureState", {
|
||||
userId: fixture.userId,
|
||||
publisherId: fixture.publisherId,
|
||||
skillId: fixture.skillId,
|
||||
packageId: fixture.packageId,
|
||||
});
|
||||
}
|
||||
|
||||
function getAccountRecreationState(fixture: AccountDeletionFixture) {
|
||||
return runDevSeed<AccountRecreationState>("devSeed:getAccountRecreationState", {
|
||||
handle: fixture.handle,
|
||||
previousUserId: fixture.userId,
|
||||
previousPublisherId: fixture.publisherId,
|
||||
previousSkillId: fixture.skillId,
|
||||
previousPackageId: fixture.packageId,
|
||||
});
|
||||
}
|
||||
|
||||
function isExpectedAccountDeletionRuntimeError(error: string) {
|
||||
if (error.includes("server responded with a status of 404 (Not Found)")) return true;
|
||||
return error.includes("[CONVEX Q(users:me)]") && error.includes("Function execution timed out");
|
||||
}
|
||||
|
||||
test("users can permanently delete their account and personal publisher resources", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const suffix = uniqueSuffix();
|
||||
const skillSlug = `pw-account-delete-skill-${suffix}`;
|
||||
const skillDisplayName = `Playwright Account Delete Skill ${suffix}`;
|
||||
const packageName = `pw-account-delete-plugin-${suffix}`;
|
||||
const packageDisplayName = `Playwright Account Delete Plugin ${suffix}`;
|
||||
|
||||
const fixture = seedAccountDeletionFixture({
|
||||
skillSlug,
|
||||
skillDisplayName,
|
||||
packageName,
|
||||
packageDisplayName,
|
||||
});
|
||||
|
||||
await signInAsLocalPersona(page, "user");
|
||||
|
||||
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "Local User" })).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toBeVisible();
|
||||
|
||||
await page.goto(`/plugins/${encodeURIComponent(packageName)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText(packageDisplayName)).toBeVisible();
|
||||
|
||||
await page.goto("/settings?view=danger", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await page.getByRole("button", { name: "Delete account" }).click();
|
||||
await expect(page.getByText("This permanently deletes your account")).toBeVisible();
|
||||
await expect(page.getByText("Resources permanently deleted")).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(skillDisplayName)))).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(packageDisplayName)))).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("account-deletion-confirmation.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "Permanently delete account" }).click();
|
||||
await expect(page.getByText("This permanently deletes your account")).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => getAccountDeletionFixtureState(fixture), {
|
||||
timeout: 60_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
})
|
||||
.toMatchObject({
|
||||
user: {
|
||||
exists: true,
|
||||
handle: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
publisherExists: false,
|
||||
skillPubliclyVisible: false,
|
||||
packagePubliclyVisible: false,
|
||||
skillActive: false,
|
||||
packageActive: false,
|
||||
authAccountCount: 0,
|
||||
authSessionCount: 0,
|
||||
});
|
||||
const finalState = getAccountDeletionFixtureState(fixture);
|
||||
expect(finalState.user.exists).toBe(true);
|
||||
if (finalState.user.exists) {
|
||||
expect(finalState.user.deactivatedAt).toEqual(expect.any(Number));
|
||||
expect(finalState.user.purgedAt).toEqual(expect.any(Number));
|
||||
}
|
||||
await expectHealthyPage(page, errors);
|
||||
errors.length = 0;
|
||||
|
||||
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /publisher not found|we couldn't find that page/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
|
||||
await expect(page.getByText(packageDisplayName)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/skills?q=${encodeURIComponent(skillSlug)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("No skills found")).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/plugins?q=${encodeURIComponent(packageName)}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("No plugins found")).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(packageDisplayName)))).toHaveCount(0);
|
||||
|
||||
await page.goto(`/plugins/${encodeURIComponent(packageName)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(packageDisplayName)))).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("account-deletion-post-cleanup.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await signInAsLocalPersona(page, "user");
|
||||
await expect
|
||||
.poll(() => getAccountRecreationState(fixture), {
|
||||
timeout: 30_000,
|
||||
intervals: [500, 1_000, 2_000],
|
||||
})
|
||||
.toMatchObject({
|
||||
previousUser: {
|
||||
exists: true,
|
||||
handle: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
previousPublisherExists: false,
|
||||
previousSkillActive: false,
|
||||
previousPackageActive: false,
|
||||
activeUser: {
|
||||
deactivatedAt: null,
|
||||
purgedAt: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
activePublisher: {
|
||||
handle: fixture.handle,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
const recreationState = getAccountRecreationState(fixture);
|
||||
expect(recreationState.activeUser?.userId).toBeTruthy();
|
||||
expect(recreationState.activeUser?.userId).not.toBe(fixture.userId);
|
||||
expect(recreationState.activePublisher?.publisherId).toBeTruthy();
|
||||
expect(recreationState.activePublisher?.publisherId).not.toBe(fixture.publisherId);
|
||||
expect(recreationState.activePublisher?.linkedUserId).toBe(recreationState.activeUser?.userId);
|
||||
expect(recreationState.activeUser?.personalPublisherId).toBe(
|
||||
recreationState.activePublisher?.publisherId,
|
||||
);
|
||||
|
||||
await page.goto(`/user/${fixture.handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "Local User" })).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
|
||||
await expect(page.getByText(packageDisplayName)).toHaveCount(0);
|
||||
|
||||
await expectNoFatalErrorUi(page);
|
||||
expect(errors.filter((error) => !isExpectedAccountDeletionRuntimeError(error))).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
|
||||
import { escapeRegExp, signInAsLocalPersona } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"local-auth org deletion tests require the local dev auth runner",
|
||||
);
|
||||
|
||||
test.use({ video: process.env.CLAWHUB_ORG_DELETE_PROOF_VIDEO === "1" ? "on" : "off" });
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
function localConvexDeployment() {
|
||||
const raw = readFileSync(".convex/local/default/config.json", "utf8");
|
||||
const parsed = JSON.parse(raw) as { deploymentName?: unknown };
|
||||
if (typeof parsed.deploymentName !== "string" || !parsed.deploymentName) {
|
||||
throw new Error("Local Convex deployment name was not available");
|
||||
}
|
||||
return `local:${parsed.deploymentName}`;
|
||||
}
|
||||
|
||||
function seedOrgDeletionFixture(args: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
skillSlug: string;
|
||||
skillDisplayName: string;
|
||||
packageName: string;
|
||||
packageDisplayName: string;
|
||||
}) {
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
[
|
||||
"convex",
|
||||
"run",
|
||||
"--typecheck",
|
||||
"disable",
|
||||
"--codegen",
|
||||
"disable",
|
||||
"devSeed:seedOrgDeletionFixture",
|
||||
JSON.stringify(args),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CONVEX_DEPLOYMENT: localConvexDeployment() },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
["Failed to seed org deletion fixture.", result.stdout.trim(), result.stderr.trim()].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clearExpectedNotFoundNavigationErrors(errors: string[]) {
|
||||
for (let index = errors.length - 1; index >= 0; index -= 1) {
|
||||
if (
|
||||
errors[index] ===
|
||||
"console:Failed to load resource: the server responded with a status of 404 (Not Found)"
|
||||
) {
|
||||
errors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("org owners can delete an org and hide its skills and plugins", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const suffix = uniqueSuffix();
|
||||
const handle = `pw-org-del-${suffix}`;
|
||||
const displayName = `Playwright Delete Org ${suffix}`;
|
||||
const skillSlug = `pw-org-delete-skill-${suffix}`;
|
||||
const skillDisplayName = `Playwright Org Delete Skill ${suffix}`;
|
||||
const packageName = `pw-org-delete-plugin-${suffix}`;
|
||||
const packageDisplayName = `Playwright Org Delete Plugin ${suffix}`;
|
||||
|
||||
seedOrgDeletionFixture({
|
||||
handle,
|
||||
displayName,
|
||||
skillSlug,
|
||||
skillDisplayName,
|
||||
packageName,
|
||||
packageDisplayName,
|
||||
});
|
||||
|
||||
await signInAsLocalPersona(page, "owner");
|
||||
|
||||
await page.goto(`/user/${handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: displayName })).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toBeVisible();
|
||||
|
||||
await page.goto(`/plugins/${encodeURIComponent(packageName)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText(packageDisplayName)).toBeVisible();
|
||||
|
||||
await page.goto("/settings?view=organizations", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText(`@${handle} · owner`)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Delete organization" }).click();
|
||||
await expect(page.getByText(`Permanently delete @${handle}`)).toBeVisible();
|
||||
await expect(page.getByText("Resources permanently deleted")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Permanently delete organization" }).click();
|
||||
await expect(page.getByText(`Permanently delete @${handle}`)).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.goto(`/user/${handle}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: /we couldn't find that page/i })).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
|
||||
await expect(page.getByText(packageDisplayName)).toHaveCount(0);
|
||||
clearExpectedNotFoundNavigationErrors(errors);
|
||||
|
||||
await page.goto(`/skills?q=${encodeURIComponent(skillSlug)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("No skills found")).toBeVisible();
|
||||
await expect(page.getByText(skillDisplayName)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/plugins?q=${encodeURIComponent(packageName)}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("No plugins found")).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(packageDisplayName)))).toHaveCount(0);
|
||||
|
||||
await page.goto(`/plugins/${encodeURIComponent(packageName)}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible();
|
||||
await expect(page.getByText(new RegExp(escapeRegExp(packageDisplayName)))).toHaveCount(0);
|
||||
clearExpectedNotFoundNavigationErrors(errors);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -3,16 +3,62 @@ import { join } from "node:path";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import { waitForHydration } from "../helpers/runtimeErrors";
|
||||
|
||||
type DevPersona = "owner" | "user" | "admin";
|
||||
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
|
||||
|
||||
// The quality gate fingerprints line shape, so vary local-auth fixtures by slug.
|
||||
const FINGERPRINT_SALT_LINES = [
|
||||
"Ready.",
|
||||
"Local publish path ready.",
|
||||
"The local publish path records browser state with enough detail for maintainers.",
|
||||
"- Upload.",
|
||||
"- Validate the local publish form.",
|
||||
"- Validate the local publish form after selecting owner, version, and generated files.",
|
||||
"1. Check final route.",
|
||||
"### Local browser release evidence and storage handoff notes",
|
||||
] as const;
|
||||
|
||||
function hashFixtureInput(value: string) {
|
||||
let hash = 0;
|
||||
for (const char of value) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function fingerprintSaltBlock(args: { slug: string; versionLabel: string }) {
|
||||
const hash = hashFixtureInput(`${args.versionLabel}:${args.slug}:local-auth`);
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const code = (hash >>> (index * 3)) & 7;
|
||||
lines.push(FINGERPRINT_SALT_LINES[code] ?? FINGERPRINT_SALT_LINES[0]);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
|
||||
const displayName =
|
||||
persona === "owner" ? "Local Owner" : persona === "user" ? "Local User" : "Local Admin";
|
||||
persona === "owner"
|
||||
? "Local Owner"
|
||||
: persona === "user"
|
||||
? "Local User"
|
||||
: persona === "abusePublisher"
|
||||
? "Local Abuse Test Publisher"
|
||||
: "Local Admin";
|
||||
const displayNamePattern =
|
||||
persona === "abusePublisher"
|
||||
? `${escapeRegExp("Local Abuse Test Publishe")}.*`
|
||||
: escapeRegExp(displayName);
|
||||
const exactHandle =
|
||||
persona === "owner"
|
||||
? `${escapeRegExp(expectedHandle)}(?![-\\w])`
|
||||
: escapeRegExp(expectedHandle);
|
||||
return new RegExp(`@(?:${exactHandle}|${escapeRegExp(displayName)})`, "i");
|
||||
return new RegExp(`@(?:${exactHandle}|${displayNamePattern})`, "i");
|
||||
}
|
||||
|
||||
function devPersonaMenuLabel(persona: DevPersona) {
|
||||
if (persona === "abusePublisher") return "abuse publisher";
|
||||
return persona;
|
||||
}
|
||||
|
||||
export function skillMd(args: { slug: string; displayName: string; versionLabel: string }) {
|
||||
@@ -39,6 +85,8 @@ The skill documents a realistic release process so the publish quality gate sees
|
||||
This ${args.versionLabel} payload is intentionally deterministic and text-only.
|
||||
It avoids external credentials, network access, binary files, and production state.
|
||||
Maintainers can run it against a disposable local Convex backend to prove the UI still supports the full version lifecycle.
|
||||
|
||||
${fingerprintSaltBlock(args)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -47,7 +95,12 @@ export function escapeRegExp(value: string) {
|
||||
}
|
||||
|
||||
export async function expectLocalPersonaActive(page: Page, persona: DevPersona) {
|
||||
const expectedHandle = persona === "owner" ? "local" : `local-${persona}`;
|
||||
const expectedHandle =
|
||||
persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
await expect(page.locator("header .user-trigger")).toContainText(
|
||||
devPersonaHeaderPattern(persona, expectedHandle),
|
||||
{ timeout: 15_000 },
|
||||
@@ -59,7 +112,9 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByRole("button", { name: "Open local dev personas" }).click();
|
||||
await page.getByRole("menuitem", { name: new RegExp(`use ${persona}`, "i") }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: new RegExp(`use ${devPersonaMenuLabel(persona)}`, "i") })
|
||||
.click();
|
||||
try {
|
||||
await expectLocalPersonaActive(page, persona);
|
||||
} catch {
|
||||
@@ -68,7 +123,11 @@ export async function signInAsLocalPersona(page: Page, persona: DevPersona) {
|
||||
await expectLocalPersonaActive(page, persona);
|
||||
}
|
||||
|
||||
return persona === "owner" ? "local" : `local-${persona}`;
|
||||
return persona === "owner"
|
||||
? "local"
|
||||
: persona === "abusePublisher"
|
||||
? "local-abuse"
|
||||
: `local-${persona}`;
|
||||
}
|
||||
|
||||
export async function signInAsLocalOwner(page: Page) {
|
||||
@@ -107,7 +166,7 @@ export async function selectOwnerHandle(page: Page, selector: string, ownerHandl
|
||||
await ownerControl.click();
|
||||
await page
|
||||
.getByRole("option", {
|
||||
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\b|\\s|·)`, "i"),
|
||||
name: new RegExp(`@${escapeRegExp(ownerHandle)}(?:\\s|·|$)`, "i"),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import convexBrowser from "convex/browser";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
|
||||
import { publishSkillVersion, signInAsLocalPublisher } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"malicious skill ban flow requires the local dev auth runner",
|
||||
);
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
|
||||
const { ConvexHttpClient } = convexBrowser;
|
||||
type ConvexHttpClientInstance = InstanceType<typeof ConvexHttpClient>;
|
||||
|
||||
type ClaimedScanJob = {
|
||||
job: { _id: Id<"securityScanJobs">; leaseToken: string };
|
||||
target?: { skill?: { slug?: string }; version?: { version?: string } };
|
||||
};
|
||||
|
||||
type CapturedEmail = {
|
||||
idempotencyKey: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
capturedAt: number;
|
||||
};
|
||||
|
||||
function convexClient() {
|
||||
const convexUrl = process.env.VITE_CONVEX_URL;
|
||||
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
|
||||
return new ConvexHttpClient(convexUrl);
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function readCapturedEmails() {
|
||||
const captureFile = process.env.CLAWHUB_EMAIL_CAPTURE_FILE;
|
||||
if (!captureFile) throw new Error("CLAWHUB_EMAIL_CAPTURE_FILE is required");
|
||||
if (!existsSync(captureFile)) return [];
|
||||
const raw = await readFile(captureFile, "utf8");
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as CapturedEmail);
|
||||
}
|
||||
|
||||
async function waitForCapturedEmails(predicate: (emails: CapturedEmail[]) => boolean) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let latest: CapturedEmail[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
latest = await readCapturedEmails();
|
||||
if (predicate(latest)) return latest;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for captured emails. Saw: ${latest
|
||||
.map((email) => email.subject)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForClaimedScanJob(
|
||||
client: ConvexHttpClientInstance,
|
||||
slug: string,
|
||||
version: string,
|
||||
) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const jobs = (await client.action(api.securityScan.claimCodexScanJobs, {
|
||||
token: WORKER_TOKEN,
|
||||
workerId: `pw-malicious-skill-${slug}-${version}`,
|
||||
limit: 20,
|
||||
leaseMs: 60_000,
|
||||
})) as ClaimedScanJob[];
|
||||
const match = jobs.find(
|
||||
(job) => job.target?.skill?.slug === slug && job.target?.version?.version === version,
|
||||
);
|
||||
if (match) return match;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`Timed out waiting for security scan job for ${slug}@${version}`);
|
||||
}
|
||||
|
||||
async function completeScan(
|
||||
client: ConvexHttpClientInstance,
|
||||
args: { slug: string; version: string; verdict: "benign" | "malicious" },
|
||||
) {
|
||||
const scanJob = await waitForClaimedScanJob(client, args.slug, args.version);
|
||||
const malicious = args.verdict === "malicious";
|
||||
await client.action(api.securityScan.completeCodexScanJob, {
|
||||
token: WORKER_TOKEN,
|
||||
jobId: scanJob.job._id,
|
||||
leaseToken: scanJob.job.leaseToken,
|
||||
runId: "playwright-local-auth",
|
||||
llmAnalysis: {
|
||||
status: malicious ? "malicious" : "clean",
|
||||
verdict: args.verdict,
|
||||
confidence: "high",
|
||||
summary: malicious
|
||||
? "Synthetic local e2e malicious verdict."
|
||||
: "Synthetic local e2e clean verdict.",
|
||||
guidance: malicious
|
||||
? "Synthetic local e2e blocked upload."
|
||||
: "Synthetic local e2e clean upload.",
|
||||
model: "mock-local-e2e",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCurrentVersion(page: import("@playwright/test").Page, version: string) {
|
||||
const metadata = page.locator(".sidebar-metadata");
|
||||
await expect(metadata.getByText("Current version", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(metadata.getByText(`v${version}`, { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
|
||||
return errors.filter(
|
||||
(error) => !(error.includes("CONVEX M(users:ensure)") && error.includes("User not found")),
|
||||
);
|
||||
}
|
||||
|
||||
test("malicious skill retries keep the clean latest visible, email the publisher, and ban on third rejection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const client = convexClient();
|
||||
const slug = `pw-malware-${Date.now().toString(36)}`;
|
||||
const displayName = "Playwright Malicious Skill Flow";
|
||||
|
||||
const ownerHandle = await signInAsLocalPublisher(page, "abusePublisher");
|
||||
await publishSkillVersion(page, testInfo, {
|
||||
ownerHandle,
|
||||
slug,
|
||||
displayName,
|
||||
version: "1.0.0",
|
||||
versionLabel: "clean baseline release",
|
||||
changelog: "Clean baseline release before malicious retry validation.",
|
||||
});
|
||||
await completeScan(client, { slug, version: "1.0.0", verdict: "benign" });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expectCurrentVersion(page, "1.0.0");
|
||||
|
||||
const maliciousVersions = ["1.0.1", "1.0.2", "1.0.3"] as const;
|
||||
const finalMaliciousVersion = maliciousVersions[maliciousVersions.length - 1];
|
||||
for (const version of maliciousVersions) {
|
||||
await page.getByRole("link", { name: "New version" }).click();
|
||||
await expect(page).toHaveURL(/\/skills\/publish\?updateSlug=/);
|
||||
await publishSkillVersion(page, testInfo, {
|
||||
ownerHandle,
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
versionLabel: `malicious retry ${version}`,
|
||||
changelog: `Synthetic malicious retry ${version}.`,
|
||||
});
|
||||
await completeScan(client, { slug, version, verdict: "malicious" });
|
||||
if (version === finalMaliciousVersion) {
|
||||
await waitForCapturedEmails((emails) =>
|
||||
emails.some((email) => email.subject === "Your ClawHub account was disabled"),
|
||||
);
|
||||
} else {
|
||||
await waitForCapturedEmails(
|
||||
(emails) =>
|
||||
emails.filter(
|
||||
(email) =>
|
||||
email.subject === "ClawHub blocked a skill version" &&
|
||||
email.text.includes(`Version: ${version}`) &&
|
||||
email.text.includes(`clawhub scan download ${slug} --version ${version}`),
|
||||
).length === 1,
|
||||
);
|
||||
}
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
if (version !== finalMaliciousVersion) {
|
||||
await expectCurrentVersion(page, "1.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
const emails = await waitForCapturedEmails(
|
||||
(captured) =>
|
||||
captured.filter((email) => email.subject === "ClawHub blocked a skill version").length ===
|
||||
2 && captured.some((email) => email.subject === "Your ClawHub account was disabled"),
|
||||
);
|
||||
const artifactEmails = emails.filter(
|
||||
(email) => email.subject === "ClawHub blocked a skill version",
|
||||
);
|
||||
expect(artifactEmails).toHaveLength(2);
|
||||
for (const email of artifactEmails) {
|
||||
expect(email.text).toContain("Your account can still sign in.");
|
||||
expect(email.text).toContain("Repeated malicious rejections may lead to account disablement");
|
||||
expect(email.text).not.toContain("appeals.openclaw.ai");
|
||||
}
|
||||
|
||||
const accountBanEmail = emails.find(
|
||||
(email) => email.subject === "Your ClawHub account was disabled",
|
||||
);
|
||||
expect(accountBanEmail?.text).toContain("Appeal: https://appeals.openclaw.ai/");
|
||||
expect(accountBanEmail?.text).not.toContain("clawhub scan download");
|
||||
|
||||
await page.getByRole("button", { name: "Open local dev personas" }).click();
|
||||
await page.getByRole("menuitem", { name: /sign out/i }).click();
|
||||
await page.goto("/dashboard?error_description=This%20account%20has%20been%20banned", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(page).toHaveURL(/\/account-banned$/, { timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Your ClawHub account has been banned" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Open an appeal" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://appeals.openclaw.ai/",
|
||||
);
|
||||
|
||||
await expectHealthyPage(page, withoutExpectedBannedSessionTeardownErrors(errors));
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
type SeedFixtures = {
|
||||
skill: {
|
||||
displayName: string;
|
||||
ownerHandle: string;
|
||||
slug: string;
|
||||
};
|
||||
plugin: {
|
||||
displayName: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PublicRouteCase = {
|
||||
label: string;
|
||||
path: (fixtures: SeedFixtures) => string;
|
||||
assert: (page: Page, fixtures: SeedFixtures) => Promise<void>;
|
||||
};
|
||||
|
||||
function pluginDetailPath(name: string) {
|
||||
const scopedMatch = /^@([^/]+)\/([^/]+)$/.exec(name.trim());
|
||||
if (scopedMatch) {
|
||||
return `/plugins/@${encodeURIComponent(scopedMatch[1]!)}/${encodeURIComponent(scopedMatch[2]!)}`;
|
||||
}
|
||||
return `/plugins/${encodeURIComponent(name.trim())}`;
|
||||
}
|
||||
|
||||
function seedApiUrl(path: string) {
|
||||
const convexSiteUrl = process.env.VITE_CONVEX_SITE_URL?.trim();
|
||||
return convexSiteUrl ? new URL(path, convexSiteUrl).toString() : path;
|
||||
}
|
||||
|
||||
async function fetchSeedFixtures(request: APIRequestContext): Promise<SeedFixtures> {
|
||||
const skillPath = "/api/v1/skills/gifgrep";
|
||||
const skillResponse = await request.get(seedApiUrl(skillPath));
|
||||
expect(
|
||||
skillResponse.ok(),
|
||||
`seed skill fixture ${skillPath} returned ${skillResponse.status()}`,
|
||||
).toBe(true);
|
||||
const skillPayload = (await skillResponse.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { displayName?: string | null; slug?: string | null };
|
||||
};
|
||||
const ownerHandle = skillPayload.owner?.handle?.trim();
|
||||
const skillSlug = skillPayload.skill?.slug?.trim();
|
||||
const skillDisplayName = skillPayload.skill?.displayName?.trim();
|
||||
expect(ownerHandle, "gifgrep seed fixture needs an owner handle").toBeTruthy();
|
||||
expect(skillSlug, "gifgrep seed fixture needs a slug").toBeTruthy();
|
||||
expect(skillDisplayName, "gifgrep seed fixture needs a display name").toBeTruthy();
|
||||
|
||||
const pluginPath = "/api/v1/plugins?limit=1";
|
||||
const pluginResponse = await request.get(seedApiUrl(pluginPath));
|
||||
expect(
|
||||
pluginResponse.ok(),
|
||||
`seed plugin catalog ${pluginPath} returned ${pluginResponse.status()}`,
|
||||
).toBe(true);
|
||||
const pluginPayload = (await pluginResponse.json()) as {
|
||||
items?: Array<{ displayName?: string | null; name?: string | null }>;
|
||||
};
|
||||
const plugin = pluginPayload.items?.find((item) => item.name?.trim() && item.displayName?.trim());
|
||||
expect(plugin, "seed plugin catalog needs at least one public plugin").toBeTruthy();
|
||||
|
||||
return {
|
||||
skill: {
|
||||
displayName: skillDisplayName!,
|
||||
ownerHandle: ownerHandle!,
|
||||
slug: skillSlug!,
|
||||
},
|
||||
plugin: {
|
||||
displayName: plugin!.displayName!.trim(),
|
||||
name: plugin!.name!.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publicRouteCases(): PublicRouteCase[] {
|
||||
return [
|
||||
{
|
||||
label: "home",
|
||||
path: () => "/",
|
||||
assert: async (page) => {
|
||||
await expect(page.locator("body")).toContainText("ClawHub");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skills browse",
|
||||
path: () => "/skills",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugins browse",
|
||||
path: () => "/plugins",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Plugins/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "publishers browse",
|
||||
path: () => "/publishers",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Publishers/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "souls browse",
|
||||
path: () => "/souls",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /SOUL\.md discovery/i })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "search results",
|
||||
path: () => "/search?q=gifgrep",
|
||||
assert: async (page) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Search results for "gifgrep"/ }),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skill detail",
|
||||
path: (fixtures) =>
|
||||
`/${encodeURIComponent(fixtures.skill.ownerHandle)}/${encodeURIComponent(fixtures.skill.slug)}`,
|
||||
assert: async (page, fixtures) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: fixtures.skill.displayName }).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skill security audit",
|
||||
path: (fixtures) =>
|
||||
`/${encodeURIComponent(fixtures.skill.ownerHandle)}/${encodeURIComponent(
|
||||
fixtures.skill.slug,
|
||||
)}/security-audit`,
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Security Audit").first()).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "publisher profile",
|
||||
path: (fixtures) => `/user/${encodeURIComponent(fixtures.skill.ownerHandle)}`,
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Publisher catalog")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugin detail",
|
||||
path: (fixtures) => pluginDetailPath(fixtures.plugin.name),
|
||||
assert: async (page, fixtures) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: fixtures.plugin.displayName }).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugin security audit",
|
||||
path: (fixtures) => `${pluginDetailPath(fixtures.plugin.name)}/security-audit`,
|
||||
assert: async (page) => {
|
||||
await expect(
|
||||
page.getByText(/Security Audit|Security audit is unavailable/i).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out skill publish",
|
||||
path: () => "/skills/publish",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to publish a skill")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out plugin publish",
|
||||
path: () => "/plugins/publish",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to publish a plugin")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out import",
|
||||
path: () => "/import",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to import and publish skills")).toBeVisible();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function expectPublicRouteHealthy(
|
||||
page: Page,
|
||||
route: PublicRouteCase,
|
||||
fixtures: SeedFixtures,
|
||||
) {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const path = route.path(fixtures);
|
||||
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
expect(response, `${route.label} should return a response`).not.toBeNull();
|
||||
expect(response!.status(), `${route.label} should not return a 5xx response`).toBeLessThan(500);
|
||||
await expect(page.locator("body")).not.toContainText(/\bServer Error\b/i);
|
||||
await waitForHydration(page);
|
||||
await route.assert(page, fixtures);
|
||||
await expectHealthyPage(page, errors);
|
||||
}
|
||||
|
||||
for (const route of publicRouteCases()) {
|
||||
test(`public route renders: ${route.label}`, async ({ page, request }) => {
|
||||
const fixtures = await fetchSeedFixtures(request);
|
||||
await expectPublicRouteHealthy(page, route, fixtures);
|
||||
});
|
||||
}
|
||||
+20
-19
@@ -12,10 +12,10 @@
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
|
||||
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
|
||||
"ci:pr": "bun run ci:static && bun run ci:unit && bun run ci:packages && bun run ci:types-build && bun run ci:e2e-http",
|
||||
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 && bun run format:check && bun run lint && bun run deadcode:ci",
|
||||
"ci:types-build": "bunx tsc --noEmit && bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit && bun run --cwd packages/clawhub-mod typecheck && VITE_CONVEX_URL=https://example.invalid bun run build",
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "0.37.4",
|
||||
"@convex-dev/auth": "0.0.92",
|
||||
"@convex-dev/auth": "0.0.93",
|
||||
"@fontsource/bricolage-grotesque": "5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
"@fontsource/manrope": "5.2.8",
|
||||
@@ -88,29 +88,30 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@resvg/resvg-wasm": "2.6.2",
|
||||
"@shikijs/rehype": "4.1.0",
|
||||
"@tanstack/react-router": "1.170.8",
|
||||
"@tanstack/react-start": "1.168.13",
|
||||
"@shikijs/rehype": "4.2.0",
|
||||
"@tanstack/react-router": "1.170.12",
|
||||
"@tanstack/react-start": "1.168.21",
|
||||
"@vercel/analytics": "2.0.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clawhub-schema": "workspace:0.0.2",
|
||||
"clsx": "2.1.1",
|
||||
"convex": "1.39.1",
|
||||
"convex": "1.40.0",
|
||||
"convex-helpers": "0.1.118",
|
||||
"fflate": "0.8.3",
|
||||
"h3": "2.0.1-rc.22",
|
||||
"ignore": "7.0.5",
|
||||
"lucide-react": "1.16.0",
|
||||
"lucide-react": "1.17.0",
|
||||
"mime": "4.1.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-markdown": "10.1.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"rehype-sanitize": "6.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"semver": "7.8.1",
|
||||
"shiki": "4.1.0",
|
||||
"resend": "6.12.4",
|
||||
"semver": "7.8.2",
|
||||
"shiki": "4.2.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
@@ -128,21 +129,21 @@
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"jsdom": "29.1.1",
|
||||
"nitro": "3.0.260429-beta",
|
||||
"only-allow": "1.2.2",
|
||||
"oxfmt": "0.51.0",
|
||||
"oxlint": "1.66.0",
|
||||
"oxfmt": "0.53.0",
|
||||
"oxlint": "1.68.0",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"typescript": "6.0.3",
|
||||
"undici": "7.26.0",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "4.1.7"
|
||||
"undici": "7.27.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user