Compare commits

...
Author SHA1 Message Date
Patrick Erichsen 8e1bad4ac1 feat: improve account appeal notifications (#2520)
* feat: improve account appeal notifications

Adds Resend-backed account-action emails for bans/restores, public-safe ban reason summaries, scanner-triggered appeal scan guidance, and inline banned-account sign-in appeal messaging.

Autoreview findings addressed:

- [P2] Wire restored-account email into unban flow

- [P2] Add the scan upload test to the CI selection

* feat: quarantine malicious artifact uploads

* fix: warn on repeated malicious rejections

* fix: route banned sign-ins to appeal page

* feat: add stored scan report downloads

* chore: bump clawhub cli to 0.20.0

* test: cover malicious skill ban journey

* test: align deletion cleanup expectations after merge

* fix: hide deleted account packages before cleanup

* fix: finalize appeals moderation flow
2026-06-06 16:55:23 -07:00
Patrick Erichsen 8f7c21ac02 test: cover account recreation after deletion (#2540) 2026-06-06 16:47:19 -07:00
Patrick Erichsen 999131e2e7 fix: purge auth-locked deleted accounts
* fix: purge auth-locked deleted accounts

* test: wait for account deletion resources

* fix: mark packages inactive during deletion purge

* test: expect package soft delete before purge

* test: ignore post-delete auth timeout noise
2026-06-06 16:07:19 -07:00
Patrick Erichsen 571cc70c7a test: limit local-auth playwright retries (#2538) 2026-06-06 14:58:18 -07:00
Patrick Erichsen 3ead985f82 fix: restore post-merge CI gates (#2537) 2026-06-06 14:50:46 -07:00
Patrick Erichsen 1117aa4340 feat: add cohort temporal abuse scoring (#2536) 2026-06-06 14:26:46 -07:00
Patrick Erichsen 9ce5e702b5 feat: hard delete account-owned resources (#2533)
* feat: hard delete account-owned resources

* fix: expose account recovery purge dry-run candidates
2026-06-06 14:17:50 -07:00
Patrick Erichsen c632b697bb fix: hide banned user publisher profiles (#2528)
* fix: hide banned user publisher profiles

* docs: require real ClawHub screenshot proof

* test: stabilize local auth profile visibility checks
2026-06-05 17:33:03 -07:00
Patrick Erichsen d2525b179a fix: stop copying publisher ids to skill embeddings (#2532) 2026-06-05 17:28:55 -07:00
Patrick Erichsen 8ebae39140 feat: add temporal publisher abuse scan (#2531) 2026-06-05 17:14:57 -07:00
Patrick Erichsen 821494b9dd fix(cli): accept legacy verify json flag
Accept legacy skill verify --json usage as a hidden compatibility no-op, add a regression e2e for the flattened verifier response, and prepare clawhub@0.19.2.
2026-06-05 16:30:19 -07:00
Patrick Erichsen 94413a60fb feat: show plugin downloads in lists
Show plugin download counts in lists, add downloads sorting, and remove plugin family badges from plugin list/grid rows.
2026-06-05 16:18:00 -07:00
Patrick Erichsen 9cbf98297c fix: exclude official orgs from publisher abuse (#2527) 2026-06-05 15:59:10 -07:00
Patrick Erichsen 94ded18dec feat: add org deletion cascade
Closes #2516.
2026-06-05 14:52:31 -07:00
Patrick Erichsen 9897850074 fix: add targeted legacy publisher ownership repair
Adds a targeted legacy owner-publisher projection repair mutation for one user and covers the repair path in maintenance tests.
2026-06-05 14:24:29 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 159118dc59 build(deps): bump the github-actions group with 3 updates (#2473)
Bumps the github-actions group with 3 updates: [useblacksmith/begin-testbox](https://github.com/useblacksmith/begin-testbox), [github/codeql-action](https://github.com/github/codeql-action) and [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog).


Updates `useblacksmith/begin-testbox` from d0e04585c26905fdd92c94a09c159544c7ee1b67 to 233448af4bfdc6fca509a7f0974411ac6d8a8043
- [Commits](https://github.com/useblacksmith/begin-testbox/compare/d0e04585c26905fdd92c94a09c159544c7ee1b67...233448af4bfdc6fca509a7f0974411ac6d8a8043)

Updates `github/codeql-action` from 4.36.0 to 4.36.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...87557b9c84dde89fdd9b10e88954ac2f4248e463)

Updates `trufflesecurity/trufflehog` from 3.95.3 to 3.95.5
- [Release notes](https://github.com/trufflesecurity/trufflehog/releases)
- [Commits](https://github.com/trufflesecurity/trufflehog/compare/v3.95.3...v3.95.5)

---
updated-dependencies:
- dependency-name: useblacksmith/begin-testbox
  dependency-version: 233448af4bfdc6fca509a7f0974411ac6d8a8043
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: trufflesecurity/trufflehog
  dependency-version: 3.95.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:11:39 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 843f19e640 build(deps): bump commander from 14.0.3 to 15.0.0 (#2476)
Bumps [commander](https://github.com/tj/commander.js) from 14.0.3 to 15.0.0.
- [Release notes](https://github.com/tj/commander.js/releases)
- [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tj/commander.js/compare/v14.0.3...v15.0.0)

---
updated-dependencies:
- dependency-name: commander
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:07:07 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Peter Steinberger
4b882b2f43 build(deps): bump the production-minor-and-patch group across 1 directory with 12 updates (#2510)
* build(deps): bump the production-minor-and-patch group across 1 directory with 12 updates

Bumps the production-minor-and-patch group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@convex-dev/auth](https://github.com/get-convex/convex-auth) | `0.0.92` | `0.0.93` |
| [@shikijs/rehype](https://github.com/shikijs/shiki/tree/HEAD/packages/rehype) | `4.1.0` | `4.2.0` |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.170.8` | `1.170.12` |
| [@tanstack/react-start](https://github.com/TanStack/router/tree/HEAD/packages/react-start) | `1.168.13` | `1.168.21` |
| [convex](https://github.com/get-convex/convex-backend/tree/HEAD/npm-packages/convex) | `1.39.1` | `1.40.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.17.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [semver](https://github.com/npm/node-semver) | `7.8.1` | `7.8.2` |
| [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) | `4.1.0` | `4.2.0` |
| [undici](https://github.com/nodejs/undici) | `7.26.0` | `7.27.1` |
| [@clack/prompts](https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts) | `1.4.0` | `1.5.1` |



Updates `@convex-dev/auth` from 0.0.92 to 0.0.93
- [Changelog](https://github.com/get-convex/convex-auth/blob/main/CHANGELOG.md)
- [Commits](https://github.com/get-convex/convex-auth/compare/v0.0.92...v0.0.93)

Updates `@shikijs/rehype` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Commits](https://github.com/shikijs/shiki/commits/v4.2.0/packages/rehype)

Updates `@tanstack/react-router` from 1.170.8 to 1.170.12
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.170.12/packages/react-router)

Updates `@tanstack/react-start` from 1.168.13 to 1.168.21
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-start/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-start@1.168.21/packages/react-start)

Updates `convex` from 1.39.1 to 1.40.0
- [Release notes](https://github.com/get-convex/convex-backend/releases)
- [Changelog](https://github.com/get-convex/convex-backend/blob/main/npm-packages/convex/CHANGELOG.md)
- [Commits](https://github.com/get-convex/convex-backend/commits/HEAD/npm-packages/convex)

Updates `lucide-react` from 1.16.0 to 1.17.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `semver` from 7.8.1 to 7.8.2
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.1...v7.8.2)

Updates `shiki` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/shikijs/shiki/releases)
- [Commits](https://github.com/shikijs/shiki/commits/v4.2.0/packages/shiki)

Updates `undici` from 7.26.0 to 7.27.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.26.0...v7.27.1)

Updates `@clack/prompts` from 1.4.0 to 1.5.1
- [Release notes](https://github.com/bombshell-dev/clack/releases)
- [Changelog](https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md)
- [Commits](https://github.com/bombshell-dev/clack/commits/@clack/prompts@1.5.1/packages/prompts)

---
updated-dependencies:
- dependency-name: "@clack/prompts"
  dependency-version: 1.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: "@convex-dev/auth"
  dependency-version: 0.0.93
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: "@shikijs/rehype"
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.170.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: "@tanstack/react-start"
  dependency-version: 1.168.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: convex
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: semver
  dependency-version: 7.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: shiki
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: undici
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci: update convex local auth runner

* ci: keep local auth deployment marker prefixed

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-05 13:50:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 634667c2c8 build(deps-dev): bump the development-minor-and-patch group across 1 directory with 5 updates (#2475)
Bumps the development-minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.7` | `4.1.8` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.51.0` | `0.53.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.66.0` | `1.68.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.14` | `8.0.16` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.7` | `4.1.8` |



Updates `@vitest/coverage-v8` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8)

Updates `oxfmt` from 0.51.0 to 0.53.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.53.0/npm/oxfmt)

Updates `oxlint` from 1.66.0 to 1.68.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.68.0/npm/oxlint)

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

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: oxfmt
  dependency-version: 0.53.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: oxlint
  dependency-version: 1.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 13:26:14 -07:00
Patrick Erichsen bd30b182d7 feat: show skill scan queue progress 2026-06-05 11:35:08 -07:00
Patrick Erichsen 2d719feef5 fix: sync github status through skill digests 2026-06-04 21:02:54 -07:00
Patrick Erichsen d3e1059266 fix: remove org badge from publisher profiles 2026-06-04 21:02:54 -07:00
Patrick Erichsen 1a968d8243 fix: clean up publisher catalog rows (#2513)
* fix: clean up publisher catalog rows

* fix: type github sync soft deletes
2026-06-04 20:21:59 -07:00
Patrick Erichsen 59667cc1a5 fix: keep pending github skills visible (#2514) 2026-06-04 20:20:13 -07:00
Patrick Erichsen 5b0dd96530 fix: soft delete removed github skills (#2512) 2026-06-04 19:46:57 -07:00
Patrick Erichsen 8e3858a31d chore: release clawhub cli 0.19.1
Release clawhub CLI 0.19.1 so npm includes GitHub-backed skill install support.
2026-06-04 19:26:34 -07:00
Patrick Erichsen fe4acc8e10 feat: surface github sync issues (#2509) 2026-06-04 19:15:39 -07:00
Patrick Erichsen f2e6e87756 test: gate public routes in ci smoke (#2508) 2026-06-04 19:09:39 -07:00
Patrick Erichsen 3de69919de Add official org selector to GitHub sync settings (#2505)
* feat: select official org for github sync sources

* fix: rename github sync publisher field
2026-06-04 19:08:23 -07:00
Patrick Erichsen 7629433248 fix: skip publisher handle conflicts in ownership repair (#2507) 2026-06-04 19:06:38 -07:00
Patrick Erichsen b82ad43eae fix: align org publisher catalog tabs (#2506) 2026-06-04 19:05:33 -07:00
Patrick Erichsen b7ee5c8e62 fix: backfill legacy publisher ownership (#2503)
* fix: backfill legacy publisher ownership

* test: update publisher official mocks
2026-06-04 18:57:42 -07:00
Patrick Erichsen 1c8543ade6 feat: add official org mod commands (#2504) 2026-06-04 18:47:12 -07:00
Patrick Erichsen 7cd0ef362d fix: select publisher previews by installs (#2502) 2026-06-04 18:39:47 -07:00
Patrick Erichsen 9cf9e12bdd GitHub-backed skill source sync (#2498)
* feat: add github-backed skill seed schema

* feat: render github-backed skill manifests

* feat: add github-backed skill install resolver

* feat: add github-backed skill source sync

* fix: handle github resolver blocks

* fix: harden github source sync

* fix: expose github install blocks

* feat: support GitHub-backed skill sync

* fix: handle blocked github skill content

* fix: harden github sync rollout

* fix: bound github sync payloads

* feat: allow forcing pending github skill installs

* fix: remove official publisher seed rollout

* chore: format clawhub e2e test
2026-06-04 18:20:43 -07:00
Patrick Erichsen 112e25e28c fix: retry incomplete github sign-ins (#2501) 2026-06-04 17:18:32 -07:00
Jesse Merhi d786374b77 feat: add shared download metering (#2485) 2026-06-04 16:48:08 -07:00
Jesse Merhi 3fbe27560e feat: add publisher abuse review dashboard (#2471) 2026-06-05 09:05:50 +10:00
Patrick Erichsen 33539b6df3 fix: point account recovery to github issues (#2499) 2026-06-04 14:41:52 -07:00
clawsweeper[bot]clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>momothemage
354f12a033 fix(httpApiV1): surface 400-class delete validation errors instead of opaque 500 (#2496)
Summary:
- The branch narrows the soft-delete bad-request substring whitelist from any `reserved` message to the ClawHu ... rvation phrase and adds regression tests for the intended 400 path and an unrelated reserved-word 500 path.
- Reproducibility: yes. by source inspection: current main maps any cleaned soft-delete error containing `rese ...  while preserving the package route-reservation case. I did not execute tests during this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head a58687c54e.
- Required merge gates passed before the squash merge.

Prepared head SHA: a58687c54e
Review: https://github.com/openclaw/clawhub/pull/2496#issuecomment-4620039541

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: momothemage
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
2026-06-04 07:43:22 +00:00
Momo b0ea80df6c fix(httpApiV1): surface 400-class delete validation errors instead of opaque 500 (#2488)
Merged via squash.

Prepared head SHA: cc6ac9d31a
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-06-04 15:32:30 +08:00
200 changed files with 36164 additions and 2329 deletions
+11
View File
@@ -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>
+1 -1
View File
@@ -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 }}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1
View File
@@ -3,6 +3,7 @@ node_modules
.bun-build
*.bun-build
.artifacts/
artifacts/
.cache/
.data/
bin/docs-list
+2
View File
@@ -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.
+18
View File
@@ -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
+199 -228
View File
@@ -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=="],
}
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
},
+58
View File
@@ -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,
{},
);
});
});
+28
View File
@@ -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;
+243
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -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(), {});
});
});
+137
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+158
View File
@@ -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,
});
},
});
+1
View File
@@ -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),
+241
View File
@@ -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);
});
});
+206
View File
@@ -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),
});
+249
View File
@@ -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
+14
View File
@@ -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",
+20 -4
View File
@@ -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
View File
@@ -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,
+714 -5
View File
@@ -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 () => {
+49
View File
@@ -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">;
+7
View File
@@ -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,
+79 -14
View File
@@ -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);
+23 -5
View File
@@ -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
View File
@@ -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) {
+77
View File
@@ -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>,
+97 -1
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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,
+67 -3
View File
@@ -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
View File
@@ -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 {
+18
View File
@@ -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`);
}
+122
View File
@@ -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.");
});
});
+352
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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,
};
}
+1 -6
View File
@@ -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(
+566
View File
@@ -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");
});
});
+671
View File
@@ -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;
}
+214
View File
@@ -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,
});
});
});
+180
View File
@@ -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("/");
}
+18
View File
@@ -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 }));
}
+83 -38
View File
@@ -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,
);
});
});
+12 -35
View File
@@ -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(
+3
View File
@@ -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",
+16
View File
@@ -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
View File
@@ -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,
+173
View File
@@ -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,
}));
}
+337
View File
@@ -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;
+196
View File
@@ -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",
]);
});
});
+157
View File
@@ -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,
};
}
+27
View File
@@ -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 = {
+3 -2
View File
@@ -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> {
+1 -1
View File
@@ -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 (
+25
View File
@@ -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">,
+15 -8
View File
@@ -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",
+3 -2
View File
@@ -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",
+719
View File
@@ -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
View File
@@ -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:
+360
View File
@@ -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" },
});
});
});
+181
View File
@@ -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);
},
});
+671 -8
View File
@@ -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
View File
@@ -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",
});
},
});
File diff suppressed because it is too large Load Diff
+1196 -10
View File
File diff suppressed because it is too large Load Diff
+330
View File
@@ -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;
}
+712
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+599 -54
View File
@@ -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
View File
@@ -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,
+627
View File
@@ -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
View File
@@ -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"),
+22 -4
View File
@@ -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);
+85 -1
View File
@@ -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
View File
@@ -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",
}),
+2 -2
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+703 -21
View File
@@ -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}`,
);
+54 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+66 -7
View File
@@ -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));
});
+214
View File
@@ -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
View File
@@ -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