Compare commits

...
Author SHA1 Message Date
Patrick Erichsen 875f026a23 chore(release): prepare clawhub cli 0.18.0
Bump the clawhub CLI package to 0.18.0 and add release notes for skill verification.
2026-05-25 17:25:02 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4248f61926 build(deps-dev): bump the development-minor-and-patch group across 1 directory with 10 updates (#2395)
Bumps the development-minor-and-patch group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/devtools-vite](https://github.com/TanStack/devtools/tree/HEAD/packages/devtools-vite) | `0.6.0` | `0.7.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.7.0` | `25.9.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.14` | `19.2.15` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.1` | `6.0.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.6` | `4.1.7` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.49.0` | `0.51.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.64.0` | `1.66.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `0.22.1` | `0.23.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.12` | `8.0.14` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.6` | `4.1.7` |



Updates `@tanstack/devtools-vite` from 0.6.0 to 0.7.0
- [Release notes](https://github.com/TanStack/devtools/releases)
- [Changelog](https://github.com/TanStack/devtools/blob/main/packages/devtools-vite/CHANGELOG.md)
- [Commits](https://github.com/TanStack/devtools/commits/@tanstack/devtools-vite@0.7.0/packages/devtools-vite)

Updates `@types/node` from 25.7.0 to 25.9.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@vitejs/plugin-react` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.2/packages/plugin-react)

Updates `@vitest/coverage-v8` from 4.1.6 to 4.1.7
- [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.7/packages/coverage-v8)

Updates `oxfmt` from 0.49.0 to 0.51.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.51.0/npm/oxfmt)

Updates `oxlint` from 1.64.0 to 1.66.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.66.0/npm/oxlint)

Updates `oxlint-tsgolint` from 0.22.1 to 0.23.0
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v0.22.1...v0.23.0)

Updates `vite` from 8.0.12 to 8.0.14
- [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.14/packages/vite)

Updates `vitest` from 4.1.6 to 4.1.7
- [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.7/packages/vitest)

---
updated-dependencies:
- dependency-name: "@tanstack/devtools-vite"
  dependency-version: 0.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: oxfmt
  dependency-version: 0.51.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: oxlint
  dependency-version: 1.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: oxlint-tsgolint
  dependency-version: 0.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.7
  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-05-25 17:19:04 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aec03c016d build(deps): bump the production-minor-and-patch group across 1 directory with 10 updates (#2396)
Bumps the production-minor-and-patch group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@shikijs/rehype](https://github.com/shikijs/shiki/tree/HEAD/packages/rehype) | `4.0.2` | `4.1.0` |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.169.2` | `1.170.8` |
| [@tanstack/react-start](https://github.com/TanStack/router/tree/HEAD/packages/react-start) | `1.167.65` | `1.168.13` |
| [convex](https://github.com/get-convex/convex-backend/tree/HEAD/npm-packages/convex) | `1.38.0` | `1.39.1` |
| [convex-helpers](https://github.com/get-convex/convex-helpers/tree/HEAD/packages/convex-helpers) | `0.1.116` | `0.1.118` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.14.0` | `1.16.0` |
| [semver](https://github.com/npm/node-semver) | `7.8.0` | `7.8.1` |
| [shiki](https://github.com/shikijs/shiki/tree/HEAD/packages/shiki) | `4.0.2` | `4.1.0` |
| [undici](https://github.com/nodejs/undici) | `7.25.0` | `7.26.0` |



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

Updates `@tanstack/react-router` from 1.169.2 to 1.170.8
- [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.8/packages/react-router)

Updates `@tanstack/react-start` from 1.167.65 to 1.168.13
- [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.13/packages/react-start)

Updates `convex` from 1.38.0 to 1.39.1
- [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/npm/1.39.1/npm-packages/convex)

Updates `convex-helpers` from 0.1.116 to 0.1.118
- [Changelog](https://github.com/get-convex/convex-helpers/blob/main/packages/convex-helpers/CHANGELOG.md)
- [Commits](https://github.com/get-convex/convex-helpers/commits/npm/0.1.118/packages/convex-helpers)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

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

Updates `semver` from 7.8.0 to 7.8.1
- [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.0...v7.8.1)

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

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

---
updated-dependencies:
- dependency-name: "@shikijs/rehype"
  dependency-version: 4.1.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.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: "@tanstack/react-start"
  dependency-version: 1.168.13
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: convex
  dependency-version: 1.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: convex-helpers
  dependency-version: 0.1.118
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: shiki
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: undici
  dependency-version: 7.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 17:10:26 -07:00
Patrick Erichsen b62d8ca813 feat: generate nvidia-style skill cards (#2382)
* feat: generate nvidia-style skill cards

* fix: harden skill card verification

* fix: use version-specific skill card evidence

* fix: close skill card verification gaps

* fix: refresh stale skill cards

* fix: protect skill card provenance fingerprints

* fix: close skill card review gaps

* fix: harden skill card trust boundaries

* fix: isolate skill card renderer

* fix: harden skill card workflows
2026-05-25 16:53:25 -07:00
Patrick Erichsen 01946864f2 feat: refine skillspector audit UI (#2397) 2026-05-25 16:02:19 -07:00
Patrick Erichsen 963b0a5719 fix: increase security scan worker throughput (#2389) 2026-05-23 18:56:52 -07:00
Patrick Erichsen 6a3c8551e8 fix: let codex adjudicate scan evidence (#2388) 2026-05-23 17:46:37 -07:00
Patrick Erichsen 1db8a6ca22 feat: support package security rescans (#2387) 2026-05-23 17:18:18 -07:00
Patrick Erichsen 2ad4068071 fix: streamline security audit agentic lanes (#2386)
* fix: streamline security audit agentic lanes

* fix: cover security audit rescan route mock

* fix: invert security audit meter scale
2026-05-23 16:41:16 -07:00
Patrick Erichsen 7446579772 fix: require scan worker artifact reads (#2385) 2026-05-23 16:26:56 -07:00
Patrick Erichsen c9e105fa34 fix: make security scan worker dispatchable (#2384) 2026-05-23 15:40:14 -07:00
Patrick Erichsen c538848a3d feat: use SkillSpector for agentic risk findings (#2383)
* feat: use SkillSpector for agentic risk findings

* fix: harden SkillSpector result handling
2026-05-23 15:31:58 -07:00
Peter Steinberger c145166287 fix: point docs auth to canonical host 2026-05-23 19:48:52 +01:00
Patrick Erichsen 0907fae0d9 fix: cap skill export page size (#2376) 2026-05-22 13:33:11 -07:00
Patrick Erichsen afd73264bd fix: log skill export failures (#2375) 2026-05-22 12:44:18 -07:00
e22bb7d427 Export skills function added by Mirror Site maintainer (#2138)
* feat: add GET /api/v1/skills/export for batch ZIP download

Add a new REST API endpoint that allows authenticated admin users to
export skills in bulk as a merged ZIP archive, designed for the ClawHub
China mirror site to efficiently sync skill data.

- New endpoint: GET /api/v1/skills/export?startDate=&endDate=&limit=&cursor=
- Admin-only auth via requireExportAuth (Bearer token + role check)
- Zip Slip protection: validateSlug + validateFilePath
- Duplicate ZIP path detection in buildMergedExportZip
- Per-skill metadata written to _export_skill_meta.json (avoids collision with skill files)
- Error recording: missing version/blob logged to _errors.json
- Dedicated rate limit tier: export { ip: 10, key: 60, adminKey: 600 }
- Cursor-based pagination on skillSearchDigest.by_active_created index
- Chunked parallel blob reads (50 concurrent)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: allow authenticated skill exports

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-22 12:22:16 -07:00
Momoandmomothemage 232017ba6f feat: surface "API key required" skill attribute (#2353)
Merged via squash.

Prepared head SHA: 94992fb6d1
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-22 16:42:05 +08:00
Tak Hoffman d897660b55 fix(ci): route ClawSweeper comment commands
Route ClawSweeper issue-comment commands through the dispatch workflow so maintainer @clawsweeper commands reach the repair comment router.
2026-05-21 23:36:07 -05:00
Onur Solmaz 5415940219 Merge pull request #2371 from openclaw/fix/publisher-title-clipping
fix: prevent publisher title clipping
2026-05-22 11:51:09 +08:00
Onur Solmaz bf2366fad2 fix: prevent publisher title clipping 2026-05-22 11:22:19 +08:00
Patrick Erichsen 28fb63cc67 fix: use generic 404 for missing skill pages (#2366) 2026-05-21 12:11:21 -07:00
Patrick Erichsen 77624dd70f fix: clarify package publish runtime errors (#2364) 2026-05-21 11:42:47 -07:00
Patrick Erichsen d3cd7a75cf fix: contain skill install command card (#2362) 2026-05-21 10:41:21 -07:00
Patrick Erichsen 7467cd88cc fix: prefer plugin manifest display names (#2361) 2026-05-21 10:23:33 -07:00
Patrick Erichsen b07196f336 feat: add org repair commands to clawhub-mod (#2359) 2026-05-21 08:21:04 -07:00
Patrick Erichsen 92dfd8f35a fix: use readme heading for package display fallback (#2358) 2026-05-21 07:59:32 -07:00
Patrick Erichsen 01e4418ccc fix: persist codex scan diagnostics (#2351) 2026-05-20 20:26:16 -07:00
Patrick Erichsen 64633c2644 fix: make header background opaque (#2352) 2026-05-20 20:04:00 -07:00
Patrick Erichsen 39107900ea fix: hide empty VirusTotal stats (#2350)
* fix: hide empty VirusTotal stats

* fix: avoid partial VirusTotal denominators
2026-05-20 19:58:27 -07:00
Vyctor H. BrzezowskiandPatrick Erichsen a54f240a08 feat: unify signed-out screens with SignInPrompt component (#2148)
Replace ad-hoc sign-in UI on /settings, /dashboard, /import, /stars,
/cli/auth and /docs/auth with a single SignInPrompt component that
mirrors the polished /settings design (gradient backdrop, blur, card
with shadow, LockKeyhole icon, styled GitHub sign-in button).

Also replaces inline 'Sign in to comment.' text in SoulDetailPage and
SkillCommentsPanel with a compact SignInButton size='sm'.

Adds SignInPrompt.test.tsx with 8 unit tests and -stars.test.tsx with
6 route-level tests.

Fixes stars.tsx loading logic so unauthenticated users see the prompt
immediately instead of a skeleton.

- bun run build: pass
- bun run format:check: pass
- bun run lint: pass
- bun run test: 1,758 tests pass

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-20 19:37:25 -07:00
Vyctor H. BrzezowskiandPatrick Erichsen 07bf41e109 fix(api): accept full skill list pagination cursors (#2275) (#2286)
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-20 19:36:57 -07:00
Pragnyan Ramtha 5cc3e890fd fix(clawhub): cap embedding text by bytes (#2337) 2026-05-21 12:34:20 +10:00
Vyctor H. Brzezowski cb6f365bb5 fix(web): remove files tab height cap on desktop (#2217)
* fix(web): adjust files tab tree height and mobile disclosure

* fix(web): refine mobile see-all affordance in files tab

* fix(web): polish files preview empty state

* fix(web): add files preview header divider

* fix(web): polish files code preview styling

* fix(web): move files preview metadata to footer

* fix(web): keep files code preview scrollbars visible

* fix(web): tighten files preview footer divider

* fix(web): move files preview scrolling to viewer body

* fix(web): align files tab panel headers

* fix(web): align files panel header dividers

* fix(web): balance files panel header padding

* fix(web): match files preview header typography

* fix(web): precisely align files panel header dividers

* fix(web): split file row paths by filename

* fix(web): add contrast to files preview body

* fix(web): align files panel mobile breakpoint

* fix(web): polish mobile files see all overlay
2026-05-20 19:30:16 -07:00
Luke 9a2e8d6fea fix: clarify package sync and publish paths (#2235) 2026-05-20 17:52:54 -07:00
Pragnyan RamthaandPatrick Erichsen dcf42b0502 fix(clawhub): continue sync after slug conflicts (#2336)
* fix(clawhub): continue sync after slug conflicts

* fix(clawhub): handle locked sync slugs

* fix: continue sync after publish failures

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-20 17:44:19 -07:00
Jesse Merhi 5f6b73024c fix: separate financial capability tags from crypto (#2344) 2026-05-20 17:07:39 -07:00
Patrick Erichsen 4e4d5c88d1 feat: consolidate security audit UI (#2349) 2026-05-20 15:33:35 -07:00
Jason (Json) 5be50db7ec feat: add category-aware related skills (#2270) 2026-05-20 15:02:45 -07:00
Patrick Erichsen 3c39480695 Fix undetected VirusTotal results staying pending (#2347)
* fix: increase clawscan worker throughput

* fix: treat undetected VT results as clean
2026-05-20 14:46:38 -07:00
Vyctor H. Brzezowski 526d84f338 fix: render CJK text in OG images (#2342) 2026-05-19 22:13:24 -07:00
Patrick Erichsen 9be35a3d43 feat: restore skill rescan moderation command (#2341) 2026-05-19 22:06:40 -07:00
ImLukeF e712ce7362 fix: increase mobile detail tab touch target 2026-05-20 14:55:01 +10:00
Patrick Erichsen 3c45326b88 fix: show VT undetected fallback as pass (#2338) 2026-05-19 21:22:12 -07:00
Patrick Erichsen d67583f075 chore(release): automate cli github releases (#2331) 2026-05-19 17:05:42 -07:00
Vyctor H. Brzezowski f23671bba9 docs: reconcile local dev worktree workflows (#2328) 2026-05-19 15:56:17 -07:00
Patrick Erichsen b753b1f7ab chore(release): prepare clawhub cli 0.17.0 (#2326) 2026-05-19 14:23:06 -07:00
Patrick Erichsen ffdd06a731 fix: increase clawscan worker throughput (#2327) 2026-05-19 14:20:37 -07:00
Patrick Erichsen 0b888a2d13 feat: add self-serve publisher creation (#2324) 2026-05-19 14:15:54 -07:00
Vyctor H. Brzezowski b8efe83d1b feat: refresh dynamic og images (#2180) 2026-05-19 13:37:36 -07:00
Patrick Erichsen c7935b6800 fix: cancel queued vt update scans (#2313) 2026-05-18 22:56:29 -07:00
Patrick Erichsen 4851f5f76d fix: add vt scan queue pruning helper (#2312) 2026-05-18 22:24:15 -07:00
Patrick Erichsen 970663d51d fix: remove virustotal code insight scans (#2311) 2026-05-18 22:22:42 -07:00
Patrick Erichsen a5ec1c71a3 fix: disable daily vt rescan (#2309) 2026-05-18 21:58:00 -07:00
Patrick Erichsen 66f3d07ca1 feat: add ban reclassification tool (#2305) 2026-05-18 18:42:59 -07:00
Patrick Erichsen 27f0b7d206 fix: paginate autoban remediation dry runs (#2304)
* fix: split autoban dry-run pagination

* fix: paginate autoban remediation dry runs
2026-05-18 18:06:06 -07:00
Patrick Erichsen 60aa4a77fd Fix synchronous skill star counts (#2301)
* fix: update skill star counts synchronously

* test: wait for stable local publish owner

* test: wait for local star persona on detail page
2026-05-18 17:11:23 -07:00
Patrick Erichsen 583a48db07 fix: split autoban dry-run pagination (#2303) 2026-05-18 17:02:30 -07:00
Patrick Erichsen 6814af95df feat: add autoban remediation command (#2302) 2026-05-18 16:28:52 -07:00
Patrick Erichsen 2aa2a449e5 chore: route worktree startup through Worktrunk (#2297)
* fix: add worktree preflight checks

* fix: remove worktree preflight spec note

* chore: compare worktrunk worktree startup

* fix: stop worktrunk dev process group

* chore: route worktree startup through worktrunk

* chore: keep dev seeding as project command

* chore: clarify worktrunk worktree script boundaries

* fix: keep worktree CI green after main merge
2026-05-18 14:02:14 -07:00
Patrick Erichsen 7cf16ebb28 fix: preserve skill updated timestamp on scans (#2296)
* fix: preserve skill updated timestamp on scans

* fix: scope scan timestamp preservation
2026-05-18 11:51:06 -07:00
Patrick Erichsen f9072e53e1 chore: add ClawHub autoreview skill (#2294) 2026-05-18 10:53:23 -07:00
Peter Steinberger ba587040b0 fix: let Codex own release scan verdicts 2026-05-18 09:42:09 +01:00
Peter Steinberger 1a00013c21 test: accept canonical local publish owner 2026-05-18 07:26:52 +01:00
Peter Steinberger 0613c54ce6 chore: prepare 0.16.0 release 2026-05-18 07:17:32 +01:00
Peter Steinberger 389b06b2cc fix: make Codex scan schema strict 2026-05-18 07:09:45 +01:00
Peter Steinberger de59d21291 fix: harden package publish contention (#2291)
* fix: harden package publish contention

* test: fix http retry overload
2026-05-18 07:09:01 +01:00
Peter Steinberger e3ad5892a5 fix: authenticate Codex security worker in CI 2026-05-18 06:55:34 +01:00
Peter Steinberger 74421c37fd fix: make Codex security worker executable in Actions 2026-05-18 06:51:55 +01:00
Peter Steinberger 35aa372b24 feat: run ClawScan classification through Codex (#2290) 2026-05-18 01:55:28 +01:00
Patrick Erichsen 636750fbf8 fix: add admin package name repair (#2289)
* fix: add admin package name repair

* fix: stabilize publish owner selection
2026-05-17 17:08:42 -07:00
Vyctor H. BrzezowskiandPeter Steinberger 8bbc66868d fix: harden skill transfer acceptance (#2276)
* fix: prevent skill transfer acceptance after requester is banned

The acceptTransferInternal mutation did not verify whether the transfer
requester (fromUser) was banned or deactivated when the skill still
belonged to that user. This created a race condition where a pending
transfer could be accepted after the requester was banned, allowing
the skill to escape the ban batch and remain alive under a new owner.

This change moves the requester validity check before the ownership
branch, so it is evaluated unconditionally for all transfers.

Fixes a security vulnerability where banned users' skills could
survive moderation actions via pending transfers.

* fix: harden skill transfer acceptance

Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-16 14:35:13 +01:00
Patrick Erichsen fd4d22d2c2 Seed local dev with public corpus (#2226)
* feat: seed local dev with public corpus

* fix: preserve local seed owner helpers after merge
2026-05-14 23:20:04 -07:00
Patrick Erichsen e99eae32d6 fix: replace search loading boxes with skeletons (#2224) 2026-05-14 11:22:43 -07:00
Vyctor H. BrzezowskiandPatrick Erichsen 9ab92e5847 fix(web): harden search relevance UX (#2206)
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
2026-05-14 11:18:42 -07:00
Patrick Erichsen cccef81a3e fix: show suspicious skills in UI search (#2220) 2026-05-14 10:24:03 -07:00
Momoandmomothemage c7458d9477 fix: package delete returns 500 for packages with capability tags (#2212)
Merged via squash.

Prepared head SHA: 82ca516464
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-14 11:14:19 +08:00
Jason 7e1e2f0a4f Fix local dev seeding, redirects, and package pinning (#2169)
* feat: seed local owner content for dev

* fix: redirect merged skill pages to canonical slug

* chore: pin package dependency versions

* fix: remove dashboard seed button

* docs: document local fixture seeding

* fix: make current-user seed fixtures internal
2026-05-13 16:41:31 -07:00
Patrick Erichsen 9fcf892e34 feat: add ClawScan finding permalinks (#2209) 2026-05-13 16:16:29 -07:00
Patrick Erichsen 22e994fe55 feat: add ClawScan finding permalinks (#2208) 2026-05-13 15:48:55 -07:00
Jason 0dd91f130e fix: use canonical user profile route (#2173) 2026-05-13 15:45:47 -07:00
Patrick Erichsen 893f341bdb feat: add security audits page (#2207)
* feat: add security audits page

* fix: remove stale nav icon export
2026-05-13 15:37:16 -07:00
Patrick Erichsen 3b6d8ac3d8 fix: shorten ClawScan risk badge labels (#2204) 2026-05-13 14:27:58 -07:00
Patrick Erichsen a116d92866 feat: show ClawScan risk levels in UI (#2202) 2026-05-13 14:09:38 -07:00
Patrick Erichsen a1666bb1e6 feat: export ClawScan findings sidecar (#2201)
* feat: export clawscan findings sidecar

* fix: canonicalize clawscan sidecar fields
2026-05-13 09:45:33 -07:00
Peter Steinberger 5c98c7e3e1 docs: add inline comment guidance 2026-05-13 16:13:33 +01:00
Peter Steinberger 0e926c6f8a fix: tolerate publisher handle conflicts during session ensure 2026-05-13 12:31:18 +01:00
Peter Steinbergerandvyctorbrzezowski 50858282b3 feat: add plugin discovery categories
Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-13 12:18:05 +01:00
Peter Steinberger b1206ed994 fix: land catalog docs and starred page fixes 2026-05-13 12:11:10 +01:00
Peter Steinberger 2763be8fb7 test: fix icon publisher CI checks 2026-05-13 10:55:00 +01:00
Peter Steinberger 4ca5ba9d1e fix: tighten ClawHub publish and API edge cases 2026-05-13 10:50:13 +01:00
Momo 5e4be11a85 feat(skills): allow publishers to pick a custom lucide icon (#2174)
Merged via squash.

Prepared head SHA: 1c3d695e87
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-13 17:39:57 +08:00
Peter Steinberger 6c15a481d0 fix: improve search ownership and profile saves 2026-05-13 08:33:19 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a925359e13 build(deps-dev): bump the development-minor-and-patch group across 1 directory with 7 updates (#2166)
Bumps the development-minor-and-patch group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.6.2` | `25.7.0` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.5` | `4.1.6` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.48.0` | `0.49.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.63.0` | `1.64.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.11` | `8.0.12` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |



Updates `@playwright/test` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `@types/node` from 25.6.2 to 25.7.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8)

Updates `oxfmt` from 0.48.0 to 0.49.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.49.0/npm/oxfmt)

Updates `oxlint` from 1.63.0 to 1.64.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.64.0/npm/oxlint)

Updates `vite` from 8.0.11 to 8.0.12
- [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.12/packages/vite)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: oxfmt
  dependency-version: 0.49.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: oxlint
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.6
  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-05-13 08:24:05 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 207e2a8448 build(deps): bump the github-actions group with 2 updates (#2165)
Bumps the github-actions group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog).


Updates `github/codeql-action` from 4.35.3 to 4.35.4
- [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/e46ed2cbd01164d986452f91f178727624ae40d7...68bde559dea0fdcac2102bfdf6230c5f70eb485e)

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

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: trufflesecurity/trufflehog
  dependency-version: 3.95.3
  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-05-13 08:23:57 +01:00
Patrick Erichsen ae2ffd25b5 Add new version detail actions (#2196)
* feat: add new version detail actions

* test: update new version e2e flow
2026-05-12 22:55:35 -07:00
Peter Steinberger 6dbeabb983 ci: harden prod HTTP smoke retries 2026-05-13 06:34:17 +01:00
Patrick Erichsen 8f74484032 docs: add clawhub moderation skill (#2195) 2026-05-12 22:32:48 -07:00
Vyctor H. Brzezowski d794f4633d fix(web): sort publisher previews by downloads (#2176) 2026-05-12 22:17:52 -07:00
Peter Steinberger e6a3bdf4e3 fix: audit identity and publisher changes 2026-05-13 06:13:13 +01:00
Patrick Erichsen 72d4e7a326 feat: restore skills via moderator unhide (#2194) 2026-05-12 22:06:20 -07:00
Peter Steinberger f72e936a47 test: stabilize live e2e coverage 2026-05-13 05:56:02 +01:00
Patrick Erichsen 64f1ffbeab test: add skill publish lifecycle e2e (#2193)
* test: add skill publish lifecycle e2e

* test: generalize local auth e2e lane

* fix: tighten local dev auth e2e gate
2026-05-12 21:40:51 -07:00
Patrick Erichsen 2ddaad62cc feat: add Crabbox UI proof workflow (#2192)
* feat: add crabbox ui proof workflow

* fix: render ui proof video previews inline
2026-05-12 21:13:04 -07:00
Jesse Merhi f0a6789c31 feat: add exact release trust endpoint (#2191) 2026-05-12 20:12:56 -07:00
Marko Anastasov af96221ebb fix: avoid slug availability crash for duplicate slugs (#2179) 2026-05-12 13:20:00 -07:00
Peter Steinberger be77f0626d test: wait for hydration before owner navigation 2026-05-12 08:21:00 +01:00
Peter Steinberger dded7a55b9 fix: improve mobile detail tab targets 2026-05-12 08:11:21 +01:00
Peter Steinberger 5b10c21b50 ci: ignore overbroad tanstack history advisory 2026-05-12 07:34:11 +01:00
Peter Steinberger c107523a31 fix: prepare clawhub 0.15.0 2026-05-12 07:30:23 +01:00
Val Alexander 09cbd9c58d Update styles.css 2026-05-11 22:42:42 -05:00
Val Alexander 22950d1bd2 Update .gitignore 2026-05-11 22:42:23 -05:00
Patrick Erichsen 53b64d1d91 fix: show plugin settings action to managers (#2168) 2026-05-11 17:14:14 -07:00
Patrick Erichsen b80b942fb2 fix: show plugin settings action to managers (#2163) 2026-05-11 17:02:29 -07:00
Patrick Erichsen 426a2a7879 fix: clarify skill summary settings copy (#2162) 2026-05-11 16:37:37 -07:00
Patrick Erichsen ee8e33947c docs: clarify pre-pr validation gates (#2161) 2026-05-11 16:36:01 -07:00
Patrick Erichsen 0c4d0448c1 fix: show full skill description on detail pages (#2160) 2026-05-11 16:27:26 -07:00
Patrick Erichsen 3b2d3dcc7a fix: expand skill summary clamp (#2158) 2026-05-11 15:45:13 -07:00
Patrick Erichsen c1257a31fc fix: polish dashboard artifact cards (#2157) 2026-05-11 15:41:01 -07:00
Patrick Erichsen 404a1359df fix: clamp skill detail summaries (#2156) 2026-05-11 15:03:56 -07:00
Patrick Erichsen 4eb1b97980 fix: refresh skill star state (#2155) 2026-05-11 15:00:24 -07:00
Patrick Erichsen dd7bc17fd3 fix: prevent skill tabs from scrolling (#2154) 2026-05-11 14:59:02 -07:00
Patrick Erichsen 008af4dcf8 fix: show downloads as primary adoption metric (#2153) 2026-05-11 14:37:49 -07:00
Patrick Erichsen ea0f8ba64a test: drop removed rescan e2e probes (#2152) 2026-05-11 14:36:01 -07:00
Patrick Erichsen 39695af5e3 fix: refresh detail page loading skeletons (#2150) 2026-05-11 14:35:43 -07:00
Patrick Erichsen c3d120756e fix: show owner names when handles are hidden (#2151) 2026-05-11 14:23:22 -07:00
Patrick Erichsen b67d7cc619 chore: prepare clawhub cli 0.14.0 (#2149) 2026-05-11 14:20:06 -07:00
Patrick ErichsenBunsDevcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c51cfe2459 Add publisher notes and unify ClawScan review pages (#2111)
* feat: store clawscan notes on artifact versions

* feat: include clawscan notes in evaluation

* feat: unify ClawScan report layout for plugins and skills

* feat: render clawscan notes in publish and security UI

* chore: document local moderation seed fixtures

* fix: remove appeal surfaces

* fix: remove owner-requested rescans

* feat: add publisher note rescan flow

* fix: resolve main rebase fallout

* fix: address review feedback - breadcrumbs, tab guard, merge target, test matcher

Agent-Logs-Url: https://github.com/openclaw/clawhub/sessions/7bfbe5cf-0b8e-44f9-bf0a-e6235f7f3f1d

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>

* fix: address pr ci fallout

* fix: resolve ci after main rebase

* fix: make package VT AI verdicts advisory

* fix: restore skill sidebar actions

* fix: resolve clawscan ui and ci checks

* fix: align security settings access and pending audits

* fix: restore skill version tabs

* fix: show publisher names in sidebars

* fix: align plugin install command styling

* fix: clarify virustotal audit copy

* fix: polish security summaries

* test: align security UI expectations

* docs: document clawscan note workflow

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-05-11 14:14:12 -07:00
Patrick Erichsen 64da959704 Auto-start services for Codex worktrees (#2147)
* feat: auto-start worktree services

* fix: address worktree autostart ci
2026-05-11 12:56:04 -07:00
Patrick Erichsen 5d3de37ff0 feat: add local dev persona FAB (#2146)
* feat: add local dev persona fab

* fix: time out stalled dev persona auth

* fix: restrict dev persona auth to local deployments

* chore: refresh static check dependencies
2026-05-11 12:43:22 -07:00
Patrick Erichsen 8ed8481380 feat: add local clawscan dry run script (#2143)
* feat: add local clawscan dry run script

* chore: add local clawscan script
2026-05-11 09:10:12 -07:00
Peter Steinberger bb6c6c1b38 fix: harden permission denial responses 2026-05-11 15:50:14 +01:00
Peter Steinberger 6f619eabc0 docs: prepare 0.13.0 changelog 2026-05-11 15:18:36 +01:00
Peter Steinberger bac981a193 fix: allow direct skill transfer back to personal publisher 2026-05-11 14:53:33 +01:00
Peter Steinberger 3d021d3c5d fix: send skill transfer payloads as JSON objects 2026-05-11 14:45:30 +01:00
Peter Steinberger a873ffbf53 fix: keep package digests schema-safe 2026-05-11 14:33:53 +01:00
Peter Steinberger 8287c494b2 fix: align publisher lifecycle permissions 2026-05-11 14:20:37 +01:00
Vyctor H. Brzezowski 8a87890c6a feat(web): redesign settings experience
Redesign Settings into focused account, organization, API token, and account deletion views.

Follow-up verification:
- restored the Separator primitive usage so the Radix dependency remains active and static checks pass
- gated the organization member query to the organizations view with a selected org
- added focused settings coverage for default account rendering, organizations navigation/member loading, and legacy hash migration

Validation:
- bun install --frozen-lockfile
- bun run test -- src/routes/-settings.test.tsx src/__tests__/header.test.tsx
- bun run test:ui-contract
- bun run ci:static
- bunx tsc --noEmit
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- VITE_CONVEX_URL=https://example.invalid bun run build
- GitHub repo-owned PR checks passed on verified signed head 7abd808fd4

Vercel fork authorization remained a non-code failure; authenticated local visual proof was blocked by missing GitHub login credentials, while signed-out settings route dev QA rendered without framework overlay.
2026-05-11 08:05:34 -05:00
Luke ff53a37353 fix: bound skill full-text search fanout 2026-05-11 20:47:58 +10:00
Luke b79bfc469e fix: require preinstall package verification in skill prompt
* fix: require preinstall package verification in skill prompt

* fix: include install safety checks in all skill prompts
2026-05-11 20:41:08 +10:00
Momo 190ce3769f fix(search): recall non-first-token matches via full-text indexes (#2140)
Merged via squash.

Prepared head SHA: b597454bc2
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-11 18:36:37 +08:00
Peter Steinberger 75e04d2c91 fix: guard public skill list cursors 2026-05-11 09:38:02 +01:00
Peter Steinberger 3173ecd629 fix: treat VirusTotal AI verdicts as advisory 2026-05-11 08:43:57 +01:00
Peter Steinberger bcc8fa5b71 fix: keep aggregate suspicious rows hidden 2026-05-11 02:31:31 +01:00
Peter Steinberger 5d0264daa7 fix: split ClawScan review from suspicious 2026-05-11 01:28:42 +01:00
Peter Steinberger 2dcaf25d23 chore: add ClawScan inventory counter 2026-05-10 16:39:03 +01:00
Peter Steinberger 3d934a5f28 fix: reduce ClawScan false positives 2026-05-10 16:29:02 +01:00
Peter Steinberger 92f5e68764 chore: add forced skill moderation backfill 2026-05-10 10:04:54 +01:00
Peter Steinberger c2c01300a7 fix: make static suspicious findings advisory 2026-05-10 09:57:58 +01:00
Peter Steinberger 3b80e3f67d fix: reduce ClawScan false positives 2026-05-10 06:50:54 +01:00
Peter Steinberger 003d243ccc fix: allow staff security rescans 2026-05-10 05:23:41 +01:00
Peter Steinberger 3a6ab49dc3 fix: include package env metadata in ClawScan 2026-05-10 02:50:25 +01:00
Peter Steinberger ec4fa3b76d test: fix user badge fixtures 2026-05-10 02:29:30 +01:00
Peter Steinberger 4ce383e822 fix: accept PowerShell skill files 2026-05-10 02:25:01 +01:00
Peter Steinberger 383230582c test: align catalog profile e2e assertions 2026-05-10 02:10:19 +01:00
Peter Steinberger 49d77c040e fix: link owner badges to publisher profiles 2026-05-10 02:05:44 +01:00
Peter Steinberger edba7e1b12 fix: stabilize publishers directory and e2e flows 2026-05-10 02:01:21 +01:00
Peter Steinberger dccb030dd6 fix: count package install stats 2026-05-10 00:47:45 +01:00
Vyctor H. Brzezowski 77692d1249 fix: avoid eager publisher page hydration (#2129) 2026-05-09 19:43:50 -04:00
Vyctor H. Brzezowski 91d962e8b7 fix: show suspicious filter conditionally (#2121) 2026-05-09 19:43:18 -04:00
Peter Steinberger 4f0abcdac1 fix: keep undici on node 20 compatible major 2026-05-10 00:24:46 +01:00
Peter Steinberger bc1dab4b08 chore: update dependencies 2026-05-10 00:21:21 +01:00
Peter Steinberger 4cfc137f26 fix(web): surface publish validation inline 2026-05-09 23:39:13 +01:00
Vyctor H. Brzezowski 31def36bbd fix(web): constrain page content containers
Constrain settings, profile content, skill detail, and plugin detail pages to the header content width while preserving profile hero bleed.\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 18:35:28 -04:00
Vyctor H. Brzezowski 8a6efc236a fix(web): align header avatar controls
Align signed-in header avatar controls across desktop and mobile so the menu trigger keeps consistent sizing, truncation, and dropdown styling.\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 18:35:25 -04:00
Vyctor Huggo Przozwski 38ba9768d3 test: isolate site helper env
Test-only isolation for site helper env handling.\n\nVerified locally with bun run ci:pr, focused polluted-env site helper test, and Playwright smoke.
2026-05-09 10:59:29 -04:00
Peter Steinberger 40627f0796 fix(web): bound publisher directory counts 2026-05-09 14:53:48 +01:00
Vyctor Huggo PrzozwskiandPeter Steinberger 8bd7be9d04 feat(web): add publishers discovery and profiles
Add the publishers discovery/profile surface and harden the landing fixups for existing publisher aggregate rows and scoped plugin links.

Co-authored-by: Vyctor Huggo Przozwski <krzyszchweski@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.com>
2026-05-09 09:36:21 -04:00
Peter Steinberger da20972a04 docs: update changelog and CLI readmes 2026-05-09 13:49:33 +01:00
Peter Steinberger 227cce18a0 feat: complete CLI device login flow
Complete CLI device login with Convex-backed device-code endpoints, the web approval page, API URL discovery, endpoint rate limiting, and approval state hardening.\n\nTests:\n- bunx vitest run convex/httpApi.handlers.test.ts packages/clawhub/src/deviceAuth.test.ts\n- bun run --cwd packages/clawhub test:src -- src/deviceAuth.test.ts\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- bun run ci:static\n- bun run ci:types-build\n- bunx convex codegen\n\nCo-authored-by: Lumen <openclaw@openclaw-secure.local>
2026-05-09 08:42:04 -04:00
Peter Steinberger 0b56c68643 feat: add moderation hold recovery
Add admin-only moderation hold recovery with audit logs, docs, and safe skill restoration that preserves independently hidden scanner/manual moderation reasons.\n\nTests:\n- bunx vitest run convex/users.test.ts convex/skills.moderationHold.test.ts --testNamePattern "liftModerationHold|reserveHandleInternal|moderation holds"\n- bunx tsc --noEmit && bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- bun run ci:static\n- bunx convex codegen\n\nCo-authored-by: Justin Sparks <openclaw@openclaw-secure.local>
2026-05-09 08:40:40 -04:00
Deepak Jain ab3ede496d feat(cli): add per-skill pinning
Add per-skill CLI pinning and harden install semantics so pinned skills cannot be overwritten by forced installs.\n\nTests:\n- bunx vitest run packages/clawhub/src/cli/commands/skills.test.ts packages/clawhub/src/skills.test.ts packages/schema/src/schemas.test.ts\n- bun run --cwd packages/clawhub verify:build\n- bun run ci:static\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n\nCo-authored-by: deepujain <deepujain@users.noreply.github.com>
2026-05-09 08:35:41 -04:00
openclaw-barnacle[bot] ada5556752 chore: update Convex AI files
Update Convex-generated AI guidance files and apply repository formatting.\n\nTests:\n- bun run ci:static\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
2026-05-09 08:35:32 -04:00
Vyctor Huggo Przozwski f3d9c9b27a fix: rename plugins cards view to grid (#2119) 2026-05-09 07:56:39 -04:00
SylvanXiaoandSylvanXiao ceb25616ab feat: allow owners to edit skill summary (#1411)
Co-authored-by: SylvanXiao <Sylvanxiao@gmail.com>
2026-05-09 07:39:29 -04:00
Deepak Jain cb018bf64c docs: document trademark takedown reports (#1860)
Fixes #1591
2026-05-09 07:33:47 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> db2a14755d build(deps): bump the github-actions group with 2 updates (#2006)
Bumps the github-actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `github/codeql-action` from 4.35.2 to 4.35.3
- [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/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...e46ed2cbd01164d986452f91f178727624ae40d7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: github/codeql-action
  dependency-version: 4.35.3
  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-05-09 07:33:44 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 12f2cb8513 build(deps-dev): bump the development-minor-and-patch group across 1 directory with 3 updates (#2063)
Bumps the development-minor-and-patch group with 3 updates in the / directory: [jsdom](https://github.com/jsdom/jsdom), [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) and [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint).


Updates `jsdom` from 29.1.0 to 29.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.0...v29.1.1)

Updates `oxfmt` from 0.47.0 to 0.48.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.48.0/npm/oxfmt)

Updates `oxlint` from 1.62.0 to 1.63.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.63.0/npm/oxlint)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-minor-and-patch
- dependency-name: oxfmt
  dependency-version: 0.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-minor-and-patch
- dependency-name: oxlint
  dependency-version: 1.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  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-05-09 07:33:39 -04:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Peter Steinberger
d16a62f552 build(deps): bump the production-minor-and-patch group across 1 directory with 8 updates (#2065)
* build(deps): bump the production-minor-and-patch group across 1 directory with 8 updates

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

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.168.26` | `1.169.2` |
| [@tanstack/react-start](https://github.com/TanStack/router/tree/HEAD/packages/react-start) | `1.167.52` | `1.167.65` |
| [convex](https://github.com/get-convex/convex-backend/tree/HEAD/npm-packages/convex) | `1.36.1` | `1.37.0` |
| [convex-helpers](https://github.com/get-convex/convex-helpers/tree/HEAD/packages/convex-helpers) | `0.1.115` | `0.1.116` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.5` | `19.2.6` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.5` | `19.2.6` |
| [yaml](https://github.com/eemeli/yaml) | `2.8.3` | `2.8.4` |
| [zod](https://github.com/colinhacks/zod) | `4.4.1` | `4.4.3` |



Updates `@tanstack/react-router` from 1.168.26 to 1.169.2
- [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.169.2/packages/react-router)

Updates `@tanstack/react-start` from 1.167.52 to 1.167.65
- [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.167.65/packages/react-start)

Updates `convex` from 1.36.1 to 1.37.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 `convex-helpers` from 0.1.115 to 0.1.116
- [Changelog](https://github.com/get-convex/convex-helpers/blob/main/packages/convex-helpers/CHANGELOG.md)
- [Commits](https://github.com/get-convex/convex-helpers/commits/npm/0.1.116/packages/convex-helpers)

Updates `react` from 19.2.5 to 19.2.6
- [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.6/packages/react)

Updates `react-dom` from 19.2.5 to 19.2.6
- [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.6/packages/react-dom)

Updates `yaml` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.3...v2.8.4)

Updates `zod` from 4.4.1 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.1...v4.4.3)

---
updated-dependencies:
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.169.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: "@tanstack/react-start"
  dependency-version: 1.167.65
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: convex
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-minor-and-patch
- dependency-name: convex-helpers
  dependency-version: 0.1.116
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: react
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: yaml
  dependency-version: 2.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-minor-and-patch
...

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

* fix: keep zod pinned for TanStack build

---------

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-05-09 07:24:12 -04:00
Vyctor Huggo Przozwski 6b2efcb7b8 fix(web): polish browse listing surfaces
Polish ClawHub browse and listing surfaces across skills, plugins, and search: clearer search controls, visible safety filtering, plugin card view parity, and more consistent card metadata treatment. Kept #2090's Grid terminology for skills while preserving plugin card view behavior.\n\nTests:\n- bun run test -- src/__tests__/search-route.test.tsx src/__tests__/packages-route.test.tsx src/__tests__/skills-index.test.tsx src/__tests__/header.test.tsx src/__tests__/ui-design-contract.test.ts\n- bun run format:check\n- bun run lint\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- git diff --check origin/main...HEAD\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 06:46:14 -04:00
Vyctor Huggo Przozwski 05122ea799 fix(dev): keep local seed resets deterministic
Clean stale seed lookup/badge rows during repeated local Convex dev seed resets, and delete package fixtures in an order that avoids the package-release trigger fallback query limit.\n\nTests:\n- bun run test -- convex/devSeed.rescanFixtures.test.ts\n- bun run format:check\n- bun run lint\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- git diff --check origin/main...HEAD\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 06:42:27 -04:00
Vyctor Huggo Przozwski b65d05ba9f fix(dev): align local Convex site URL setup
Document local Convex HTTP route usage through the site proxy port and make setup-worktree reject local site URL misconfigurations that point browser/auth routes at the function port.\n\nTests:\n- bun run test -- scripts/setup-worktree.test.ts\n- bun run format:check\n- bun run lint\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- git diff --check origin/main...HEAD\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 06:39:20 -04:00
José Antonio Mijares 7fe9451bda fix(clawsweeper): remove PAT dispatch fallback
Require the ClawSweeper GitHub App dispatch token path and remove the PAT fallback from dispatch credentials. Missing-app-secret deployments still exit through the existing no-token notice.\n\nTests:\n- rg -n "OPENCLAW_GH_TOKEN|steps\.token\.outputs\.token" .github/workflows/clawsweeper-dispatch.yml .github/workflows\n- bun run format:check\n- bun run lint\n- git diff --check origin/main...HEAD\n\nCo-authored-by: José Antonio Mijares <joseamijares@hotmail.com>
2026-05-09 06:39:17 -04:00
Vyctor Huggo Przozwski 4c8f9d98fb fix(web): rename skills cards view to grid
Rename the /skills alternate browse view from Cards to Grid while preserving legacy view=cards URLs as a compatible alias.\n\nTests:\n- VITE_CONVEX_URL=https://example.invalid bun run test -- src/__tests__/skills-index.test.tsx src/__tests__/skills-toolbar.test.tsx\n- bun run format:check\n- bun run lint\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n\nCo-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
2026-05-09 06:33:58 -04:00
Jason dde3a019bd fix(web): restore dashboard skill metrics
Restore downloads, all-time installs, stars, and version metrics on owned skill rows in the dashboard. Also makes dropdown menu items use the expected pointer cursor.\n\nTests:\n- bunx vitest run src/routes/-dashboard.test.tsx --reporter verbose\n- bun run format:check\n- bun run lint\n- bunx tsc --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- bun run test\n\nCo-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-09 05:38:00 -04:00
Momo df7e0f77a6 fix(search): disclose suspicious results hidden on unified search (#2079)
Merged via squash.

Prepared head SHA: 1477ca7120
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-09 17:19:06 +08:00
Jason b6875e60f6 fix(skills): repair merge ownership and slug reservations
Fix publisher-owned skill merge authorization, bound historical slug redirects, protect reserved namespaces, and add expiring owner-unpublish slug reservations.

Maintainer follow-up: require current owner-hide provenance before honoring an unpublished slug reservation, clear stale reservation fields on non-owner hide paths, and add regressions for stale moderation-owned reservations.

Verification:
- bunx vitest run convex/skills.rateLimit.test.ts convex/skills.slugAvailability.test.ts convex/skills.undeleteGate.test.ts --reporter verbose
- bun run format:check
- bun run lint
- bunx tsc --noEmit
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bunx convex codegen
- bun run test
- bun run --cwd packages/clawhub test:src -- src/cli/commands/delete.test.ts --reporter verbose
- git diff --check
- GitHub CI: static, packages, types-build, unit, e2e-http, playwright-smoke, CodeQL, secret scanning all passed on 2d0564d1

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-09 04:55:32 -04:00
Momoandmomothemage 5b63d5df60 fix: harden skill owner migration
Harden skill owner migration across UI, CLI, API, stats, backups, and docs.

Co-authored-by: momothemage <niuzhengnan@163.com>
2026-05-08 12:23:05 -04:00
Momo 38c2134590 fix(skills): block owner from undeleting moderator-hidden skills (#2078)
Merged via squash.

Prepared head SHA: a3ff0752a9
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-08 11:56:08 +08:00
Patrick Erichsen f14d70759d fix: use canonical publishing docs path 2026-05-07 19:43:05 -07:00
Patrick Erichsen a292a60a36 fix: use short publishing docs link (#2098) 2026-05-07 19:32:50 -07:00
Patrick Erichsen 4d3b7dedba feat: add docs nav link (#2097) 2026-05-07 19:16:04 -07:00
Patrick Erichsen 1aab139775 feat: enforce scoped plugin ownership (#2072) 2026-05-07 19:14:24 -07:00
Patrick Erichsen 88756d5997 docs: mention package transfer in publishing FAQ (#2096) 2026-05-07 19:13:17 -07:00
Patrick Erichsen 8c86d6f570 Fix plugin publish ownership visibility (#2073)
* fix: clarify plugin publish ownership state

* test: tolerate publish route migration in prod smoke

* fix: reserve publish route collisions

* fix: preflight package scope owner mismatches in CLI

* fix: keep package scope validation server-side

* docs: explain ClawHub publishing flow

* fix: include publishing docs link in scope errors

* fix: centralize docs links

* fix: build docs links with URL

* fix: shorten package scope docs hint
2026-05-07 19:03:40 -07:00
Patrick Erichsen 86898837fb docs: split ClawHub public docs from specs (#2095)
* docs: split clawhub docs source

* docs: make clawhub docs product-facing

* docs: refine public clawhub docs routes
2026-05-07 18:54:47 -07:00
Patrick Erichsen d7c774996e docs: add RFC community review process (#2092)
* docs: add RFC community review process

* chore: keep slug validator options internal
2026-05-07 14:57:55 -07:00
Vincent Koc b8e5486f63 chore(ci): harden security ownership and workflow permissions (#2045)
* chore(security): expand protected automation owners

* chore(ci): default workflows to no token permissions
2026-05-07 01:13:54 -07:00
Momo 571a85f539 fix(slug): enforce length, pattern, and reserved-word rules on skill & soul slugs (#1879)
Merged via squash.

Prepared head SHA: d93026fd4b
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-05-07 14:37:58 +08:00
Patrick Erichsen 0f938fbabd feat: add entity-scoped moderator commands (#2066) 2026-05-06 19:54:07 -07:00
Peter Steinberger 5e7797df72 fix: explain blocked and unauthorized API states 2026-05-07 03:47:08 +01:00
Patrick Erichsen 0b058d10bf fix: repair clawhub mod installer 2026-05-06 19:09:33 -07:00
Patrick Erichsen 0749f16499 feat: split moderator commands into private cli 2026-05-06 19:05:07 -07:00
Patrick Erichsen 52c3b649e0 Merge pull request #2062 from openclaw/pe/maintenance-cleanup
chore: remove stale maintenance helpers
2026-05-06 17:34:07 -07:00
Patrick Erichsen 03328f7523 chore: remove stale maintenance helpers 2026-05-06 17:32:24 -07:00
Peter Steinberger 5019f2a78a fix: surface blocked account auth state 2026-05-07 01:30:58 +01:00
Patrick Erichsen e4a68d2a76 Merge pull request #2061 from openclaw/pe/security-review-grey-badge
Make security review badges neutral
2026-05-06 17:27:37 -07:00
Patrick Erichsen bde371360d fix: make security review badges neutral 2026-05-06 17:21:18 -07:00
Patrick Erichsen e62935762b Merge pull request #2055 from openclaw/pe/artifact-moderation-cases
Add skill and package artifact moderation cases
2026-05-06 17:10:19 -07:00
Patrick Erichsen ac08267403 test: update moderator summary expectation 2026-05-06 17:04:48 -07:00
Patrick Erichsen 9fe7532e27 feat: add report status backfill 2026-05-06 17:01:48 -07:00
Patrick Erichsen 3618d296af feat: summarize moderation CLI actions 2026-05-06 17:01:14 -07:00
Patrick Erichsen cab18339e6 docs: align moderation wording with moderator role 2026-05-06 16:42:15 -07:00
Patrick Erichsen 5b1cfb4574 fix: rename report resolution status to confirmed 2026-05-06 16:39:55 -07:00
Patrick Erichsen 2bd6ed9198 test: add package artifact moderation lifecycle e2e 2026-05-06 16:26:53 -07:00
Peter Steinberger 4f4d7dd563 docs: document slug routing contract 2026-05-07 00:24:20 +01:00
Patrick Erichsen d5d58a9dbc Merge pull request #2058 from openclaw/pe/pr-template-screenshots
docs: add pull request template
2026-05-06 16:19:05 -07:00
Patrick Erichsen 7a2733947e Merge pull request #2059 from openclaw/pe/friendly-404-page
Add a friendlier 404 page
2026-05-06 16:15:05 -07:00
Patrick Erichsen 4592e66879 feat: enforce artifact moderation state transitions 2026-05-06 16:05:23 -07:00
Patrick Erichsen 06500ea4ca feat: add friendly 404 page 2026-05-06 16:01:24 -07:00
Patrick Erichsen d89e2ce1a1 docs: add pull request template 2026-05-06 15:35:56 -07:00
Patrick Erichsen c4d1fcdbc6 feat: add skill artifact moderation cases 2026-05-06 15:22:54 -07:00
Peter Steinberger e8deec13a2 fix: allow docs host auth callback form posts 2026-05-06 22:16:00 +01:00
Peter Steinberger 678935d014 fix: preserve docs auth return host 2026-05-06 22:10:47 +01:00
Patrick Erichsen bb592363a7 Merge pull request #2054 from openclaw/pe/worktree-setup-bootstrap
fix: bootstrap ClawHub worktree setup
2026-05-06 13:48:25 -07:00
Patrick Erichsen e3b59fce38 fix: align route tests with scoped plugin routes 2026-05-06 13:43:31 -07:00
Patrick Erichsen 1da5c53ce9 fix: bootstrap clawhub worktree setup 2026-05-06 13:37:09 -07:00
Patrick Erichsen bfb16ceddf Merge pull request #2051 from vyctorbrzezowski/contrib/dev-worktree-seed-readiness
fix: wait for Convex functions before seeding worktrees
2026-05-06 13:25:51 -07:00
Patrick Erichsen f7fbd6bde4 Merge pull request #2052 from vyctorbrzezowski/contrib/browse-sidebar-sticky-offset
fix: keep browse sidebar below sticky header
2026-05-06 13:15:59 -07:00
Peter Steinberger bec9362361 feat: add ClawHub docs auth broker 2026-05-06 21:08:39 +01:00
vyctorbrzezowski c4688b3526 fix: keep browse sidebar below sticky header 2026-05-06 16:49:26 -03:00
Peter Steinberger 8d5eb14919 fix: keep scoped plugin URLs readable 2026-05-06 20:12:07 +01:00
Peter Steinberger 5aa4d13560 fix: route official extension slug aliases 2026-05-06 20:01:49 +01:00
vyctorbrzezowski a046cff693 fix: wait for Convex functions before seeding worktrees 2026-05-06 15:23:03 -03:00
Peter Steinberger 57308e6059 docs(changelog): add 0.12 release notes 2026-05-06 07:17:31 +01:00
Peter Steinberger 32011a1f9a test(e2e): open mobile nav in smoke 2026-05-06 07:09:11 +01:00
Patrick Erichsen b735a529c2 Merge pull request #2041 from openclaw/pe/worktree-env-fallback
fix: discover shared env for worktree dev
2026-05-05 23:03:45 -07:00
Peter Steinberger 6925ec761c fix(cli): support org-owned skill publishes 2026-05-06 07:00:40 +01:00
Patrick Erichsen 521fd2796a fix: discover shared env for worktree dev 2026-05-05 22:57:11 -07:00
Peter Steinberger 2b00f0b37e Merge pull request #2040 from openclaw/pe/package-delete-cli-ui
feat: allow package owners to delete plugins
2026-05-06 06:42:28 +01:00
Peter Steinberger 88b6a941ec fix(packages): finish delete flow cleanup 2026-05-06 06:38:36 +01:00
Patrick Erichsen 0c7607bd64 feat: allow package owners to delete plugins 2026-05-05 22:37:19 -07:00
Patrick Erichsen bb6ef2ba44 Merge pull request #2037 from openclaw/pe/fix-package-moderation-queue
fix: normalize package moderation queue timestamps
2026-05-05 21:01:11 -07:00
Patrick Erichsen df61771b7e fix: normalize package moderation queue timestamps 2026-05-05 20:50:08 -07:00
Patrick Erichsen 9605bb3d8e Merge pull request #2036 from openclaw/pe/mobile-friendly-clawhub
Fix mobile layout responsiveness
2026-05-05 20:08:07 -07:00
Patrick Erichsen 33176522da fix: improve mobile layout responsiveness 2026-05-05 19:46:40 -07:00
Patrick Erichsen 102f47174d Merge pull request #2035 from openclaw/pe/codex-worktree-setup
[codex] Add Codex worktree setup
2026-05-05 18:09:08 -07:00
Patrick Erichsen cd9995c676 fix: leave contributing guide unchanged 2026-05-05 18:06:42 -07:00
Patrick Erichsen 6679f36a2f fix: remove hardcoded worktree env path 2026-05-05 18:04:15 -07:00
Patrick Erichsen 19993f93ed fix: start Convex from worktree helper 2026-05-05 17:52:26 -07:00
Patrick Erichsen 9028a7402a fix: avoid Bun ambient type in worktree helper 2026-05-05 17:42:26 -07:00
Patrick Erichsen 0a32b9857d feat: add Codex worktree setup 2026-05-05 17:33:49 -07:00
Patrick Erichsen 0a5b648f78 Merge pull request #2033 from openclaw/pe/cli-privileged-help-visibility
fix: gate privileged cli help by role
2026-05-05 15:25:05 -07:00
Patrick Erichsen ebe77f7f63 fix: gate privileged cli help by role 2026-05-05 14:40:25 -07:00
Vincent Koc caac39ce29 fix(convex): scope rate limit buckets by kind
Scope HTTP rate limit buckets by request kind and raise healthy production limits so plugin downloads are not throttled by unrelated API reads.
2026-05-04 20:25:15 -07:00
Patrick Erichsen d24422a005 Merge pull request #2016 from openclaw/pe/package-lookup-not-found
fix(packages): return not found for invalid lookups
2026-05-04 20:23:00 -07:00
Patrick Erichsen 1c62c5fff0 fix(packages): return not found for invalid lookups 2026-05-04 20:18:46 -07:00
Peter Steinberger 395862fadf ci: use app token for Convex AI update PRs 2026-05-04 09:52:34 +01:00
Peter Steinberger ba7a108af1 fix: keep oxlint underscore rule disabled 2026-05-04 08:08:59 +01:00
Peter Steinberger 6c3f911e8e test: fix http api rate limit mock 2026-05-04 08:06:04 +01:00
Peter Steinberger facf20ceb6 fix: raise admin api rate limits 2026-05-04 07:56:36 +01:00
Peter Steinberger 0690891781 fix: raise trusted publish rate limit 2026-05-04 06:35:30 +01:00
Peter Steinberger 0df30649ca fix: validate clawpack runtime entries against extracted files 2026-05-04 06:06:30 +01:00
Peter Steinberger bbdde7fd53 fix: keep package dry-run metadata-only 2026-05-03 23:23:36 +01:00
Peter Steinberger 3d6f3b49a5 fix: reject code plugins without runtime output 2026-05-03 23:19:00 +01:00
Peter Steinberger 0b842636dc fix: allow admin plugin release publishes 2026-05-03 22:59:58 +01:00
Peter Steinberger 2d2d791e9f fix: infer package owner from scoped names 2026-05-03 22:53:01 +01:00
Peter Steinberger 96e3d7ebd4 fix: raise authenticated write rate limit 2026-05-03 22:44:21 +01:00
Peter Steinberger 768a50149e fix: support monorepo package publishes 2026-05-03 21:04:34 +01:00
Vincent Koc 199e6a0cdf fix(api): expose legacy zip artifact aliases
Expose legacy ZIP resolver compatibility aliases without confusing publish-time content hashes for downloaded archive integrity.
2026-05-03 10:34:37 -07:00
Vincent Koc 59fc54ff64 fix(web): canonicalize scoped plugin paths 2026-05-03 09:35:02 -07:00
Vincent Koc 343781a668 fix(api): decode scoped package paths 2026-05-03 09:26:13 -07:00
Vincent Koc 62b10f829d fix(packages): use single-window search fallback 2026-05-03 02:20:53 -07:00
Vincent Koc eb3113c1f3 fix(packages): rebuild search queries per page 2026-05-03 02:09:54 -07:00
Vincent Koc 887e81eb85 fix(api): return lean skill list payloads 2026-05-03 02:04:16 -07:00
Vincent Koc d6cfc891f0 fix(search): reduce lexical fallback scan budget 2026-05-03 02:02:19 -07:00
Vincent Koc cf5778d7d5 fix(api): route package search through digest index 2026-05-03 02:01:20 -07:00
Vincent Koc e76b72cdb1 fix(search): cap vector hydration window 2026-05-03 01:13:31 -07:00
Vincent Koc f53b49041a fix(api): avoid redundant latest tag version reads 2026-05-03 01:11:43 -07:00
Vincent Koc 21abd07672 fix(search): bound lexical fallback scans 2026-05-03 01:09:44 -07:00
Vincent Koc 6085ee4852 fix(convex): raise download rate limit 2026-05-03 00:24:42 -07:00
Vincent Koc 05653453ea fix(convex): reduce download and token write contention 2026-05-03 00:06:59 -07:00
Vincent Koc 86f8aa88af test(convex): update leaderboard page size expectation 2026-05-02 23:45:27 -07:00
Vincent Koc 2d42c3d57a fix(convex): reduce hot rate-limit and catalog reads 2026-05-02 23:42:15 -07:00
Vincent Koc cf5a6f6e8b fix(scanner): avoid generic pay purchase tags 2026-05-02 23:13:59 -07:00
Vincent Koc 46354c9967 fix(security): flag python file upload exfiltration 2026-05-02 22:50:57 -07:00
Vincent Koc 063ee210a7 fix(github): keep scanner appeal issues open 2026-05-02 22:50:09 -07:00
Vincent Koc f84c894e4e fix(ui): restore skill downloads and search paging 2026-05-02 22:46:30 -07:00
Vincent Koc f8141bc517 fix(convex): gate large index deletion 2026-05-02 19:07:44 -07:00
Vincent Koc ca4899078d fix(convex): retain built rate limit index 2026-05-02 18:37:10 -07:00
Vincent Koc 51d4633df0 fix(convex): avoid rate limit index backfill 2026-05-02 18:19:08 -07:00
Vincent Koc 6139dcd052 fix(convex): drop unused package stat index 2026-05-02 17:20:27 -07:00
Vincent Koc ab48c07b98 test(rate-limits): expect consumed shard quota 2026-05-02 16:19:00 -07:00
Vincent Koc 0a49b75e2f fix(convex): reduce hot stat write contention 2026-05-02 16:16:06 -07:00
Vincent Koc 5e9c61a185 fix(convex): bound skill health reads 2026-05-02 16:16:02 -07:00
Vincent Koc 8234c92dcf fix(packages): keep mirror artifact URLs on public host 2026-05-02 15:54:57 -07:00
Vincent Koc 9edff6fd38 build(schema): update package response dist 2026-05-02 15:45:05 -07:00
Vincent Koc 2ebcdd4ed0 test(packages): include required plugin manifests 2026-05-02 15:38:08 -07:00
Vincent Koc f5183cae9b fix(security): flag confirmation bypasses 2026-05-02 15:33:49 -07:00
Vincent Koc 4196789c6d chore(cli): bump to 0.12.2 2026-05-02 14:43:47 -07:00
Vincent Koc 4c69f2af2e fix(schema): allow nullable package sha 2026-05-02 14:42:57 -07:00
Vincent Koc 4c52dc23c1 fix(cli): allow legacy package downloads 2026-05-02 14:39:04 -07:00
Vincent Koc 8916167505 style(api): format scoped route changes 2026-05-02 14:29:08 -07:00
Vincent Koc f4f2da7fe7 fix(api): resolve scoped package routes 2026-05-02 14:28:27 -07:00
Vincent Koc 01529aaaf1 fix(cli): publish code plugins as clawpacks 2026-05-02 14:27:10 -07:00
Vincent Koc 05efb81669 chore(cli): bump to 0.12.1 2026-05-02 13:47:48 -07:00
Vincent Koc ca0d0bd1bd docs(security): clarify clawpack scan scope 2026-05-02 13:47:05 -07:00
Vincent Koc f82e07fd3a ci: add clean production deploy tags 2026-05-02 13:29:59 -07:00
Vincent Koc f2a61c9d94 fix(packages): scan clawpack artifacts with virustotal 2026-05-02 13:09:03 -07:00
Vincent Koc 3c09df3b77 ci: tag production frontend deploys 2026-05-02 13:06:55 -07:00
Vincent Koc 6d4cf0cfe7 test(packages): avoid unsafe optional chaining 2026-05-02 12:44:28 -07:00
Vincent Koc e4aa4c7459 style: format clawpack rollout changes 2026-05-02 12:43:44 -07:00
Vincent Koc 3aff30b955 fix(plugins): hide staged bundle publish ux 2026-05-02 12:42:59 -07:00
Vincent Koc c9a225aef7 fix(plugins): show clawpack artifact downloads 2026-05-02 12:41:53 -07:00
Vincent Koc 0fe234e68d feat(cli): add clawpack pack command 2026-05-02 12:40:38 -07:00
Vincent Koc 56743ce3d8 fix(packages): store clawpack metadata only 2026-05-02 12:38:57 -07:00
Vincent Koc 1fdfbcd51f fix(packages): cap clawpack tarballs at 120mb 2026-05-02 12:38:11 -07:00
Vincent Koc bf1e112d5a style(packages): format package updates 2026-05-02 11:41:12 -07:00
Vincent Koc 7266f4f927 docs(packages): clarify plugin package metadata 2026-05-02 11:39:43 -07:00
Vincent Koc 77927830f3 fix(api): accept scoped npm packuments 2026-05-02 11:38:39 -07:00
Vincent Koc e599d23f69 fix(packages): use real bundle markers 2026-05-02 11:37:28 -07:00
Vincent Koc 4c8738f1ef fix(clawpack): require plugin manifests 2026-05-02 11:34:51 -07:00
Vincent Koc e01c7a9f31 fix(packages): make host metadata optional 2026-05-02 11:33:38 -07:00
Vincent Koc c9a5b8508d test(packages): satisfy migration lint 2026-05-02 11:03:41 -07:00
Vincent Koc cb320fe2ab docs(packages): document official migrations 2026-05-02 11:02:35 -07:00
Vincent Koc 773df44f17 feat(cli): manage official migrations 2026-05-02 11:02:00 -07:00
Vincent Koc 402ddddbd7 feat(api): manage official migrations 2026-05-02 10:59:56 -07:00
Vincent Koc 238f3f6b14 feat(packages): persist official migrations 2026-05-02 10:58:23 -07:00
Vincent Koc 539bf60e97 chore(schema): build official migration types 2026-05-02 10:57:35 -07:00
Vincent Koc 6527ab6a9f feat(packages): add official migration schema 2026-05-02 10:55:03 -07:00
Vincent Koc 63164eb762 docs(cli): document package migration status 2026-05-02 10:53:54 -07:00
Vincent Koc 669e14b92c feat(cli): show package migration status 2026-05-02 10:53:36 -07:00
Vincent Koc 28da510571 feat(packages): resolve package appeals 2026-05-02 10:50:57 -07:00
Vincent Koc 6e5578ee6d feat(packages): submit package appeals 2026-05-02 10:47:26 -07:00
Vincent Koc 68017740e7 feat(packages): show moderation status 2026-05-02 10:44:37 -07:00
Vincent Koc ff68eeb5d1 feat(packages): triage package reports 2026-05-02 10:40:13 -07:00
Vincent Koc 276760d703 feat(packages): report packages for review 2026-05-02 10:35:28 -07:00
Vincent Koc 1b33c949f1 feat(packages): filter by artifact availability 2026-05-02 10:25:33 -07:00
Vincent Koc 417537a13f feat(packages): list moderation queue 2026-05-02 10:17:09 -07:00
Vincent Koc 6e15ed65e0 feat(cli): filter packages by environment 2026-05-02 10:07:55 -07:00
Vincent Koc 58dcd55076 style(dashboard): format pagination changes 2026-05-02 10:06:37 -07:00
Vincent Koc c9ad1305ff feat(packages): require environment metadata 2026-05-02 10:05:49 -07:00
Vlad Ursul 964fc0fa87 feat(dashboard): add skill pagination
Adds indexed, paginated dashboard skill loading and Load More UI.\n\nMaintainer validation after rebasing onto current main:\n- bun run test -- convex/skills.dashboard.test.ts src/routes/-dashboard.test.tsx\n- bun run test -- convex/skills.dashboard.test.ts convex/skills.list.test.ts\n- bunx tsc -p tsconfig.json --noEmit\n- bunx tsc -p packages/schema/tsconfig.json --noEmit\n- bunx tsc -p packages/clawhub/tsconfig.json --noEmit\n- bun run lint\n- bun run build\n\nNote: full bun run test currently has unrelated package publish route failures on current main; PR-focused tests and build are clean. Vercel PR preview remains blocked by fork deployment authorization.
2026-05-02 12:01:07 -05:00
Vincent Koc bc234c7d89 feat(packages): report openclaw readiness 2026-05-02 09:57:12 -07:00
Vincent Koc 87a286fe1f feat(packages): backfill package artifact kinds 2026-05-02 09:52:37 -07:00
Vincent Koc 00970bbee9 feat(packages): require code plugin host targets 2026-05-02 09:40:00 -07:00
Val Alexander 7979ff4249 chore: update ClawHub UI code owner
Update frontend/UI CODEOWNERS entries to use @BunsDev while preserving secops review ownership.
2026-05-02 11:38:28 -05:00
Val Alexander f3c4cbb99a feat: clarify about page policy patterns
Summary:
- Refresh the About page Recent Patterns section to explicitly allow specific maintainer-approved patterns.
- Replace the top-nav git icon with the GitHub mark for GitHub sign-in.
- Clean up ClawPack internal type exports and make Convex integrity hashing compatible with CI WebCrypto.

Validation:
- bun run format:check
- bun run lint
- bun run ci:static
- bun run ci:unit
- bunx tsc --noEmit
- bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bun run test -- convex/lib/clawpack.test.ts
- VITE_CONVEX_URL=https://example.invalid bun run build
- GitHub checks for PR #1980 all passed
2026-05-02 11:33:49 -05:00
Vincent Koc bed2d4b1b0 feat(packages): moderate package releases 2026-05-02 09:28:40 -07:00
Vincent Koc 81759fd857 chore(convex): refresh generated api 2026-05-02 09:28:25 -07:00
Vincent Koc f3cf886ce5 feat(cli): download and verify package artifacts 2026-05-02 09:10:17 -07:00
Vincent Koc 2176dbf4c2 fix(packages): satisfy clawpack lint gates 2026-05-02 08:52:06 -07:00
Vincent Koc 80e8b599f9 test(cli): cover clawpack publish upload 2026-05-02 08:49:56 -07:00
Vincent Koc 7d636b771b test(api): cover clawpack package routes 2026-05-02 08:47:32 -07:00
Vincent Koc e94cc91b8e docs(packages): document clawpack artifact paths 2026-05-02 08:46:17 -07:00
Vincent Koc 0774d0fe92 feat(cli): publish uploaded clawpacks 2026-05-02 08:44:41 -07:00
Vincent Koc 1261062585 feat(api): serve clawpack mirror artifacts 2026-05-02 08:42:38 -07:00
Vincent Koc 88d0cc7888 feat(packages): accept clawpack uploads 2026-05-02 08:37:45 -07:00
Vincent Koc 87848016ff feat(packages): widen artifact schema 2026-05-02 08:34:47 -07:00
Vincent Koc 86e58d6031 feat(packages): add clawpack parser 2026-05-02 08:33:41 -07:00
Peter Steinberger 48e66714ac fix: add package identity repair admin 2026-05-02 06:47:24 +01:00
Peter Steinberger 0c705e159f fix: allow JSON Schema manifests in package publish 2026-05-02 05:41:51 +01:00
Peter Steinberger 5409df4123 fix: keep beta plugin packages off latest 2026-05-02 05:15:50 +01:00
Peter Steinberger ac15e5adea fix: add package owner transfer repair 2026-05-02 04:56:46 +01:00
Peter Steinberger 880d9e0572 feat: reserve OpenClaw plugin package names 2026-05-01 22:51:03 +01:00
Patrick Erichsen 63dfbd8876 Merge pull request #1967 from openclaw/pe/clawscan
Clarify ClawScan artifact prompt boundaries
2026-05-01 06:32:59 -07:00
Patrick Erichsen 4a7b7b7024 Update securityPrompt.ts 2026-05-01 06:32:07 -07:00
Patrick Erichsen 34e26093ab Update securityPrompt.ts 2026-05-01 06:31:25 -07:00
Patrick Erichsen 601d29b0e9 Update securityPrompt.ts 2026-05-01 06:30:53 -07:00
Patrick Erichsen bff959c8f0 fix: rely on JSON artifact neutralization 2026-05-01 06:28:29 -07:00
Patrick Erichsen 34a2c657b6 Merge remote-tracking branch 'origin/main' into pe/clawscan
# Conflicts:
#	convex/lib/securityPrompt.ts
2026-05-01 06:20:57 -07:00
Patrick Erichsen fc6555fa1c Update securityPrompt.ts 2026-05-01 06:11:53 -07:00
Patrick Erichsen e7ad7c628d fix: wrap ClawScan skill artifacts in prompt boundary 2026-05-01 06:07:39 -07:00
Vincent Koc 7c61d55833 ci: expand pr validation coverage
Split PR validation into explicit static, unit, package, type/build, HTTP e2e, and browser-smoke gates. Add local ci:* scripts and document the required status checks.
2026-05-01 02:20:40 -07:00
Vincent Koc 89becd866a Revert "feat: add health probes"
This reverts commit bb945c740e.
2026-04-30 23:56:11 -07:00
Vincent Koc eada4d5dcb Revert "fix: keep probe helper types private"
This reverts commit 7f15dcc225.
2026-04-30 23:56:11 -07:00
Vincent Koc 7f15dcc225 fix: keep probe helper types private 2026-04-30 23:46:39 -07:00
Vincent Koc bb945c740e feat: add health probes 2026-04-30 23:44:31 -07:00
Vincent Koc dfc0d540d8 chore(ci): enforce formatting 2026-04-30 23:39:28 -07:00
Vincent Koc cd37acadbb fix(security): add skill redaction hide mutation 2026-04-30 23:33:50 -07:00
Vincent Koc c9fe6db34d fix(search): index skill first-token recall 2026-04-30 23:27:37 -07:00
Vincent Koc 5fe321a43f fix(ci): treat cli schema as deadcode entry 2026-04-30 23:22:17 -07:00
Vincent Koc 9e15c5a6fa chore(ci): add deadcode gate 2026-04-30 23:17:07 -07:00
Vincent Koc 026b911d58 chore(search): allow manual digest backfill 2026-04-30 23:13:56 -07:00
Vincent Koc 08326f7718 chore(search): expose digest backfill cursor 2026-04-30 23:08:22 -07:00
Vincent Koc 881514f444 fix(search): add normalized skill prefix recall 2026-04-30 23:01:28 -07:00
Vincent Koc 3f17fd55e5 fix(security): fully strip hidden html comments 2026-04-30 22:39:35 -07:00
Vincent Koc 3f2153e678 fix(security): neutralize llm eval prompt injection 2026-04-30 22:00:05 -07:00
Vincent Koc 9ea3ed896f fix(security): fail closed when vt is unavailable 2026-04-30 18:34:38 -07:00
Vincent Koc 7ea5fc085c fix(ci): skip frontend smoke on backend deploys 2026-04-30 18:32:55 -07:00
Patrick Erichsen 1306ab6640 Merge pull request #1961 from openclaw/pe/clawscan
feat: label "suspicious" as "review" for scans
2026-04-30 16:23:35 -07:00
Patrick Erichsen f7c5ae5a16 feat: label "suspicious" as "review" for scans 2026-04-30 15:54:45 -07:00
Patrick Erichsen 631b357a10 Merge pull request #1948 from openclaw/pe/clawscan
feat: move ClawScan eval runner into ClawHub
2026-04-30 15:01:06 -07:00
Patrick Erichsen 42bc312151 feat: export redacted skill content for security dataset 2026-04-30 14:51:26 -07:00
Peter Steinberger 12c72366f6 fix: raise public read rate limits 2026-04-30 19:53:23 +01:00
Peter Steinberger 27d7d4afa4 ci: stabilize production deploy smoke 2026-04-30 19:50:24 +01:00
Peter Steinberger cb3852ef16 fix: sync schema dist for cli delete reason 2026-04-30 19:39:49 +01:00
Peter Steinberger 50768641f9 fix: satisfy lint on latest main 2026-04-30 19:34:10 +01:00
Peter Steinberger 651e54ed7c fix: record skill moderation reasons from CLI 2026-04-30 19:30:40 +01:00
Patrick Erichsen 3bbbd858d4 chore: rename ClawScan security signals eval 2026-04-30 09:24:29 -07:00
Patrick Erichsen 9a8607038e fix: satisfy ClawScan eval lint 2026-04-30 08:59:35 -07:00
Patrick Erichsen 2bac472615 feat: parameterize ClawScan eval HF split 2026-04-30 08:58:07 -07:00
Patrick Erichsen b27072312b chore: simplify ClawScan eval defaults 2026-04-30 08:57:01 -07:00
Patrick Erichsen 6bebc0f572 fix: satisfy maintenance lint rule 2026-04-30 08:39:46 -07:00
Patrick Erichsen efa349c856 Merge remote-tracking branch 'origin/main' into pe/clawscan 2026-04-30 08:39:07 -07:00
Patrick Erichsen 21f2cfbd9c ci: remove format check from build job 2026-04-30 08:36:42 -07:00
Patrick Erichsen b96af7391c feat: move ClawScan eval runner into ClawHub 2026-04-30 08:21:06 -07:00
706 changed files with 116385 additions and 20697 deletions
+196
View File
@@ -0,0 +1,196 @@
---
name: autoreview
description: "Use when ClawHub needs Codex review, autoreview, second-model review, or a final advisory review gate before commit, PR update, ship, or maintainer handoff."
---
# Autoreview
Run Codex's built-in code review as a closeout check. This is code review
(`codex review`), not Guardian `auto_review` approval routing.
Codex native review mode performs best and is recommended. Non-Codex reviewers
are fallback or second-opinion paths that receive a generated diff prompt, not
the full Codex review-mode runtime.
Use when:
- the user asks for Codex review, autoreview, or second-model review
- after non-trivial code edits, before final/commit/ship
- reviewing a local branch or PR branch after fixes
- closing out ClawHub maintainer work that touched source, tests, Convex, UI,
CLI packages, or workflows
## Contract
- Treat review output as advisory. Never blindly apply it.
- Verify every finding by reading the real code path and adjacent files.
- Read dependency docs/source/types when the finding depends on external
behavior.
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes
that over-complicate the codebase.
- Prefer small fixes at the right ownership boundary; no refactor unless it
clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable
findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the
review helper.
- Default to Codex review. If Codex is unavailable or exits with an error, the
helper can fall back to `claude -p`, `pi -p`, `opencode run`, `droid exec`, or
`copilot`.
- Stop as soon as the review command/helper exits 0 with no
accepted/actionable findings. Do not run an extra direct `codex review` just
to get a nicer clean line, a second opinion, or clearer closeout wording.
- If rejecting a finding as intentional/not worth fixing, add a brief inline
code comment only when it explains a real invariant or ownership decision
future reviewers should know.
- Do not push just to review. Push only when the user requested push/ship/PR
update.
## ClawHub Proof Routing
Pick the smallest proof that matches the touched surface:
| Touched surface | Usual proof |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Formatting/lint/static repo health | `bun run ci:static` |
| Unit-tested source behavior | focused `bunx vitest run ...`, then `bun run ci:unit` when PR-ready |
| Convex code | read `convex/_generated/ai/guidelines.md` first; run focused tests and the deploy/typecheck path that covers the change |
| Packages/CLI/mod tool | `bun run ci:packages` or the package-specific `verify` script |
| Runtime/build/package surface | `bun run ci:types-build`, `bun run ci:e2e-http`, or the matching broader gate |
| UI behavior | use `clawhub-ui-proof` with `proof:ui`; publish proof before final PR comments when needed |
| Linux/CI-parity validation | use `crabbox`, normally through the repo scripts |
For Convex query or schema work, apply the repo's Convex rules: prefer indexes
over `.filter()` scans, use cursor-based backfills for data shape changes, and
verify with the repo's Convex/typecheck path before claiming deploy safety.
## Pick Target
Dirty local work:
```bash
codex review --uncommitted
```
Use this only when the patch is actually unstaged/staged/untracked in the
current checkout. For committed, pushed, or PR work, point Codex at the commit
or branch diff instead. A clean `--uncommitted` review only proves there is no
local patch.
Branch/PR work:
```bash
git fetch origin
codex review --base origin/main
```
If an open PR exists, use its actual base:
```bash
base=$(gh pr view --json baseRefName --jq .baseRefName)
codex review --base "origin/$base"
```
Do not pass a prompt with `--base`. Some Codex CLI versions reject
`codex review --base <ref> -` with `--base <BRANCH> cannot be used with
[PROMPT]`. If that happens, rerun plain `codex review --base <ref>` and report
that prompt injection was skipped.
Committed single change:
```bash
codex review --commit HEAD
```
or with the helper:
```bash
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
```
Use commit review for already-landed or already-pushed work on `main`.
Reviewing clean `main` against `origin/main` is usually an empty diff after
push. For a small stack, review each commit explicitly or review the branch
before merging with `--base`.
## Parallel Closeout
Format first if formatting can change line locations. Then it is OK to run
tests and review in parallel:
```bash
.agents/skills/autoreview/scripts/autoreview --parallel-tests "bun run ci:static"
```
Tradeoff: tests may force code changes that stale the review. If tests or
review lead to code edits, rerun the affected tests and rerun review until no
accepted/actionable findings remain. Once that rerun exits cleanly, stop; do
not spend another long review cycle on redundant confirmation.
## Context Efficiency
Codex review is usually noisy. Default to a subagent filter when subagents are
available. Ask it to run the review and return only:
- actionable findings it accepts
- findings it rejects, with one-line reason
- exact files/tests to rerun
Run inline only for tiny changes or when subagents are unavailable.
## Helper
Bundled helper:
```bash
.agents/skills/autoreview/scripts/autoreview --help
```
The helper:
- chooses dirty `--uncommitted` first
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- auto-runs `bun run ci:static` in parallel when the repo has `package.json`,
`bun.lock`, `node_modules`, and a `ci:static` script; disable with
`AUTOREVIEW_AUTO_TESTS=0`
- use `--mode commit --commit <ref>` for already-committed work, especially
clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch
work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto`
means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`
- falls back only when Codex is unavailable or exits nonzero without findings,
not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox
danger-full-access` by default; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt
out
- prints `autoreview clean: no accepted/actionable findings reported` when the
selected review command exits 0 and no accepted/actionable findings are
reported
## Final Report
Include:
- review command used
- tests/proof run
- findings accepted/rejected, briefly why
- the clean review result from the final helper/review run, or why a remaining
finding was consciously rejected
Do not run another Codex review solely to improve final wording. If the final
helper run exited 0 and produced no accepted/actionable findings, report that
exact run as clean.
## PR / CI Closeout
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`;
use PR rollup only for final mergeability.
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already
upstream before pushing.
- Update the PR body once near the final head unless proof labels are missing
or stale enough to block CI.
+543
View File
@@ -0,0 +1,543 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: autoreview [options]
Options:
--mode auto|local|branch|commit
Target selection. Default: auto.
--base REF
Base ref for branch review. Default: PR base or origin/main.
--commit REF
Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex with configured fallback on error.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero without findings.
--codex-bin PATH
Codex binary. Default: codex.
--claude-bin PATH
Claude binary. Default: claude.
--pi-bin PATH
Pi binary. Default: pi.
--opencode-bin PATH
OpenCode binary. Default: opencode.
--droid-bin PATH
Droid binary. Default: droid.
--copilot-bin PATH
GitHub Copilot binary. Default: copilot.
--full-access
Keep yolo/full-access mode enabled. Default.
--no-yolo
Run nested Codex review with normal sandbox/approval prompts.
--output FILE
Also save output to file.
--parallel-tests CMD
Run review and test command concurrently. Pass "" to disable auto-tests.
Default: bun run ci:static when package.json, bun.lock, node_modules, and
a ci:static script are present.
--dry-run
Print selected commands, do not run.
-h, --help
Show help.
Modes:
local codex review --uncommitted
branch codex review --base <ref>
commit codex review --commit <ref>
auto dirty tree -> local, else PR/current branch -> branch
EOF
}
mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
opencode_bin=${OPENCODE_BIN:-opencode}
droid_bin=${DROID_BIN:-droid}
copilot_bin=${COPILOT_BIN:-copilot}
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
parallel_tests=
parallel_tests_set=false
parallel_tests_auto=false
dry_run=false
while [[ $# -gt 0 ]]; do
case "$1" in
--mode) mode=${2:-}; shift 2 ;;
--base) base_ref=${2:-}; shift 2 ;;
--commit) commit_ref=${2:-}; shift 2 ;;
--reviewer) reviewer=${2:-}; shift 2 ;;
--fallback-reviewer) fallback_reviewer=${2:-}; shift 2 ;;
--codex-bin) codex_bin=${2:-}; shift 2 ;;
--claude-bin) claude_bin=${2:-}; shift 2 ;;
--pi-bin) pi_bin=${2:-}; shift 2 ;;
--opencode-bin) opencode_bin=${2:-}; shift 2 ;;
--droid-bin) droid_bin=${2:-}; shift 2 ;;
--copilot-bin) copilot_bin=${2:-}; shift 2 ;;
--full-access) yolo=1; shift ;;
--no-yolo) yolo=0; shift ;;
--output) output=${2:-}; shift 2 ;;
--parallel-tests) parallel_tests=${2:-}; parallel_tests_set=true; shift 2 ;;
--dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
case "$mode" in
auto|local|branch|commit) ;;
*) echo "invalid --mode: $mode" >&2; exit 2 ;;
esac
case "$reviewer" in
auto|codex|claude|pi|opencode|droid|copilot) ;;
*) echo "invalid --reviewer: $reviewer" >&2; exit 2 ;;
esac
case "$fallback_reviewer" in
auto|claude|pi|opencode|droid|copilot|none) ;;
*) echo "invalid --fallback-reviewer: $fallback_reviewer" >&2; exit 2 ;;
esac
repo_root=$(git rev-parse --show-toplevel)
current_branch=$(git branch --show-current 2>/dev/null || true)
dirty=false
if [[ -n "$(git status --porcelain)" ]]; then
dirty=true
fi
codex_args=()
case "$yolo" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
esac
has_package_script() {
local script_name=$1
command -v node >/dev/null 2>&1 || return 1
node -e '
const { readFileSync } = require("node:fs");
const pkg = JSON.parse(readFileSync(process.argv[1], "utf8"));
process.exit(pkg.scripts?.[process.argv[2]] ? 0 : 1);
' "$repo_root/package.json" "$script_name" >/dev/null 2>&1
}
auto_tests_disabled() {
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
*) return 1 ;;
esac
}
pr_url=
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
base_name=${pr_lines%%$'\t'*}
pr_url=${pr_lines#*$'\t'}
if [[ -n "$base_name" ]]; then
base_ref="origin/$base_name"
fi
fi
fi
if [[ -z "$base_ref" ]]; then
base_ref=origin/main
fi
review_kind=
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
review_kind=local
elif [[ "$mode" == commit ]]; then
review_kind=commit
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
review_kind=branch
else
echo "no review target: clean main checkout and no forced mode" >&2
exit 1
fi
if [[ "$review_kind" == local ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted)
elif [[ "$review_kind" == commit ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref")
else
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
fi
if [[ "$parallel_tests_set" == false && -z "$parallel_tests" ]] && ! auto_tests_disabled; then
if [[ -f "$repo_root/package.json" && -f "$repo_root/bun.lock" && -d "$repo_root/node_modules" ]] &&
command -v bun >/dev/null 2>&1 && has_package_script ci:static; then
printf -v quoted_repo_root '%q' "$repo_root"
parallel_tests="cd $quoted_repo_root && bun run ci:static"
parallel_tests_auto=true
fi
fi
printf 'autoreview target: %s\n' "$review_kind"
printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
printf 'pr: %s\n' "$pr_url"
fi
if [[ "$reviewer" == auto ]]; then
printf 'reviewer: codex\n'
else
printf 'reviewer: %s\n' "$reviewer"
fi
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
else
printf 'review: %s prompt review\n' "$reviewer"
fi
if [[ -n "$parallel_tests" ]]; then
printf 'tests: %s' "$parallel_tests"
if [[ "$parallel_tests_auto" == true ]]; then
printf ' (auto)'
fi
printf '\n'
fi
if [[ "$review_kind" == branch ]]; then
printf 'fetch: git fetch origin --quiet\n'
fi
if [[ -n "$output" ]]; then
printf 'output: %s\n' "$output"
fi
if [[ "$dry_run" == true ]]; then
exit 0
fi
if [[ "$review_kind" == branch ]]; then
git fetch origin --quiet || {
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
}
fi
review_output=$output
review_output_is_temp=false
prompt_file=
if [[ -z "$review_output" ]]; then
review_output=$(mktemp)
review_output_is_temp=true
fi
mkdir -p "$(dirname "$review_output")"
: > "$review_output"
cleanup() {
if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then
rm -f "$review_output"
fi
if [[ -n "${prompt_file:-}" ]]; then
rm -f "$prompt_file"
fi
}
trap cleanup EXIT
diff_for_review() {
case "$review_kind" in
local)
git -C "$repo_root" diff --stat
git -C "$repo_root" diff --cached --stat
git -C "$repo_root" diff --find-renames
git -C "$repo_root" diff --cached --find-renames
while IFS= read -r untracked_file; do
[[ -n "$untracked_file" ]] || continue
git -C "$repo_root" diff --no-index -- /dev/null "$untracked_file" || true
done < <(git -C "$repo_root" ls-files --others --exclude-standard)
;;
commit)
git -C "$repo_root" show --find-renames --stat --format=fuller "$commit_ref"
git -C "$repo_root" show --find-renames --format=medium "$commit_ref"
;;
branch)
git -C "$repo_root" diff --find-renames --stat "$base_ref"...HEAD
git -C "$repo_root" diff --find-renames "$base_ref"...HEAD
;;
esac
}
build_prompt_file() {
prompt_file=$(mktemp)
{
cat <<'EOF'
You are reviewing a ClawHub diff.
Return only accepted/actionable findings. Verify claims against the diff and
reject speculative, low-value, or overbroad rewrites.
Use this format for findings:
[P1] Short title
File: path:line
Why: one sentence
Fix: one sentence
If no accepted/actionable findings, output exactly:
autoreview clean: no accepted/actionable findings reported
Diff:
EOF
diff_for_review
} > "$prompt_file"
}
review_output_has_clean_marker() {
local path=$1
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
}
review_output_has_findings() {
grep -Eq '\[P[0-3]\]' "$review_output"
}
review_output_empty() {
[[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output"
}
review_output_used_prompt_reviewer() {
grep -Eq '^fallback: (claude -p|pi -p|opencode run|droid exec|copilot)$' "$review_output"
}
run_codex_review() {
if ! command -v "$codex_bin" >/dev/null 2>&1; then
echo "codex reviewer unavailable: $codex_bin" >&2
return 127
fi
"${review_cmd[@]}" 2>&1 | tee "$review_output"
}
run_prompt_reviewer() {
local selected=$1
local status=0
local prompt_bytes=0
local copilot_prompt=
build_prompt_file
case "$selected" in
claude)
command -v "$claude_bin" >/dev/null 2>&1 || {
echo "fallback reviewer unavailable: $claude_bin" >&2
return 127
}
printf 'fallback: claude -p\n' | tee -a "$review_output"
"$claude_bin" --tools "" --no-session-persistence -p < "$prompt_file" 2>&1 | tee -a "$review_output"
status=${PIPESTATUS[0]}
;;
pi)
command -v "$pi_bin" >/dev/null 2>&1 || {
echo "fallback reviewer unavailable: $pi_bin" >&2
return 127
}
printf 'fallback: pi -p\n' | tee -a "$review_output"
"$pi_bin" --no-tools --no-session -p < "$prompt_file" 2>&1 | tee -a "$review_output"
status=${PIPESTATUS[0]}
;;
opencode)
command -v "$opencode_bin" >/dev/null 2>&1 || {
echo "fallback reviewer unavailable: $opencode_bin" >&2
return 127
}
printf 'fallback: opencode run\n' | tee -a "$review_output"
"$opencode_bin" run --pure --dir "$repo_root" "Review the attached prompt file. Do not modify files." --file "$prompt_file" 2>&1 | tee -a "$review_output"
status=${PIPESTATUS[0]}
;;
droid)
command -v "$droid_bin" >/dev/null 2>&1 || {
echo "fallback reviewer unavailable: $droid_bin" >&2
return 127
}
printf 'fallback: droid exec\n' | tee -a "$review_output"
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output"
status=${PIPESTATUS[0]}
;;
copilot)
command -v "$copilot_bin" >/dev/null 2>&1 || {
echo "fallback reviewer unavailable: $copilot_bin" >&2
return 127
}
printf 'fallback: copilot\n' | tee -a "$review_output"
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
if (( prompt_bytes > 120000 )); then
echo "copilot reviewer unavailable: generated prompt is too large" | tee -a "$review_output"
status=1
else
copilot_prompt=$(< "$prompt_file")
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent -p "$copilot_prompt" 2>&1 | tee -a "$review_output"
status=${PIPESTATUS[0]}
fi
;;
*)
echo "unsupported prompt reviewer: $selected" >&2
status=2
;;
esac
rm -f "$prompt_file"
prompt_file=
return "$status"
}
fallback_reviewer_is_available() {
local selected=$1
case "$selected" in
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
run_selected_review() {
local selected=$1
case "$selected" in
codex) run_codex_review ;;
claude|pi|opencode|droid|copilot) run_prompt_reviewer "$selected" ;;
*) echo "unsupported reviewer: $selected" >&2; return 2 ;;
esac
}
run_auto_fallback_review() {
local selected
if [[ "$fallback_reviewer" != auto ]]; then
run_selected_review "$fallback_reviewer"
return $?
fi
for selected in claude pi opencode droid copilot; do
if fallback_reviewer_is_available "$selected"; then
run_selected_review "$selected"
return $?
fi
done
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
return 127
}
run_auto_review() {
local status=0
run_selected_review codex
status=$?
if [[ "$status" == 0 ]]; then
return 0
fi
if (( status > 128 && status < 192 )); then
return "$status"
fi
if review_output_has_findings; then
return "$status"
fi
if [[ "$fallback_reviewer" == none ]]; then
return "$status"
fi
if [[ "$fallback_reviewer" == auto ]]; then
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
else
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
fi
run_auto_fallback_review
}
elapsed_since() {
local started_at=$1
local finished_at
finished_at=$(date +%s)
printf '%s\n' "$((finished_at - started_at))"
}
format_elapsed() {
local seconds=$1
if (( seconds < 60 )); then
printf '%ss\n' "$seconds"
else
printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))"
fi
}
report_clean_review_or_fail() {
local elapsed_text
elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}")
if review_output_has_findings; then
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview findings: accepted/actionable findings reported\n'
return 1
fi
if review_output_empty; then
printf 'autoreview complete after %s; no output\n' "$elapsed_text"
return 1
fi
if review_output_used_prompt_reviewer && ! review_output_has_clean_marker "$review_output"; then
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview findings: prompt reviewer did not emit clean marker\n'
return 1
fi
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview clean: no accepted/actionable findings reported\n'
}
if [[ -z "$parallel_tests" ]]; then
review_started_at=$(date +%s)
set +e
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
review_status=$?
review_elapsed_seconds=$(elapsed_since "$review_started_at")
set -e
if [[ "$review_status" == 0 ]]; then
report_clean_review_or_fail
exit $?
fi
exit "$review_status"
fi
review_status_file=$(mktemp)
review_elapsed_file=$(mktemp)
tests_status_file=$(mktemp)
(
set +e
review_started_at=$(date +%s)
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
status=$?
elapsed=$(elapsed_since "$review_started_at")
printf '%s\n' "$status" > "$review_status_file"
printf '%s\n' "$elapsed" > "$review_elapsed_file"
) &
review_pid=$!
(
set +e
bash -lc "$parallel_tests"
status=$?
printf '%s\n' "$status" > "$tests_status_file"
) &
tests_pid=$!
wait "$review_pid" || true
wait "$tests_pid" || true
review_status=$(cat "$review_status_file")
review_elapsed_seconds=$(cat "$review_elapsed_file")
tests_status=$(cat "$tests_status_file")
rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file"
printf 'autoreview exit: %s\n' "$review_status"
printf 'tests exit: %s\n' "$tests_status"
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
exit 1
fi
report_clean_review_or_fail
-347
View File
@@ -1,347 +0,0 @@
---
name: blacksmith-testbox
description: Run Blacksmith Testbox for ClawHub CI-parity checks, hosted services, broad Bun gates, or builds local cannot reproduce without hurting developer machines.
---
# Blacksmith Testbox
## Scope
Use Testbox when you need remote CI parity, injected secrets, hosted services,
or an OS/runtime image that your local machine cannot provide cheaply.
Do not default to Testbox for every local test/build loop. If the repo has
documented local commands for normal iteration, use those first so you keep
warm caches, local build state, and fast feedback.
Testbox is the expensive path. Reach for it deliberately.
ClawHub maintainers can opt into Testbox-first validation by setting
`CLAWHUB_TESTBOX=1` in their environment or standing agent rules. This mode is
maintainers-only and requires Blacksmith access.
When `CLAWHUB_TESTBOX=1` is set in ClawHub:
- Pre-warm a Testbox early for longer, wider, or uncertain work.
- Prefer Testbox for broad Bun gates, e2e, Convex-ish deploy parity, package
proof, and expensive validation.
- Reuse the same Testbox ID for every run command in the same task/session.
- Use local commands only when the task explicitly sets
`CLAWHUB_LOCAL_CHECK_MODE=throttled|full`, or when the user asks for local
proof.
## Install The CLI
If `blacksmith` is not installed, install it:
```bash
curl -fsSL https://get.blacksmith.sh | sh
```
For the canary channel:
```bash
BLACKSMITH_CHANNEL=canary sh -c 'curl -fsSL https://get.blacksmith.sh | sh'
```
Then authenticate:
```bash
blacksmith auth login
```
## Agent-Triggered Browser Auth
When an agent needs to ensure the user is authenticated before running Testbox
commands, use browser-based auth with non-interactive mode. This opens the
browser for the user to sign in; the agent does not interact with the browser.
`--organization` is required with `--non-interactive`:
```bash
blacksmith auth login --non-interactive --organization <org-slug>
```
The org slug can come from `BLACKSMITH_ORG` or the `--org` global flag. Do not
use `--api-token` for this browser flow; that is for headless/token auth.
## Decide First: Local Or Testbox
Before warming anything up, check the repo's own instructions.
Prefer local commands when:
- the repo documents a supported local test/build workflow
- you are iterating on unit tests, lint, typecheck, formatting, or other
local-only validation
- the value comes from warm local caches and fast repeat runs
- the command does not need remote secrets, hosted services, or CI-only images
Prefer Testbox when:
- `CLAWHUB_TESTBOX=1` is set by the user, agent environment, or standing rules
- the repo explicitly requires CI-parity or remote validation
- the command needs secrets, service containers, or provisioned infra
- you are reproducing CI-only failures
- you need the exact workflow image/job environment from GitHub Actions
For ClawHub specifically, normal local iteration stays local unless maintainer
Testbox mode is enabled with `CLAWHUB_TESTBOX=1`:
- `bun run format:check`
- `bun run lint`
- `bun run test`
- `bun run coverage`
- `bunx tsc --noEmit`
- `bun run build`
If `CLAWHUB_TESTBOX=1` is enabled, run those same repo commands inside the warm
Testbox. If the user wants laptop-friendly local proof for one command, use the
explicit escape hatch `CLAWHUB_LOCAL_CHECK_MODE=throttled`.
In `.codex` worktrees without a `node_modules` symlink, do not run
`bun install` just to validate locally. Use syntax checks or Testbox.
## Setup: Warmup Before Coding
If you decided Testbox is warranted, warm one up early. This returns an ID
instantly and boots the CI environment in the background while you work:
```bash
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
# -> tbx_01jkz5b3t9...
```
Save this ID in the current session. You need it for every `run` command.
Treat `blacksmith testbox list` as diagnostics, not a reusable work queue.
Listed boxes can be visible at the org/repo level while still being unusable or
stale for the current local agent lane.
For ClawHub maintainer Testbox mode, claim the ID in the current checkout:
```bash
bun run testbox:claim -- --id <ID>
```
Warmup dispatches `.github/workflows/ci-check-testbox.yml`, which provisions a
VM with Bun, Node, dependency install/cache, and a clean checkout of the repo at
the chosen ref.
Bootstrap note: GitHub only exposes `workflow_dispatch` workflows through the
Actions API after the workflow file exists on the default branch. If a brand-new
Testbox workflow exists only on a feature branch, `blacksmith testbox warmup
ci-check-testbox.yml --ref <branch>` can return a GitHub 404 even though the
file exists on that branch. Land the workflow bootstrap first, then dispatch
branch refs normally.
Options:
```text
--ref <branch|tag> Git ref to dispatch against
--job <name> Specific job within the workflow, if it has multiple
--idle-timeout <min> Idle timeout in minutes
```
## Critical: Always Run From The Repo Root
Always invoke `blacksmith testbox` commands from the root of the git
repository. The CLI syncs the current working directory to the testbox using
rsync with `--delete`. If you run from a subdirectory, rsync mirrors only that
subdirectory and can delete everything else on the testbox.
Correct:
```bash
blacksmith testbox run --id <ID> "bun run test"
blacksmith testbox run --id <ID> "cd packages/clawhub && bun run verify"
```
Wrong:
```bash
cd packages/clawhub && blacksmith testbox run --id <ID> "bun run verify"
```
If your shell is in a subdirectory, move back first:
```bash
cd "$(git rev-parse --show-toplevel)"
```
## Running Commands
Raw Blacksmith form:
```bash
blacksmith testbox run --id <ID> "<command>"
```
The `run` command waits for the testbox to become ready if it is still booting,
so you can call `run` immediately after warmup.
In ClawHub, prefer the guarded runner wrapper so stale/reused ids fail before
the Blacksmith CLI spends time syncing or emits a confusing missing-key error:
```bash
bun run testbox:run -- --id <ID> -- bun run lint
bun run testbox:run -- --id <ID> -- bun run test
bun run testbox:run -- --id <ID> -- bun run build
```
The wrapper refuses to run when the local per-Testbox key is missing or when
the id was not claimed by this ClawHub checkout with:
```bash
bun run testbox:claim -- --id <ID>
```
Treat that as the expected remediation, not as a GitHub account or normal
SSH-key problem. A local key alone is not enough; a ready box may still carry
stale rsync state from another lane.
If the agent crashes, the remote box relies on Blacksmith's idle timeout. The
local ClawHub claim marker is not deleted automatically, so the wrapper treats
claims older than 12 hours as stale. Override only for intentional long-running
work with:
```bash
CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES=<minutes>
```
Before spending a broad gate on a manually assembled command, run:
```bash
bun run testbox:sanity -- --id <ID>
```
## Downloading Files From A Testbox
Use the `download` command to retrieve files or directories from a running
testbox to your local machine. This is useful for fetching build artifacts,
test results, coverage reports, or any output generated on the testbox.
```bash
blacksmith testbox download --id <ID> <remote-path> [local-path]
```
The remote path is relative to the testbox working directory. If no local path
is specified, the file is saved to the current directory using the same base
name.
Examples:
```bash
blacksmith testbox download --id <ID> coverage/lcov-report/ ./coverage/
blacksmith testbox download --id <ID> test-results/ ./test-results/
blacksmith testbox download --id <ID> dist/ ./dist/
```
## How File Sync Works
Understanding this model is critical for using Testbox correctly.
When you call `run`, the CLI performs a delta sync of your local changes to the
remote testbox before executing your command:
1. The testbox VM starts from a clean checkout at the warmup ref. The workflow
setup steps run during warmup and populate dependency directories on the
remote VM.
2. On each `run`, the CLI uses git to detect which files changed locally since
the last sync. It syncs only tracked files and untracked non-ignored files.
3. `.gitignore`'d directories are never synced. `node_modules/`, `.bun/`,
`.vite/`, `dist/`, `.output/`, `.nitro/`, and coverage outputs stay local.
The testbox uses its own copies populated by the warmup workflow.
4. If nothing has changed since the last sync, the sync is skipped.
Why this matters:
- If you modify `package.json` or `bun.lock`, re-run install on the testbox:
```bash
bun run testbox:run -- --id <ID> -- bun install --frozen-lockfile
```
- If tests depend on generated/build output, re-run the build on the testbox.
- New untracked files sync as long as they are not gitignored.
- Deleted files are also deleted on the remote testbox.
## Critical: Do Not Ban Local Tests
Do not assume local validation is forbidden. Many repos intentionally invest in
fast, warm local loops, and forcing every run through Testbox destroys that
advantage.
Use Testbox for checks that actually need it: remote parity, secrets, services,
CI-only runners, expensive broad gates, or reproducibility against the workflow
image.
ClawHub maintainer exception: if `CLAWHUB_TESTBOX=1` is set by the user or
agent environment, treat Testbox as the normal validation path for this repo.
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled|full` as the explicit local escape
hatch.
## Workflow
1. Decide whether the repo's local loop is the right default. For ClawHub,
`CLAWHUB_TESTBOX=1` makes Testbox the maintainer default.
2. If Testbox is warranted, warm up early:
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`.
3. Save the ID, then claim it:
`bun run testbox:claim -- --id <ID>`.
4. Write code while the testbox boots in the background.
5. Run sanity before broad checks:
`bun run testbox:sanity -- --id <ID>`.
6. Run the remote command:
`bun run testbox:run -- --id <ID> -- bun run lint`.
7. If tests fail, fix code and re-run against the same warm box.
8. If dependency manifests changed, run install in the box before testing.
9. If you need artifacts, download them with `blacksmith testbox download`.
10. Stop the box when done if it is no longer needed:
`blacksmith testbox stop --id <ID>`.
## ClawHub Broad Gate
For a broad ClawHub proof in maintainer Testbox mode, use the repo package
manager and keep the commands explicit:
```bash
bun run testbox:run -- --id <ID> -- bun run format:check
bun run testbox:run -- --id <ID> -- bun run lint
bun run testbox:run -- --id <ID> -- bun run test
bun run testbox:run -- --id <ID> -- bunx tsc --noEmit
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/schema/tsconfig.json --noEmit
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
bun run testbox:run -- --id <ID> -- bun run build
```
For e2e:
```bash
bun run testbox:run -- --id <ID> -- bun run test:e2e
bun run testbox:run -- --id <ID> -- bun run test:pw
```
## Waiting For Readiness
The `run` command automatically waits for the testbox, so explicit waiting is
usually unnecessary. If you do need to check readiness separately, use
`--wait`. Do not use a sleep-and-recheck loop.
```bash
blacksmith testbox status --id <ID> --wait --wait-timeout 5m
```
## Managing Testboxes
```bash
blacksmith testbox status --id <ID>
blacksmith testbox list
blacksmith testbox stop --id <ID>
```
Testboxes automatically shut down after being idle. For ClawHub maintainer
work, use 90 minutes for long-running sessions:
```bash
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 90
```
@@ -0,0 +1,96 @@
---
name: clawhub-moderation
description: "Use for ClawHub staff moderation actions with the repo-local clawhub-mod tool: unhide skills, ban or unban users, change roles, and verify moderation state."
---
# ClawHub Moderation
Use the repo-local `clawhub-mod` tool from a checked-out ClawHub repo. It wraps
the existing ClawHub CLI auth/config and HTTP API surfaces. Do not call Convex
internal mutations directly for staff actions.
## Safety Rules
- Require an explicit target from the user: skill slug, user handle, or user id.
- Require a reason for `skills unhide`, `users ban`, and `users unban`.
- Before any write, show the exact command and ask for confirmation unless the
user already said to proceed or supplied `--yes`.
- Prefer handles for humans. Use `--id` only when the user provides a user id.
- Never bypass API-token auth, server role checks, or audit logging.
- After the write, verify state with the CLI/API and report the result.
## Commands
Run from the ClawHub repo root:
```sh
bun run mod -- --help
```
Authenticate or validate the current token:
```sh
bun run mod -- login
bun run mod -- whoami
```
Unhide a skill after moderator review:
```sh
bun run mod -- skills unhide <slug> --reason "<reason>" --yes
```
List and triage skill reports:
```sh
bun run mod -- skills reports --status open
bun run mod -- skills triage-report <report-id> --status confirmed --action hide --note "<note>" --yes
```
Ban a user:
```sh
bun run mod -- users ban <handleOrId> --reason "<reason>" --yes
```
Unban a user:
```sh
bun run mod -- users unban <handleOrId> --reason "<reason>" --yes
```
Change a user role:
```sh
bun run mod -- users set-role <handleOrId> <user|moderator|admin> --yes
```
Use `--id` when `<handleOrId>` is a user id. Use `--fuzzy` only when the user
has asked for fuzzy handle resolution or the exact handle is ambiguous.
The old top-level aliases still exist for user commands:
```sh
bun run mod -- ban-user <handleOrId> --reason "<reason>" --yes
bun run mod -- unban-user <handleOrId> --reason "<reason>" --yes
```
## Verification
- For skills, inspect the page/API status after `skills unhide`.
- For users, prefer `bun run mod -- whoami` for the current token and user
search/admin surfaces for target accounts where available.
- If verification is blocked by auth or missing admin access, report the command
result and the verification blocker plainly.
## Impact Notes
- `skills unhide` is a moderator manual restore. It clears skill hidden state,
applies a clean manual override to top-level moderation fields, preserves
version-level scanner records, updates public stats, and writes audit logs.
- There is no standalone `skills hide` command in `clawhub-mod`; use report
triage with `--action hide` when resolving a report that should hide a skill.
- `ban-user` is disruptive: it revokes API tokens, marks the user deleted,
hides owned skills, soft-deletes comments, and writes audit logs.
- `unban-user` is admin-only. It clears ban state and restores skills that were
hidden by the matching ban flow; revoked API tokens stay revoked.
@@ -0,0 +1,102 @@
---
name: clawhub-pr-maintainer
description: Use when reviewing, triaging, validating, or discussing ClawHub GitHub issues or pull requests, including author context, CI, UI proof, evidence, labels, close decisions, and maintainer handoff.
---
# ClawHub PR Maintainer
Use this skill for maintainer-facing ClawHub GitHub workflow, not for ordinary
implementation work.
## Start With Live GitHub State
- Use `gh pr view` or `gh issue view` against `openclaw/clawhub`; verify live
state before commenting, labeling, closing, or recommending merge.
- For PRs, read title, body, author, labels, comments, files, commits, status
checks, review state, and linked issues.
- Surface author identity briefly: GitHub name/login and account age when
useful. Treat identity as triage signal, never as proof by itself.
Common read-only commands:
```sh
gh pr view <number> --repo openclaw/clawhub --json title,body,author,labels,comments,files,commits,statusCheckRollup,reviewDecision,url
gh issue view <number> --repo openclaw/clawhub --json title,body,author,labels,comments,state,url
gh api users/<login> --jq '{login,name,created_at,type}'
```
## Review Evidence Bar
- For bug fixes, require symptom evidence, a plausible root cause in the touched
code path, and either a regression test or focused manual proof.
- For UI changes, require screenshots or video when the behavior is meaningfully
visual. Use tests as supplemental evidence, not a substitute for visible proof.
- Do not merge or recommend merge based only on PR prose, AI rationale, or green
CI when the changed behavior has not been exercised.
- For contributor-provided screenshots/videos/logs, inspect the artifact
directly and state what it proves. Do not rerun `proof:ui` just to inspect
existing evidence.
## Decide UI Proof Mode
Use the `clawhub-ui-proof` skill when the maintainer/agent should generate new
visual evidence.
- `before-after`: bug fixes, regressions, changed copy, changed layout, or any
PR where main-vs-candidate comparison clarifies the change.
- `feature`: new page, new flow, new UI state, or behavior that cannot exist on
`origin/main`.
- No generated proof: docs-only, backend-only, tests-only, metadata-only, or
already-sufficient contributor evidence.
Write a temporary Playwright scenario under `.artifacts/proof-scenarios/`; do
not infer manual clicks. Keep screenshots and videos in `.artifacts/` until
publishing. Never commit proof artifacts.
## Final Review Comment With Proof
If this review generated `proof:ui` artifacts, publish them before the final PR
review comment. Do not leave only local `.artifacts/...` paths in a PR comment;
they are useful to the maintainer locally but invisible to GitHub readers.
Use:
```sh
bun run proof:publish -- --proof-dir .artifacts/clawhub-ui-proof/<timestamp> --target-pr <number>
```
`proof:publish` copies the selected files to the `qa-artifacts` branch and
upserts a marker-backed PR comment with a **ClawHub UI Proof** section.
That comment includes:
- the proof mode (`before-after` or `feature`)
- the `report.md` result summary
- the most relevant per-step screenshots
- inline video previews when GIF previews are present
- links to full-run MP4s
- links to raw proof files on the artifact branch
Use `--dry-run` before publishing if you need to inspect the generated comment.
If publishing fails because credentials are missing, report the local proof
directory and the failed command instead of posting a comment that claims
evidence is attached.
## ClawSweeper
ClawSweeper is the bot control plane for automated PR/issue review once ClawHub
dispatch is configured. Until then, use this skill for manual maintainer review.
If ClawSweeper has posted a review, read it as evidence but verify live PR state
before acting.
## Commenting And Labels
- Use literal multiline comment bodies or `--body-file`; never pass escaped
`\n` strings.
- Keep maintainer comments short: finding, evidence, requested action, and
verification path.
- When no proof artifacts were generated, `gh pr comment --body-file` is fine.
When proof artifacts were generated, use `proof:publish` so screenshots/videos
are published before posting.
- Do not close more than five issues/PRs in one action without explicit
confirmation and the exact target list.
+75
View File
@@ -0,0 +1,75 @@
---
name: clawhub-ui-proof
description: Use when ClawHub UI changes need visual proof, before/after comparison, new-feature screenshots, temporary Playwright scenarios, or Crabbox desktop recordings.
---
# ClawHub UI Proof
Use `proof:ui` for human-readable UI evidence. The agent should write a
temporary scenario for the feature instead of manually clicking through the UI.
## Pick A Mode
- Use `--mode before-after` for bug fixes, regressions, changed copy, changed
layout, or anything where main-vs-candidate comparison helps. This is the
default and runs baseline `origin/main` plus the candidate worktree.
- Use `--mode feature` for new pages, new workflows, or new UI states that do
not exist on main. This runs only the candidate lane.
- Do not use `proof:ui` to inspect contributor-provided screenshots, videos, or
logs. Review those artifacts directly and cite what they prove or fail to
prove.
## Scenario Shape
Create a temporary scenario under `.artifacts/proof-scenarios/`:
```js
export default async function scenario({ baseURL, expect, page, proof }) {
await proof.step("01 skills list", async () => {
await page.goto(`${baseURL}/skills`);
await expect(page.getByText("Skills")).toBeVisible();
});
}
```
Each `proof.step()` captures a screenshot after the step. The runner compares
`origin/main` to the current worktree by default in `before-after` mode.
## Commands
Dry-run the plan first. Before/after mode is the default:
```sh
bun run proof:ui -- --mode before-after --scenario .artifacts/proof-scenarios/my-fix.pw.ts --dry-run
```
For new feature proof, run candidate-only:
```sh
bun run proof:ui -- --mode feature --scenario .artifacts/proof-scenarios/my-feature.pw.ts --dry-run
```
Run real desktop proof on a Crabbox-owned provider:
```sh
bun run proof:ui -- --mode before-after --scenario .artifacts/proof-scenarios/my-fix.pw.ts --provider hetzner
```
Artifacts are written under `.artifacts/clawhub-ui-proof/<timestamp>/` with
screenshots, videos when available, `summary.json`, and `report.md`. Feature
mode has only candidate artifacts. Promote only broadly useful scenarios into
committed `e2e/proofs/`.
## Publish To A PR
When UI proof should appear on a GitHub PR, publish the completed proof run
instead of posting local paths:
```sh
bun run proof:publish -- --proof-dir .artifacts/clawhub-ui-proof/<timestamp> --target-pr <number>
```
`proof:publish` copies the selected screenshots, video preview GIFs when
present, MP4s, `summary.json`, and `report.md` to the `qa-artifacts` branch,
then upserts a marker-backed PR comment with inline screenshots/previews and
linked MP4s. Use `--dry-run` first when drafting or checking the comment body.
+60 -27
View File
@@ -1,17 +1,21 @@
---
name: convex-create-component
description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work.
description: Builds reusable Convex components with isolated tables and app-facing APIs.
Use for new components, reusable backend modules, integrations, or component
boundary work.
---
# Convex Create Component
Create reusable Convex components with clear boundaries and a small app-facing API.
Create reusable Convex components with clear boundaries and a small app-facing
API.
## When to Use
- Creating a new Convex component in an existing app
- Extracting reusable backend logic into a component
- Building a third-party integration that should own its own tables and workflows
- Building a third-party integration that should own its own tables and
workflows
- Packaging Convex functionality for reuse across multiple apps
## When Not to Use
@@ -23,20 +27,30 @@ Create reusable Convex components with clear boundaries and a small app-facing A
## Workflow
1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding.
2. Choose the shape using the decision tree below and read the matching reference file.
3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state.
1. Ask the user what they are building and what the end goal is. If the repo
already makes the answer obvious, say so and confirm before proceeding.
2. Choose the shape using the decision tree below and read the matching
reference file.
3. Decide whether a component is justified. Prefer normal app code or a regular
library if the feature does not need isolated tables, backend functions, or
reusable persistent state.
4. Make a short plan for:
- what tables the component owns
- what public functions it exposes
- what data must be passed in from the app (auth, env vars, parent IDs)
- what stays in the app as wrappers or HTTP mounts
5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files.
6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files.
7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it.
8. Call the component from the app through `components.<name>` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`.
9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly.
10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing.
5. Create the component structure with `convex.config.ts`, `schema.ts`, and
function files.
6. Implement functions using the component's own `./_generated/server` imports,
not the app's generated files.
7. Wire the component into the app with `app.use(...)`. If the app does not
already have `convex/convex.config.ts`, create it.
8. Call the component from the app through `components.<name>` using
`ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`.
9. If React clients, HTTP callers, or public APIs need access, create wrapper
functions in the app instead of exposing component functions directly.
10. Run `npx convex dev` and fix codegen, type, or boundary issues before
finishing.
## Choose the Shape
@@ -169,19 +183,32 @@ export const myUnread = query({
});
```
Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app.
Note the reference path shape: a function in
`convex/components/notifications/lib.ts` is called as
`components.notifications.lib.send` from the app.
## Critical Rules
- Keep authentication in the app, because `ctx.auth` is not available inside components.
- Keep environment access in the app, because component functions cannot read `process.env`.
- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`.
- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace.
- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files.
- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides.
- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes.
- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary.
- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts.
- Keep authentication in the app, because `ctx.auth` is not available inside
components.
- Keep environment access in the app, because component functions cannot read
`process.env`.
- Pass parent app IDs across the boundary as strings, because `Id` types become
plain strings in the app-facing `ComponentApi`.
- Do not use `v.id("parentTable")` for app-owned tables inside component args or
schema, because the component has no access to the app's table namespace.
- Import `query`, `mutation`, and `action` from the component's own
`./_generated/server`, not the app's generated files.
- Do not expose component functions directly to clients. Create app wrappers
when client access is needed, because components are internal and need
auth/env wiring the app provides.
- If the component defines HTTP handlers, mount the routes in the app's
`convex/http.ts`, because components cannot register their own HTTP routes.
- If the component needs pagination, use `paginator` from `convex-helpers`
instead of built-in `.paginate()`, because `.paginate()` does not work across
the component boundary.
- Add `args` and `returns` validators to all public component functions, because
the component boundary requires explicit type contracts.
## Patterns
@@ -248,7 +275,9 @@ args: {
### Advanced Patterns
For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`.
For additional patterns including function handles for callbacks, deriving
validators from schema, static configuration with a globals table, and
class-based client wrappers, see `references/advanced-patterns.md`.
## Validation
@@ -261,8 +290,10 @@ Try validation in this order:
Important:
- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured.
- Until codegen runs, component-local `./_generated/*` imports and app-side `components.<name>...` references will not typecheck.
- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing.
- Until codegen runs, component-local `./_generated/*` imports and app-side
`components.<name>...` references will not typecheck.
- If validation blocks on Convex login or deployment setup, stop and ask the
user for that exact step instead of guessing.
## Reference Files
@@ -272,7 +303,8 @@ Read exactly one of these after the user confirms the goal:
- `references/packaged-components.md`
- `references/hybrid-components.md`
Official docs: [Authoring Components](https://docs.convex.dev/components/authoring)
Official docs:
[Authoring Components](https://docs.convex.dev/components/authoring)
## Checklist
@@ -280,7 +312,8 @@ Official docs: [Authoring Components](https://docs.convex.dev/components/authori
- [ ] Read the matching reference file
- [ ] Confirmed a component is the right abstraction
- [ ] Planned tables, public API, boundaries, and app wrappers
- [ ] Component lives under `convex/components/<name>/` (or package layout if publishing)
- [ ] Component lives under `convex/components/<name>/` (or package layout if
publishing)
- [ ] Component imports from its own `./_generated/server`
- [ ] Auth, env access, and HTTP routes stay in the app
- [ ] Parent app IDs cross the boundary as `v.string()`
@@ -4,7 +4,9 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#14B8A6"
default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it."
default_prompt: "Help me create a Convex component for this feature. First check that a
component is actually justified, then design the tables, API surface, and
app-facing wrappers before implementing it."
policy:
allow_implicit_invocation: true
@@ -1,10 +1,13 @@
# Advanced Component Patterns
Additional patterns for Convex components that go beyond the basics covered in the main skill file.
Additional patterns for Convex components that go beyond the basics covered in
the main skill file.
## Function Handles for callbacks
When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow.
When the app needs to pass a callback function to the component, use function
handles. This is common for components that run app-defined logic on a schedule
or in a workflow.
```ts
// App side: create a handle and pass it to the component
@@ -37,7 +40,8 @@ export const enqueue = mutation({
## Deriving validators from schema
Instead of manually repeating field types in return validators, extend the schema validator:
Instead of manually repeating field types in return validators, extend the
schema validator:
```ts
import { v } from "convex/values";
@@ -59,7 +63,8 @@ export const getLatest = query({
## Static configuration with a globals table
A common pattern for component configuration is a single-document "globals" table:
A common pattern for component configuration is a single-document "globals"
table:
```ts
// schema.ts
@@ -91,7 +96,8 @@ export const configure = mutation({
## Class-based client wrappers
For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components.
For components with many functions or configuration options, a class-based
client provides a cleaner API. This pattern is common in published components.
```ts
// src/client/index.ts
@@ -10,7 +10,8 @@ This can help when:
- the user wants a local install but also shared package logic
- the component needs extension points or override hooks
- some logic should live in normal TypeScript code outside the component boundary
- some logic should live in normal TypeScript code outside the component
boundary
## Default Advice
@@ -1,6 +1,7 @@
# Local Convex Components
Read this file when the component should live inside the current app and does not need to be published as an npm package.
Read this file when the component should live inside the current app and does
not need to be published as an npm package.
## When to Choose This
@@ -1,6 +1,7 @@
# Packaged Convex Components
Read this file when the user wants a reusable npm package or a component shared across multiple apps.
Read this file when the user wants a reusable npm package or a component shared
across multiple apps.
## When to Choose This
@@ -11,12 +12,14 @@ Read this file when the user wants a reusable npm package or a component shared
## Default Approach
- Prefer starting from `npx create-convex@latest --component` when possible
- Keep the official authoring docs as the source of truth for package layout and exports
- Keep the official authoring docs as the source of truth for package layout and
exports
- Validate the bundled package through an example app, not just the source files
## Build Flow
When building a packaged component, make sure the bundled output exists before the example app tries to consume it.
When building a packaged component, make sure the bundled output exists before
the example app tries to consume it.
Recommended order:
+42 -16
View File
@@ -1,6 +1,8 @@
---
name: convex-migration-helper
description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts.
description: Plans Convex schema and data migrations with widen-migrate-narrow and
@convex-dev/migrations. Use for breaking schema changes, backfills, table
reshaping, or zero-downtime rollouts.
---
# Convex Migration Helper
@@ -27,25 +29,32 @@ Safely migrate Convex schemas and data when making breaking changes.
### Schema Validation Drives the Workflow
Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration:
Convex will not let you deploy a schema that does not match the data at rest.
This is the fundamental constraint that shapes every migration:
- You cannot add a required field if existing documents don't have it
- You cannot change a field's type if existing documents have the old type
- You cannot remove a field from the schema if existing documents still have it
This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**.
This means migrations follow a predictable pattern: **widen the schema, migrate
the data, narrow the schema**.
### Online Migrations
Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats.
Convex migrations run online, meaning the app continues serving requests while
data is updated asynchronously in batches. During the migration window, your
code must handle both old and new data formats.
### Prefer New Fields Over Changing Types
When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back.
When changing the shape of data, create a new field rather than modifying an
existing one. This makes the transition safer and easier to roll back.
### Don't Delete Data
Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed.
Unless you are certain, prefer deprecating fields over deleting them. Mark the
field as `v.optional` and add a code comment explaining it is deprecated and why
it existed.
## Safe Changes (No Migration Needed)
@@ -88,7 +97,8 @@ Every breaking migration follows the same multi-deploy pattern:
**Deploy 1 - Widen the schema:**
1. Update schema to allow both old and new formats (e.g., add optional new field)
1. Update schema to allow both old and new formats (e.g., add optional new
field)
2. Update code to handle both formats when reading
3. Update code to write the new format for new documents
4. Deploy
@@ -106,13 +116,18 @@ Every breaking migration follows the same multi-deploy pattern:
## Using the Migrations Component
For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring.
For any non-trivial migration, use the
[`@convex-dev/migrations`](https://www.convex.dev/components/migrations)
component. It handles batching, cursor-based pagination, state tracking, resume
from failure, dry runs, and progress monitoring.
See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options.
See `references/migrations-component.md` for installation, setup, defining and
running migrations, dry runs, status monitoring, and configuration options.
## Common Migration Patterns
See `references/migration-patterns.md` for complete patterns with code examples covering:
See `references/migration-patterns.md` for complete patterns with code examples
covering:
- Adding a required field
- Deleting a field
@@ -125,12 +140,23 @@ See `references/migration-patterns.md` for complete patterns with code examples
## Common Pitfalls
1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first.
2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small.
3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes."
4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents.
5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it.
6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove.
1. **Making a field required before migrating data**: Convex rejects the deploy
because existing documents lack the field. Always widen the schema first.
2. **Using `.collect()` on large tables**: Hits transaction limits or causes
timeouts. Use the migrations component for proper batched pagination.
`.collect()` is only safe for tables you know are small.
3. **Not writing the new format before migrating**: Documents created during the
migration window will be missed, leaving unmigrated data after the migration
"completes."
4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic
before committing changes to production data. Catches bugs before they touch
real documents.
5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a
comment. Only delete after you are confident the data is no longer needed and
no code references it.
6. **Using crons for migration batches**: The migrations component handles
batching via recursive scheduling internally. Crons require manual cleanup
and an extra deploy to remove.
## Migration Checklist
@@ -4,7 +4,9 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#8B5CF6"
default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits."
default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying
the schema change, the existing data shape, and the widen-migrate-narrow
path before making edits."
policy:
allow_implicit_invocation: true
@@ -1,6 +1,7 @@
# Migration Patterns Reference
Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations.
Common migration patterns, zero-downtime strategies, and verification techniques
for Convex schema and data migrations.
## Adding a Required Field
@@ -30,7 +31,8 @@ users: defineTable({
## Deleting a Field
Mark the field optional first, migrate data to remove it, then remove from schema:
Mark the field optional first, migrate data to remove it, then remove from
schema:
```typescript
// Deploy 1: Make optional
@@ -51,7 +53,8 @@ export const removeIsPro = migrations.define({
## Changing a Field Type
Prefer creating a new field. You can combine adding and deleting in one migration:
Prefer creating a new field. You can combine adding and deleting in one
migration:
```typescript
// Deploy 1: Add new field, keep old field optional
@@ -98,7 +101,9 @@ export const extractPreferences = migrations.define({
});
```
Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window.
Make sure your code is already writing to the new `userPreferences` table for
new users before running this migration, so you don't miss documents created
during the migration window.
## Cleaning Up Orphaned Documents
@@ -120,18 +125,21 @@ export const deleteOrphanedEmbeddings = migrations.define({
## Zero-Downtime Strategies
During the migration window, your app must handle both old and new data formats. There are two main strategies.
During the migration window, your app must handle both old and new data formats.
There are two main strategies.
### Dual Write (Preferred)
Write to both old and new structures. Read from the old structure until migration is complete.
Write to both old and new structures. Read from the old structure until
migration is complete.
1. Deploy code that writes both formats, reads old format
2. Run migration on existing data
3. Deploy code that reads new format, still writes both
4. Deploy code that only reads and writes new format
This is preferred because you can safely roll back at any point, the old format is always up to date.
This is preferred because you can safely roll back at any point, the old format
is always up to date.
```typescript
// Bad: only writing to new structure before migration is done
@@ -167,7 +175,9 @@ Read both formats. Write only the new format.
2. Run migration on existing data
3. Deploy code that reads and writes only new format
This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format.
This avoids duplicating writes, which is useful when having two copies of data
could cause inconsistencies. The downside is that rolling back to before step 1
is harder, since new documents only have the new format.
```typescript
// Good: reading both formats, preferring new
@@ -179,7 +189,8 @@ function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
## Small Table Shortcut
For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component:
For small tables (a few thousand documents at most), you can migrate in a single
`internalMutation` without the component:
```typescript
import { internalMutation } from "./_generated/server";
@@ -200,7 +211,8 @@ export const backfillSmallTable = internalMutation({
npx convex run migrations:backfillSmallTable
```
Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component.
Only use `.collect()` when you are certain the table is small. For anything
larger, use the migrations component.
## Verifying a Migration
@@ -1,6 +1,8 @@
# Migrations Component Reference
Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations.
Complete guide to the
[`@convex-dev/migrations`](https://www.convex.dev/components/migrations)
component for batched, resumable Convex data migrations.
## Installation
@@ -30,11 +32,13 @@ export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();
```
The `DataModel` type parameter is optional but provides type safety for migration definitions.
The `DataModel` type parameter is optional but provides type safety for
migration definitions.
## Define a Migration
The `migrateOne` function processes a single document. The component handles batching and pagination automatically.
The `migrateOne` function processes a single document. The component handles
batching and pagination automatically.
```typescript
// convex/migrations.ts
@@ -90,7 +94,8 @@ export const runAll = migrations.runner([
npx convex run migrations:runAll
```
If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically.
If one fails, it stops and will not continue to the next. Call it again to retry
from where it left off. Completed migrations are skipped automatically.
## Dry Run
@@ -100,7 +105,8 @@ Test a migration before committing changes:
npx convex run migrations:runIt '{"dryRun": true}'
```
This runs one batch and then rolls back, so you can see what it would do without changing any data.
This runs one batch and then rolls back, so you can see what it would do without
changing any data.
## Check Migration Status
@@ -132,7 +138,8 @@ npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --pr
### Custom Batch Size
If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts:
If documents are large or the table has heavy write traffic, reduce the batch
size to avoid transaction limits or OCC conflicts:
```typescript
export const migrateHeavyTable = migrations.define({
@@ -158,7 +165,8 @@ export const fixEmptyNames = migrations.define({
### Parallelize Within a Batch
By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering:
By default each document in a batch is processed serially. Enable parallel
processing if your migration logic does not depend on ordering:
```typescript
export const clearField = migrations.define({
@@ -1,17 +1,22 @@
---
name: convex-performance-audit
description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification.
description: Audits Convex performance for reads, subscriptions, write contention, and
function limits. Use for slow features, insights findings, OCC conflicts, or
read amplification.
---
# Convex Performance Audit
Diagnose and fix performance problems in Convex applications, one problem class at a time.
Diagnose and fix performance problems in Convex applications, one problem class
at a time.
## When to Use
- A Convex page or feature feels slow or expensive
- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts
- Low-freshness read paths are using reactivity where point-in-time reads would do
- `npx convex insights --details` reports high bytes read, documents read, or
OCC conflicts
- Low-freshness read paths are using reactivity where point-in-time reads would
do
- OCC conflict errors or excessive mutation retries
- High subscription count or slow UI updates
- Functions approaching execution or transaction limits
@@ -21,27 +26,39 @@ Diagnose and fix performance problems in Convex applications, one problem class
- Initial Convex setup, auth setup, or component extraction
- Pure schema migrations with no performance goal
- One-off micro-optimizations without a user-visible or deployment-visible problem
- One-off micro-optimizations without a user-visible or deployment-visible
problem
## Guardrails
- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak
- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path
- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale
- Prefer simpler code when scale is small, traffic is modest, or the available
signals are weak
- Do not recommend digest tables, document splitting, fetch-strategy changes, or
migration-heavy rollouts unless there is a measured signal, a clearly
unbounded path, or a known hot read/write path
- In Convex, a simple scan on a small table is often acceptable. Do not invent
structural work just because a pattern is not ideal at large scale
## First Step: Gather Signals
Start with the strongest signal available:
1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals.
2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed.
- If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up.
3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth.
4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted.
1. If deployment Health insights are already available from the user or the
current context, treat them as a first-class source of performance signals.
2. If CLI insights are available, run `npx convex insights --details`. Use
`--prod`, `--preview-name`, or `--deployment-name` when needed.
- If the local repo's Convex CLI is too old to support `insights`, try
`npx -y convex@latest insights --details` before giving up.
3. If the repo already uses `convex-doctor`, you may treat its findings as
hints. Do not require it, and do not treat it as the source of truth.
4. If runtime signals are unavailable, audit from code anyway, but keep the
guardrails above in mind. Lack of insights is not proof of health, but it is
also not proof that a large refactor is warranted.
## Signal Routing
After gathering signals, identify the problem class and read the matching reference file.
After gathering signals, identify the problem class and read the matching
reference file.
| Signal | Reference |
| -------------------------------------------------------------- | ----------------------------------------- |
@@ -51,26 +68,31 @@ After gathering signals, identify the problem class and read the matching refere
| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` |
| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` |
Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain.
Multiple problem classes can overlap. Read the most relevant reference first,
then check the others if symptoms remain.
## Escalate Larger Fixes
If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing.
If the likely fix is invasive, cross-cutting, or migration-heavy, stop and
present options before editing.
Examples:
- introducing digest or summary tables across multiple flows
- splitting documents to isolate frequently-updated fields
- reworking pagination or fetch strategy across several screens
- switching to a new index or denormalized field that needs migration-safe rollout
- switching to a new index or denormalized field that needs migration-safe
rollout
When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow.
When correctness depends on handling old and new states during a rollout,
consult `skills/convex-migration-helper/SKILL.md` for the migration workflow.
## Workflow
### 1. Scope the problem
Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom.
Pick one concrete user flow from the actual project. Look at the codebase,
client pages, and API surface to find the flow that matches the symptom.
Write down:
@@ -90,27 +112,37 @@ For each function in the path:
4. Identify all sibling functions touching the same tables
5. Identify reactive stats, aggregates, or widgets rendered on the same page
In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems.
In Convex, every extra read increases transaction work, and every write can
invalidate reactive subscribers. Treat read amplification and invalidation
amplification as first-class problems.
### 3. Apply fixes from the relevant reference
Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order.
Read the reference file matching your problem class. Each reference includes
specific patterns, code examples, and a recommended fix order.
Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables.
Do not stop at the single function named by an insight. Trace sibling readers
and writers touching the same tables.
### 4. Fix sibling functions together
When one function touching a table has a performance bug, audit sibling functions for the same pattern.
When one function touching a table has a performance bug, audit sibling
functions for the same pattern.
After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables.
After finding one problem, inspect both sibling readers and sibling writers for
the same table family, including companion digest or summary tables.
Examples:
- If one list query switches from full docs to a digest table, inspect the other list queries for that table
- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table
- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk
- If one list query switches from full docs to a digest table, inspect the other
list queries for that table
- If one mutation isolates a frequently-updated field or splits a hot document,
inspect the other writers to the same table
- If one read path needs a migration-safe rollout for an unbackfilled field,
inspect sibling reads for the same rollout risk
Do not leave one path fixed and another path on the old pattern unless there is a clear product reason.
Do not leave one path fixed and another path on the old pattern unless there is
a clear product reason.
### 5. Verify before finishing
@@ -119,17 +151,26 @@ Confirm all of these:
1. Results are the same as before, no dropped records
2. Eliminated reads or writes are no longer in the path where expected
3. Fallback behavior works when denormalized or indexed fields are missing
4. Frequently-updated fields are isolated from widely-read documents where needed
5. Every relevant sibling reader and writer was inspected, not just the original function
4. Frequently-updated fields are isolated from widely-read documents where
needed
5. Every relevant sibling reader and writer was inspected, not just the original
function
## Reference Files
- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables
- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting
- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads
- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size
- `references/hot-path-rules.md` - Read amplification, invalidation,
denormalization, indexes, digest tables
- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document
splitting
- `references/subscription-cost.md` - Reactive query cost, subscription
granularity, point-in-time reads
- `references/function-budget.md` - Execution limits, transaction size, large
documents, payload size
Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit.
Also check the official
[Convex Best Practices](https://docs.convex.dev/understanding/best-practices/)
page for additional patterns covering argument validation, access control, and
code organization that may surface during the audit.
## Checklist
@@ -4,7 +4,9 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#EF4444"
default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes."
default_prompt: "Audit this Convex app for performance issues. Start with the strongest
signal available, identify the problem class, and suggest the smallest
high-impact fix before proposing bigger structural changes."
policy:
allow_implicit_invocation: true
@@ -1,14 +1,19 @@
# Function Budget
Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client.
Use these rules when functions are hitting execution limits, transaction size
errors, or returning excessively large payloads to the client.
## Core Principle
Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention.
Convex functions run inside transactions with budgets for time, reads, and
writes. Staying well within these limits is not just about avoiding errors, it
reduces latency and contention.
## Limits to Know
These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers.
These are the current values from the
[Convex limits docs](https://docs.convex.dev/production/state/limits). Check
that page for the latest numbers.
| Resource | Limit |
| --------------------------------- | ----------------------------------------------------- |
@@ -34,15 +39,18 @@ These are the current values from the [Convex limits docs](https://docs.convex.d
### Unbounded collection
A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents.
A query that calls `.collect()` on a table without a reasonable limit. As the
table grows, the query reads more and more documents.
### Large document reads on hot paths
Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view.
Reading documents with large fields (rich text, embedded media references, long
arrays) when only a small subset of the data is needed for the current view.
### Mutation doing too much work
A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction.
A single mutation that updates hundreds of documents, backfills data, or
rebuilds derived state in one transaction.
### Returning too much data to the client
@@ -70,13 +78,16 @@ const messages = await ctx.db
### 2. Read smaller shapes
If the list page only needs title, author, and date, do not read full documents with rich content fields.
If the list page only needs title, author, and date, do not read full documents
with rich content fields.
Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern.
Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the
digest table pattern.
### 3. Break large mutations into batches
If a mutation needs to update hundreds of documents, split it into a self-scheduling chain.
If a mutation needs to update hundreds of documents, split it into a
self-scheduling chain.
```ts
// Bad: one mutation updating every row
@@ -118,9 +129,12 @@ export const backfillBatch = internalMutation({
### 4. Move heavy work to actions
Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead.
Queries and mutations run inside Convex's transactional runtime with strict
budgets. If you need to do CPU-intensive computation, call external APIs, or
process large files, use an action instead.
Actions run outside the transaction and can call mutations to write results back.
Actions run outside the transaction and can call mutations to write results
back.
```ts
// Bad: heavy computation inside a mutation
@@ -144,7 +158,8 @@ export const processUpload = action({
### 5. Trim return values
Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning.
Only return what the client needs. If a query fetches full documents but the
component only renders a few fields, map the results before returning.
```ts
// Bad: returns full documents including large content fields
@@ -172,7 +187,9 @@ export const list = query({
### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions
Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost.
Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead
compared to calling a plain TypeScript helper function. They run in the same
transaction but pay extra per-call cost.
```ts
// Bad: unnecessary overhead from ctx.runQuery inside a mutation
@@ -194,11 +211,15 @@ export const createProject = mutation({
});
```
Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else.
Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there,
but prefer helpers everywhere else.
### 7. Avoid unnecessary `runAction` calls
`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime).
`runAction` from within an action creates a separate function invocation with
its own memory and CPU budget. The parent action just sits idle waiting. Replace
with a plain TypeScript function call unless you need a different runtime (e.g.
calling Node.js code from the Convex runtime).
```ts
// Bad: runAction overhead for no reason
@@ -228,5 +249,6 @@ export const processItems = action({
2. `npx convex insights --details` shows reduced bytes read
3. Large mutations are batched and self-scheduling
4. Client payloads are reasonably sized for the UI they serve
5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible
5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with
helpers where possible
6. Sibling functions with similar patterns were checked
@@ -1,6 +1,8 @@
# Hot Path Rules
Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes.
Use these rules when the top-level workflow points to read amplification,
denormalization, index rollout, reactive query cost, or invalidation-heavy
writes.
## Contents
@@ -10,8 +12,10 @@ Use these rules when the top-level workflow points to read amplification, denorm
- 2. Minimize Data Sources (denormalization, fallback rule)
- 3. Minimize Row Size (digest tables)
- 4. Skip No-Op Writes
- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write)
- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills)
- 5. Match Consistency To Read Patterns (high-read/low-write,
high-read/high-write)
- Convex-Specific Notes (reactive queries, point-in-time reads, triggers,
aggregates, backfills)
- Verification
## Core Principle
@@ -22,11 +26,13 @@ Think:
`cost x calls_per_second x 86400`
In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync.
In Convex, every write can also fan out into reactive invalidation, replication
work, and downstream sync.
## Consistency Rule
If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern.
If you fix a hot-path pattern for one function, audit sibling functions touching
the same tables for the same pattern.
Do this especially for:
@@ -37,7 +43,11 @@ Do this especially for:
## 1. Push Filters To Storage
Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned.
Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB
scan mean you already paid for the read. The Convex `.filter()` method has the
same performance as filtering in JS, it does not push the predicate to the
storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the
documents scanned.
Prefer:
@@ -87,17 +97,22 @@ export const listOpen = query({
### Migration rule for indexes
New indexes on partially backfilled fields can create correctness bugs during rollout.
New indexes on partially backfilled fields can create correctness bugs during
rollout.
Important Convex detail:
`undefined !== false`
If an older document is missing a field entirely, it will not match a compound index entry that expects `false`.
If an older document is missing a field entirely, it will not match a compound
index entry that expects `false`.
Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify.
Do not trust old comments saying a field is "not backfilled" or "already
backfilled". Verify.
If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`.
If correctness depends on handling old and new states during rollout, do not
improvise a partial-backfill workaround in the hot path. Use a migration-safe
rollout and consult `skills/convex-migration-helper/SKILL.md`.
```ts
// Bad: optional booleans can miss older rows where the field is undefined
@@ -115,7 +130,10 @@ const projects = await ctx.db
### Check for redundant indexes
Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete.
Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need
`by_foo_and_bar`, since you can query it with just the `foo` condition and omit
`bar`. Extra indexes add storage cost and write overhead on every insert, patch,
and delete.
```ts
// Bad: two indexes where one would do
@@ -126,19 +144,24 @@ defineTable({ team: v.id("teams"), user: v.id("users") })
```ts
// Good: single compound index serves both query patterns
defineTable({ team: v.id("teams"), user: v.id("users") }).index(
"by_team_and_user",
["team", "user"],
);
defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [
"team",
"user",
]);
```
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first.
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` +
`_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` +
`bar` + `_creationTime`. If you need results sorted by `foo` then
`_creationTime`, you need the single-field index because the compound one would
sort by `bar` first.
## 2. Minimize Data Sources
Trace every read.
If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path.
If a function resolves a foreign key for a tiny display field and a denormalized
copy already exists, prefer the denormalized field on the hot path.
### When to denormalize
@@ -152,7 +175,8 @@ Useful mental model:
`join_cost = rows_per_page x foreign_doc_size x pages_per_second`
Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not.
Small-table joins are often fine. Large-document joins for tiny fields on hot
list pages are usually not.
### Fallback rule
@@ -171,8 +195,7 @@ const ownerName = project.ownerName ?? "Unknown owner";
```ts
// Good: denormalized data is an optimization, not the only source of truth
const ownerName =
project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
```
Bad lookup map pattern:
@@ -196,9 +219,11 @@ const ownersById =
### No denormalized copy yet
Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table.
Prefer adding fields to an existing summary, companion, or digest table instead
of bloating the primary hot-path table.
If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan.
If introducing the new field or table requires a staged rollout, backfill, or
old/new-shape handling, use the migration helper skill for the rollout plan.
Rollout order:
@@ -209,7 +234,8 @@ Rollout order:
## 3. Minimize Row Size
Hot list pages should read the smallest document shape that still answers the UI.
Hot list pages should read the smallest document shape that still answers the
UI.
Prefer summary or digest tables over full source tables when:
@@ -217,12 +243,17 @@ Prefer summary or digest tables over full source tables when:
- source documents are large
- the query is high volume
An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page.
An 800 byte summary row is materially cheaper than a 3 KB full document on a hot
page.
Digest tables are a tradeoff, not a default:
- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost
- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit
- Worth it when the path is clearly hot, the source rows are much larger than
the UI needs, or many readers are repeatedly paying the same join and payload
cost
- Probably not worth it when an indexed read on the source table is already
cheap enough, the table is still small, or the extra write and migration
complexity would dominate the benefit
```ts
// Bad: list page reads source docs, then joins owner data per row
@@ -243,11 +274,14 @@ const projects = await ctx.db
## 4. Isolate Frequently-Updated Fields
Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to.
Convex already no-ops unchanged writes. The invalidation problem here is real
writes hitting documents that many queries subscribe to.
Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them.
Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status
off widely-read documents when most readers do not need them.
Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document.
Apply this across sibling writers too. Splitting one write path does not help
much if three other mutations still update the same widely-read document.
```ts
// Bad: every presence heartbeat invalidates subscribers to the whole profile
@@ -290,7 +324,9 @@ Prefer:
- local state for pagination
- caching where appropriate
Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns.
Do not treat subscriptions as automatically wrong here. Prefer point-in-time
reads only when the product does not need live freshness and the reactive cost
is material. See `subscription-cost.md` for detailed patterns.
### High-read, high-write
@@ -306,18 +342,21 @@ Reactive queries may be worth the ongoing cost.
### Reactive queries
Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query.
Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set
for the query.
On the client:
- `useQuery` creates a live subscription
- `usePaginatedQuery` creates a live subscription per page
For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically.
For low-freshness flows, consider a point-in-time read instead of a live
subscription only when the product does not need updates pushed automatically.
### Point-in-time reads
Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful.
Framework helpers, server-rendered fetches, or one-shot client reads can avoid
ongoing subscription cost when live updates are not useful.
Use them for:
@@ -328,7 +367,8 @@ Use them for:
### Triggers and fan-out
Triggers fire on every write, including writes that did not materially change the document.
Triggers fire on every write, including writes that did not materially change
the document.
When a write exists only to keep derived state in sync:
@@ -349,7 +389,8 @@ for global stats that do not need live updates every second.
### Backfills
For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component.
For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs
or the migrations component.
Deploy code that can handle both states before running the backfill.
@@ -1,10 +1,14 @@
# OCC Conflict Resolution
Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables.
Use these rules when insights, logs, or dashboard health show OCC (Optimistic
Concurrency Control) conflicts, mutation retries, or write contention on hot
tables.
## Core Principle
Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency.
Convex uses optimistic concurrency control. When two transactions read or write
overlapping data, one succeeds and the other retries automatically. High
contention means wasted work and increased latency.
## Symptoms
@@ -17,21 +21,31 @@ Convex uses optimistic concurrency control. When two transactions read or write
### Hot documents
Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record.
Multiple mutations writing to the same document concurrently. Classic examples:
a global counter, a shared settings row, or a "last updated" timestamp on a
parent record.
### Broad read sets causing false conflicts
A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified.
A query that scans a large table range creates a broad read set. If any write
touches that range, the query's transaction conflicts even if the specific
document the query cared about was not modified.
### Fan-out from triggers or cascading writes
A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others.
A single user action triggers multiple mutations that all touch related
documents. Each mutation competes with the others.
Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function.
Database triggers (e.g. from `convex-helpers`) run inside the same transaction
as the mutation that caused them. If a trigger does heavy work, reads extra
tables, or writes to many documents, it extends the transaction's read/write set
and increases the window for conflicts. Keep trigger logic minimal, or move
expensive derived work to a scheduled function.
### Write-then-read chains
A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up.
A mutation writes a document, then a reactive query re-reads it, then another
mutation writes it again. Under load, these chains stack up.
## Fix Order
@@ -75,7 +89,9 @@ Aggregate the shards in a query or scheduled job when you need the total.
### 3. Move non-critical work to scheduled functions
If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set.
If a mutation does primary work plus secondary bookkeeping (analytics,
non-critical notifications, cache warming), the bookkeeping extends the
transaction's lifetime and read/write set.
```ts
// Bad: canonical write and derived work happen in the same transaction
@@ -98,13 +114,20 @@ await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, {
### 4. Combine competing writes
If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows.
If two mutations must update the same document atomically, consider whether they
can be combined into a single mutation call from the client, reducing round
trips and conflict windows.
Do not introduce artificial locks or queues unless the above steps have been tried first.
Do not introduce artificial locks or queues unless the above steps have been
tried first.
## Related: Invalidation Scope
Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern.
Splitting hot documents also reduces subscription invalidation, not just OCC
contention. If a document is written frequently and read by many queries, those
queries re-run on every write even when the fields they care about have not
changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated
fields") for that pattern.
## Verification
@@ -1,14 +1,20 @@
# Subscription Cost
Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes.
Use these rules when the problem is too many reactive subscriptions, queries
invalidating too frequently, or React components re-rendering excessively due to
Convex state changes.
## Core Principle
Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with:
Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The
server tracks the query's read set and re-executes the query whenever any
document in that read set changes. Subscription cost scales with:
`subscriptions x invalidation_frequency x query_cost`
Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle.
Subscriptions are not inherently bad. Convex reactivity is often the right
default. The goal is to reduce unnecessary invalidation work, not to eliminate
subscriptions on principle.
## Symptoms
@@ -22,35 +28,47 @@ Subscriptions are not inherently bad. Convex reactivity is often the right defau
### Reactive queries on low-freshness flows
Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth.
Some user flows are read-heavy and do not need live updates every time the
underlying data changes. In those cases, ongoing subscriptions may cost more
than they are worth.
### Overly broad queries
A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation.
A query that returns a large result set invalidates whenever any document in
that set changes. The broader the query, the more frequent the invalidation.
### Too many subscriptions per page
A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor.
A page with 20 list items, each running its own `useQuery` to fetch related
data, creates 20+ subscriptions per visitor.
### Paginated queries keeping all pages live
`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive.
`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a
page where a user has scrolled through 10 pages, all 10 stay reactive.
### Frequently-updated fields on widely-read documents
A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason.
A document that many queries touch gets a frequently-updated field (like
`lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates
every subscription that reads the document, even if those subscriptions never
use the field. This is different from OCC conflicts (see `occ-conflicts.md`),
which are write-vs-write contention. This is write-vs-subscription: the write
succeeds fine, but it forces hundreds of queries to re-run for no reason.
## Fix Order
### 1. Use point-in-time reads when live updates are not valuable
Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data.
Keep `useQuery` and `usePaginatedQuery` by default when the product benefits
from fresh live data.
Consider a point-in-time read instead when all of these are true:
- the flow is high-read
- the underlying data changes less often than users need to see
- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable
- explicit refresh, periodic refresh, or a fresh read on navigation is
acceptable
Possible implementations depend on environment:
@@ -99,7 +117,8 @@ Keep reactive for:
### 2. Batch related data into fewer queries
Instead of N components each fetching their own related data, fetch it in a single query.
Instead of N components each fetching their own related data, fetch it in a
single query.
```ts
// Bad: each card fetches its own author
@@ -119,13 +138,17 @@ function ProjectList() {
}
```
This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N.
This can use denormalized fields or server-side joins in the query handler.
Either way, it is one subscription instead of N.
This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count.
This is not automatically better. If the combined query becomes much broader and
invalidates much more often, several narrower subscriptions may be the better
tradeoff. Optimize for total invalidation cost, not raw subscription count.
### 3. Use skip to avoid unnecessary subscriptions
The `"skip"` value prevents a subscription from being created when the arguments are not ready.
The `"skip"` value prevents a subscription from being created when the arguments
are not ready.
```ts
// Bad: subscribes with undefined args, wastes a subscription slot
@@ -134,15 +157,14 @@ const profile = useQuery(api.users.getProfile, { userId: selectedId! });
```ts
// Good: skip when there is nothing to fetch
const profile = useQuery(
api.users.getProfile,
selectedId ? { userId: selectedId } : "skip",
);
const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip");
```
### 4. Isolate frequently-updated fields into separate documents
If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes.
If a document is widely read but has a field that changes often, move that field
to a separate document. Queries that do not need the field will no longer be
invalidated by its writes.
```ts
// Bad: lastSeen lives on the user doc, every heartbeat invalidates
@@ -167,17 +189,31 @@ const heartbeats = defineTable({
});
```
Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly.
Queries that only need `name` and `email` no longer re-run on every heartbeat.
Queries that actually need online status fetch the heartbeat document
explicitly.
For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat.
For an even further optimization, if you only need a coarse online/offline
boolean rather than the exact `lastSeen` timestamp, add a separate presence
document with an `isOnline` flag. Update it immediately when a user comes
online, and use a cron to batch-mark users offline when their heartbeat goes
stale. This way the presence query only invalidates when online status actually
changes, not on every heartbeat.
### 5. Use the aggregate component for counts and sums
Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table.
Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert
or delete to the table. The
[`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate)
component maintains denormalized COUNT, SUM, and MAX values efficiently so you
do not need a reactive query scanning the full table.
Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively.
Use it for leaderboards, totals, "X items" badges, or any stat that would
otherwise require scanning many rows reactively.
If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables.
If the aggregate component is not appropriate, prefer point-in-time reads for
global stats, or precomputed summary rows updated by a cron or trigger, over
reactive queries that scan large tables.
### 6. Narrow query read sets
@@ -205,7 +241,9 @@ Writes to fields not in the digest table do not invalidate the digest query.
### 7. Remove `Date.now()` from queries
Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed.
Using `Date.now()` inside a query defeats Convex's query cache. The cache is
invalidated frequently to avoid showing stale time-dependent results, which
increases database work even when the underlying data has not changed.
```ts
// Bad: Date.now() defeats query caching and causes frequent re-evaluation
@@ -223,19 +261,25 @@ const releasedPosts = await ctx.db
.take(100);
```
If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry.
If the query must compare against a time value, pass it as an explicit argument
from the client and round it to a coarse interval (e.g. the most recent minute)
so requests within that window share the same cache entry.
### 8. Consider pagination strategy
For long lists where users scroll through many pages:
- If the data does not need live updates, use point-in-time fetching with manual "load more"
- If it does need live updates, accept the subscription cost but limit the number of loaded pages
- If the data does not need live updates, use point-in-time fetching with manual
"load more"
- If it does need live updates, accept the subscription cost but limit the
number of loaded pages
- Consider whether older pages can be unloaded as the user scrolls forward
### 9. Separate backend cost from UI churn
If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether.
If the main problem is loading flash or UI churn when query arguments change,
stabilizing the reactive UI behavior may be better than replacing reactivity
altogether.
Treat this as a UX problem first when:
@@ -248,5 +292,6 @@ Treat this as a UX problem first when:
1. Subscription count in dashboard is lower for the affected pages
2. UI responsiveness has improved
3. React profiling shows fewer unnecessary re-renders
4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily
4. Surfaces that do not need live updates are not paying for persistent
subscriptions unnecessarily
5. Sibling pages with similar patterns were updated consistently
+53 -30
View File
@@ -1,6 +1,7 @@
---
name: convex-quickstart
description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run.
description: Creates or adds Convex to an app. Use for new Convex projects, npm create
convex@latest, frontend setup, env vars, or the first npx convex dev run.
---
# Convex Quickstart
@@ -15,8 +16,10 @@ Set up a working Convex project as fast as possible.
## When Not to Use
- The project already has Convex installed and `convex/` exists - just start building
- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill
- The project already has Convex installed and `convex/` exists - just start
building
- You only need to add auth to an existing Convex app - use the
`convex-setup-auth` skill
## Workflow
@@ -28,7 +31,8 @@ Set up a working Convex project as fast as possible.
## Path 1: New Project (Recommended)
Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together.
Use the official scaffolding tool. It creates a complete project with the
frontend framework, Convex backend, and all config wired together.
### Pick a template
@@ -42,7 +46,8 @@ Use the official scaffolding tool. It creates a complete project with the fronte
| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui |
| `bare` | Convex backend only, no frontend |
If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes.
If the user has not specified a preference, default to `react-vite-shadcn` for
simple apps or `nextjs-shadcn` for apps that need SSR or API routes.
You can also use any GitHub repo as a template:
@@ -61,7 +66,8 @@ cd my-app
npm install
```
The scaffolding tool creates files but does not run `npm install`, so you must run it yourself.
The scaffolding tool creates files but does not run `npm install`, so you must
run it yourself.
To scaffold in the current directory (if it is empty):
@@ -72,20 +78,28 @@ npm install
### Start the dev loop
`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly.
`npx convex dev` is a long-running watcher process that syncs backend code to a
Convex deployment on every save. It also requires authentication on first run
(browser-based OAuth). Both of these make it unsuitable for an agent to run
directly.
**Ask the user to run this themselves:**
Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will:
Tell the user to run `npx convex dev` in their terminal. On first run it will
prompt them to log in or develop anonymously. Once running, it will:
- Create a Convex project and dev deployment
- Write the deployment URL to `.env.local`
- Create the `convex/` directory with generated types
- Watch for changes and sync continuously
The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`.
The user should keep `npx convex dev` running in the background while you work
on code. The watcher will automatically pick up any files you create or edit in
`convex/`.
**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction.
**Exception - cloud or headless agents:** Environments that cannot open a
browser for interactive login should use Agent Mode (see below) to run
anonymously without user interaction.
### Start the frontend
@@ -122,7 +136,8 @@ Proceed to adding schema, functions, and UI.
## Path 2: Add Convex to an Existing App
Use this when the user already has a frontend project and wants to add Convex as the backend.
Use this when the user already has a frontend project and wants to add Convex as
the backend.
### Install
@@ -132,7 +147,10 @@ npm install convex
### Initialize and start dev loop
Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly.
Ask the user to run `npx convex dev` in their terminal. This handles login,
creates the `convex/` directory, writes the deployment URL to `.env.local`, and
starts the file watcher. See the notes in Path 1 about why the agent should not
run this directly.
### Wire up the provider
@@ -143,9 +161,7 @@ Create the `ConvexReactClient` at module scope, not inside a component:
```tsx
// Bad: re-creates the client on every render
function App() {
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL as string,
);
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
@@ -196,11 +212,7 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
@@ -213,7 +225,8 @@ export default function RootLayout({
#### Other frameworks
For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide:
For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the
matching quickstart guide:
- [Vue](https://docs.convex.dev/quickstart/vue)
- [Svelte](https://docs.convex.dev/quickstart/svelte)
@@ -237,7 +250,9 @@ The env var name depends on the framework:
## Agent Mode (Cloud and Headless Agents)
When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment.
When running in a cloud or headless agent environment where interactive browser
login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local
anonymous deployment.
Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline:
@@ -245,7 +260,8 @@ Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline:
CONVEX_AGENT_MODE=anonymous npx convex dev
```
This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment.
This runs a local Convex backend on the VM without requiring authentication, and
avoids conflicting with the user's personal dev deployment.
## Verify the Setup
@@ -257,7 +273,8 @@ After setup, confirm everything is working:
## Writing Your First Function
Once the project is set up, create a schema and a query to verify the full loop works.
Once the project is set up, create a schema and a query to verify the full loop
works.
`convex/schema.ts`:
@@ -294,7 +311,8 @@ export const create = mutation({
});
```
Use in a React component (adjust the import path based on your file location relative to `convex/`):
Use in a React component (adjust the import path based on your file location
relative to `convex/`):
```tsx
import { useQuery, useMutation } from "convex/react";
@@ -317,7 +335,8 @@ function Tasks() {
## Development vs Production
Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save.
Always use `npx convex dev` during development. It runs against your personal
dev deployment and syncs code on save.
When ready to ship, deploy to production:
@@ -325,21 +344,25 @@ When ready to ship, deploy to production:
npx convex deploy
```
This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development.
This pushes to the production deployment, which is separate from dev. Do not use
`deploy` during development.
## Next Steps
- Add authentication: use the `convex-setup-auth` skill
- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas)
- Design your schema: see
[Schema docs](https://docs.convex.dev/database/schemas)
- Build components: use the `convex-create-component` skill
- Plan a migration: use the `convex-migration-helper` skill
- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage)
- Add file storage: see
[File Storage docs](https://docs.convex.dev/file-storage)
- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling)
## Checklist
- [ ] Determined starting point: new project or existing app
- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template
- [ ] If new project: scaffolded with `npm create convex@latest` using
appropriate template
- [ ] If existing app: installed `convex` and wired up the provider
- [ ] User has `npx convex dev` running and connected to a deployment
- [ ] `convex/_generated/` directory exists with types
@@ -4,7 +4,9 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#F97316"
default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works."
default_prompt: "Set up Convex for this project as fast as possible. First decide whether
this is a new app or an existing app, then scaffold or integrate Convex and
verify the setup works."
policy:
allow_implicit_invocation: true
+73 -39
View File
@@ -1,25 +1,29 @@
---
name: convex-setup-auth
description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app.
description: Sets up Convex auth, identity mapping, and access control. Use for login, auth
providers, users tables, protected functions, or roles in a Convex app.
---
# Convex Authentication Setup
Implement secure authentication in Convex with user management and access control.
Implement secure authentication in Convex with user management and access
control.
## When to Use
- Setting up authentication for the first time
- Implementing user management (users table, identity mapping)
- Creating authentication helper functions
- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT)
- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom
JWT)
## When Not to Use
- Auth for a non-Convex backend
- Pure OAuth/OIDC documentation without a Convex implementation
- Debugging unrelated bugs that happen to surface near auth code
- The auth provider is already fully configured and the user only needs a one-line fix
- The auth provider is already fully configured and the user only needs a
one-line fix
## First Step: Choose the Auth Provider
@@ -27,34 +31,49 @@ Convex supports multiple authentication approaches. Do not assume a provider.
Before writing setup code:
1. Ask the user which auth solution they want, unless the repository already makes it obvious
2. If the repo already uses a provider, continue with that provider unless the user wants to switch
3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding
1. Ask the user which auth solution they want, unless the repository already
makes it obvious
2. If the repo already uses a provider, continue with that provider unless the
user wants to switch
3. If the user has not chosen a provider and the repo does not make it obvious,
ask before proceeding
Common options:
- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex
- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features
- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically
- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0
- Custom JWT provider - use when integrating an existing auth system not covered above
- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when
the user wants auth handled directly in Convex
- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses
Clerk or the user wants Clerk's hosted auth features
- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app
already uses WorkOS or the user wants AuthKit specifically
- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses
Auth0
- Custom JWT provider - use when integrating an existing auth system not covered
above
Look for signals in the repo before asking:
- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages
- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components
- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth
packages
- Existing files such as `convex/auth.config.ts`, auth middleware, provider
wrappers, or login components
- Environment variables that clearly point at a provider
## After Choosing a Provider
Read the provider's official guide and the matching local reference file:
- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md`
- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md`
- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md`
- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md`
- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then
`references/convex-auth.md`
- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then
`references/clerk.md`
- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then
`references/workos-authkit.md`
- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then
`references/auth0.md`
The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks.
The local reference files contain the concrete workflow, expected files and env
vars, gotchas, and validation checks.
Use those sources for:
@@ -67,15 +86,25 @@ Use those sources for:
For shared auth behavior, use the official Convex docs as the source of truth:
- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()`
- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage
- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance
- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth
- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for
`ctx.auth.getUserIdentity()`
- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth)
for optional app-level user storage
- [Authentication](https://docs.convex.dev/auth) for general auth and
authorization guidance
- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the
provider is Convex Auth
Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns.
For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table.
For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally.
After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration.
Prefer official docs over recalled steps, because provider CLIs and Convex Auth
internals change between versions. Inventing setup from memory risks outdated
patterns. For third-party providers, only add app-level user storage if the app
actually needs user documents in Convex. Not every app needs a `users` table.
For Convex Auth, follow the Convex Auth docs and built-in auth tables rather
than adding a parallel `users` table plus `storeUser` flow, because Convex Auth
already manages user records internally. After running provider initialization
commands, verify generated files and complete the post-init wiring steps the
provider reference calls out. Initialization commands rarely finish the entire
integration.
## Core Pattern: Protecting Backend Functions
@@ -101,9 +130,7 @@ export const getMyProfile = query({
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
},
});
@@ -115,15 +142,20 @@ export const getMyProfile = query({
2. Ask whether the user wants local-only setup or production-ready setup now
3. Read the matching provider reference file
4. Follow the official provider docs for current setup details
5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns
5. Follow the official Convex docs for shared backend auth behavior, user
storage, and authorization patterns
6. Only add app-level user storage if the docs and app requirements call for it
7. Add authorization checks for ownership, roles, or team access only where the app needs them
8. Verify login state, protected queries, environment variables, and production configuration if requested
7. Add authorization checks for ownership, roles, or team access only where the
app needs them
8. Verify login state, protected queries, environment variables, and production
configuration if requested
If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it.
For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done.
If the environment has browser automation tools, you can use them.
If it does not, give the user a short manual validation checklist instead.
If the flow blocks on interactive provider or deployment setup, ask the user
explicitly for the exact human step needed, then continue after they complete
it. For UI-facing auth flows, offer to validate the real sign-up or sign-in flow
after setup is done. If the environment has browser automation tools, you can
use them. If it does not, give the user a short manual validation checklist
instead.
## Reference Files
@@ -140,9 +172,11 @@ If it does not, give the user a short manual validation checklist instead.
- [ ] Read the relevant provider reference file
- [ ] Asked whether the user wants local-only setup or production-ready setup
- [ ] Used the official provider docs for provider-specific wiring
- [ ] Used the official Convex docs for shared auth behavior and authorization patterns
- [ ] Used the official Convex docs for shared auth behavior and authorization
patterns
- [ ] Only added app-level user storage if the app actually needs it
- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth
- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for
Convex Auth
- [ ] Added authentication checks in protected backend functions
- [ ] Added authorization checks where the app actually needs them
- [ ] Clear error messages ("Not authenticated", "Unauthorized")
@@ -4,7 +4,9 @@ interface:
icon_small: "./assets/icon.svg"
icon_large: "./assets/icon.svg"
brand_color: "#2563EB"
default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation."
default_prompt: "Set up authentication for this Convex app. Figure out the provider first,
then wire up the user model, identity mapping, and access control with the
smallest solid implementation."
policy:
allow_implicit_invocation: true
@@ -15,25 +15,35 @@ Use this when the app already uses Auth0 or the user wants Auth0 specifically.
3. Ask whether the user wants local-only setup or production-ready setup now
4. Read the official Convex and Auth0 guides before making changes
5. Ask whether they want the fastest setup path by installing the Auth0 CLI
6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI
6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as
possible through the CLI
7. If they do not want the CLI path, use the Auth0 dashboard path instead
8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up
8. Complete the relevant Auth0 frontend quickstart if the app does not already
have Auth0 wired up
9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID
10. Set environment variables for local and production environments
11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
12. Gate Convex-backed UI with Convex auth state
13. Try to verify Convex reports the user as authenticated after Auth0 login
14. If the refresh-token path fails, stop improvising and send the user back to the official docs
15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered
14. If the refresh-token path fails, stop improvising and send the user back to
the official docs
15. If the user wants production-ready setup, make sure the production Auth0
tenant and env vars are also covered
## What To Do
- Read the official Convex and Auth0 guide before writing setup code
- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet
- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?"
- Make sure the app has already completed the relevant Auth0 quickstart for its frontend
- Prefer the Auth0 CLI path for mechanical setup if the user is willing to
install it, but do not present it as a fully validated end-to-end path yet
- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can
do more of this for you. If you want, I can install it and then only ask you
to log in when needed. Would you like me to do that?"
- Make sure the app has already completed the relevant Auth0 quickstart for its
frontend
- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0`
- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated
- If the Auth0 login or refresh flow starts failing in a way that is not clearly
explained by the docs, say that plainly and fall back to the official docs
instead of pretending the flow is validated
## Key Setup Areas
@@ -56,11 +66,15 @@ Use this when the app already uses Auth0 or the user wants Auth0 specifically.
## Concrete Steps
1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework
1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0
quickstart for the app's framework
2. Ask whether the user wants the Auth0 CLI path
3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login`
4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app
5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard
3. If yes, install Auth0 CLI and have the user authenticate it with
`auth0 login`
4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web
origins if creating a new app
5. If not using the CLI path, complete the relevant Auth0 frontend quickstart
and create the Auth0 app in the dashboard
6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard
7. Install the Auth0 SDK for the app's framework
8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID
@@ -69,31 +83,52 @@ Use this when the app already uses Auth0 or the user wants Auth0 specifically.
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0`
12. Run the normal Convex dev or deploy flow after backend config changes
13. Try the official provider config shown in the Convex docs
14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now
15. Only claim success if the user can sign in and Convex recognizes the authenticated session
16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too
14. If login works but Convex auth or token refresh fails in a way you cannot
clearly resolve, stop and tell the user to follow the official docs manually
for now
15. Only claim success if the user can sign in and Convex recognizes the
authenticated session
16. If the user wants production-ready setup, configure the production Auth0
tenant values and production environment variables too
## Gotchas
- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch
- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant
- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard
- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced
- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end
- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled
- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation
- Keep dev and prod tenants separate if the project uses different Auth0 environments
- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work.
- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it.
- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately.
- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins
- The Convex docs assume the Auth0 side is already set up, so do not skip the
Auth0 quickstart if the app is starting from scratch
- The Auth0 CLI is often the fastest path for a fresh setup, but it still
requires the user to authenticate the CLI to their Auth0 tenant
- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself
instead of bouncing them through the dashboard
- If login succeeds but Convex still reports unauthenticated, double-check
`convex/auth.config.ts` and whether the backend config was synced
- We were able to automate Auth0 app creation and Convex config wiring, but we
did not fully validate the refresh-token path end to end
- In validation, the documented `useRefreshTokens={true}` and
`cacheLocation="localstorage"` setup hit refresh-token failures, so do not
present that path as settled
- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep
inventing fixes indefinitely, send the user back to the official docs and
explain that this path is still under investigation
- Keep dev and prod tenants separate if the project uses different Auth0
environments
- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token".
Both need to work.
- If the repo already uses Auth0, preserve existing redirect and tenant
configuration unless the user asked to change it.
- Do not assume the local Auth0 tenant settings match production. Verify the
production domain, client ID, and callback URLs separately.
- For local dev, make sure the Auth0 app settings match the app's real local
port for callback URLs, logout URLs, and web origins
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered
- Verify production environment variables and redirect settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
- If the answer is production-ready, make sure the production Auth0 tenant
values, callback URLs, and Convex deployment config are all covered
- Verify production environment variables and redirect settings before calling
the task complete
- Do not silently write a notes file into the repo by default. If the user wants
rollout or handoff docs, create one explicitly.
## Validation
@@ -101,9 +136,13 @@ Use this when the app already uses Auth0 or the user wants Auth0 specifically.
- Verify Convex-authenticated UI renders only after Convex auth state is ready
- Verify protected Convex queries succeed after login
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
- Verify the Auth0 app settings match the real local callback and logout URLs during development
- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully
- If production-ready setup was requested, verify the production Auth0 configuration is also covered
- Verify the Auth0 app settings match the real local callback and logout URLs
during development
- If the Auth0 refresh-token path fails, mark the setup as not fully validated
and direct the user to the official docs instead of claiming the skill
completed successfully
- If production-ready setup was requested, verify the production Auth0
configuration is also covered
## Checklist
@@ -112,5 +151,6 @@ Use this when the app already uses Auth0 or the user wants Auth0 specifically.
- [ ] Complete the relevant Auth0 frontend setup
- [ ] Configure `convex/auth.config.ts`
- [ ] Set environment variables
- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs
- [ ] Verify Convex authenticated state after login, or explicitly tell the user
this path is still under investigation and send them to the official docs
- [ ] If requested, configure the production deployment too
@@ -5,7 +5,8 @@ Official docs:
- https://docs.convex.dev/auth/clerk
- https://clerk.com/docs/guides/development/integrations/databases/convex
Use this when the app already uses Clerk or the user wants Clerk's hosted auth features.
Use this when the app already uses Clerk or the user wants Clerk's hosted auth
features.
## Workflow
@@ -20,15 +21,21 @@ Use this when the app already uses Clerk or the user wants Clerk's hosted auth f
6. Follow the correct framework section in the official docs
7. Complete the backend and client wiring
8. Verify Convex reports the user as authenticated after login
9. If the user wants production-ready setup, make sure the production Clerk config is also covered
9. If the user wants production-ready setup, make sure the production Clerk
config is also covered
## What To Do
- Read the official Convex and Clerk guide before writing setup code
- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application
- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active
- Match the guide to the app's framework, usually React, Next.js, or TanStack Start
- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth`
- If the user does not already have Clerk set up, send them to
`https://dashboard.clerk.com/sign-up` to create an account and
`https://dashboard.clerk.com/apps/new` to create an application
- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex
integration is not already active
- Match the guide to the app's framework, usually React, Next.js, or TanStack
Start
- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and
`useAuth`
## Key Setup Areas
@@ -36,7 +43,8 @@ Use this when the app already uses Clerk or the user wants Clerk's hosted auth f
- configure `convex/auth.config.ts` with the Clerk issuer domain
- set the required Clerk environment variables
- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk`
- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading`
- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`,
and `AuthLoading`
## Files and Env Vars To Expect
@@ -54,13 +62,16 @@ Use this when the app already uses Clerk or the user wants Clerk's hosted auth f
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps
- `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required
`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs.
`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk
Frontend API URL value. Do not treat them as two different URLs.
## Concrete Steps
1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up`
2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new`
3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed
2. If needed, create a Clerk application at
`https://dashboard.clerk.com/apps/new`
3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the
publishable key, plus the secret key for Next.js where needed
4. Open `https://dashboard.clerk.com/apps/setup/convex`
5. Activate the Convex integration in Clerk if it is not already active
6. Copy the Clerk Frontend API URL shown there
@@ -72,35 +83,52 @@ Use this when the app already uses Clerk or the user wants Clerk's hosted auth f
12. Wrap the app in `ClerkProvider`
13. Use Convex auth helpers for authenticated rendering
14. Run the normal Convex dev or deploy flow after updating backend auth config
15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too
15. If the user wants production-ready setup, configure the production Clerk
values and production issuer domain too
## Gotchas
- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render
- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper
- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config
- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests.
- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it.
- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately.
- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key.
- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex`
- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects.
- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether
Convex-authenticated UI can render
- For Next.js, keep server and client boundaries in mind when creating the
Convex provider wrapper
- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy
flow so the backend picks up the new config
- Do not stop at "Clerk login works". The important check is that Convex also
sees the session and can authenticate requests.
- If the repo already uses Clerk, preserve its existing auth flow unless the
user asked to change it.
- Do not assume the same Clerk values work for both dev and production. Check
the production issuer domain and publishable key separately.
- The Convex setup page is where you get the Clerk Frontend API URL for Convex.
Keep using the Clerk API keys page for the publishable key and the secret key.
- If Convex says no auth provider matched the token, first confirm the Clerk
Convex integration was activated at
`https://dashboard.clerk.com/apps/setup/convex`
- After activating the Clerk Convex integration, sign out completely and sign
back in before retesting. An old Clerk session can keep using a token that
Convex rejects.
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included
- Verify production redirect URLs and any production Clerk domain values before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
- If the answer is production-ready, make sure production Clerk keys and issuer
configuration are included
- Verify production redirect URLs and any production Clerk domain values before
calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants
rollout or handoff docs, create one explicitly.
## Validation
- Verify the user can sign in with Clerk
- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in
- If the Clerk integration was just activated, verify after a full Clerk
sign-out and fresh sign-in
- Verify `useConvexAuth()` reaches the authenticated state after Clerk login
- Verify protected Convex queries run successfully inside authenticated UI
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
- If production-ready setup was requested, verify the production Clerk configuration is also covered
- If production-ready setup was requested, verify the production Clerk
configuration is also covered
## Checklist
@@ -1,9 +1,10 @@
# Convex Auth
Official docs: https://docs.convex.dev/auth/convex-auth
Setup guide: https://labs.convex.dev/auth/setup
Official docs: https://docs.convex.dev/auth/convex-auth Setup guide:
https://labs.convex.dev/auth/setup
Use this when the user wants auth handled directly in Convex rather than through a third-party provider.
Use this when the user wants auth handled directly in Convex rather than through
a third-party provider.
## Workflow
@@ -16,7 +17,8 @@ Use this when the user wants auth handled directly in Convex rather than through
4. Read the Convex Auth setup guide before writing code
5. Make sure the project has a configured Convex deployment:
- run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set
- if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing
- if CLI configuration requires interactive human input, stop and ask the
user to complete that step before continuing
6. Install the auth packages:
- `npm install @convex-dev/auth @auth/core@0.37.0`
7. Run the initialization command:
@@ -28,30 +30,38 @@ Use this when the user wants auth handled directly in Convex rather than through
9. Add the required `authTables` to `convex/schema.ts`
10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider`
11. Configure at least one auth method in `convex/auth.ts`
12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code
12. Run `npx convex dev --once` or the normal dev flow to push the updated
schema and generated code
13. Verify the client can sign in successfully
14. Verify Convex receives authenticated identity in backend functions
15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well
16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex
15. If the user wants production-ready setup, make sure the same auth setup is
configured for the production deployment as well
16. Only add a `users` table and `storeUser` flow if the app needs app-level
user records inside Convex
## What This Reference Is For
- choosing Convex Auth as the default provider for a new Convex app
- understanding whether the app wants magic links, OTPs, OAuth, or passwords
- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior
- keeping the setup provider-specific while using the official Convex Auth docs
for identity and authorization behavior
## What To Do
- Read the Convex Auth setup guide before writing setup code
- Follow the setup flow from the docs rather than recreating it from memory
- If the app is new, consider starting from the official starter flow instead of hand-wiring everything
- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra
- If the app is new, consider starting from the official starter flow instead of
hand-wiring everything
- Treat `npx @convex-dev/auth` as a required initialization step for existing
apps, not an optional extra
## Concrete Steps
1. Install `@convex-dev/auth` and `@auth/core@0.37.0`
2. Run `npx convex dev` if the project does not already have a configured deployment
3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment
2. Run `npx convex dev` if the project does not already have a configured
deployment
3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to
finish configuring the Convex deployment
4. Run `npx @convex-dev/auth`
5. Confirm the generated auth setup is present before continuing:
- `convex/auth.config.ts`
@@ -60,46 +70,70 @@ Use this when the user wants auth handled directly in Convex rather than through
6. Add `authTables` to `convex/schema.ts`
7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry
8. Configure the selected auth methods in `convex/auth.ts`
9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed
9. Run `npx convex dev --once` or the normal dev flow so the updated schema and
auth files are pushed
10. Verify login locally
11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment
11. If the user wants production-ready setup, repeat the required auth
configuration against the production deployment
## Expected Files and Decisions
- `convex/schema.ts`
- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file
- frontend app entry such as `src/main.tsx` or the framework-equivalent provider
file
- generated Convex Auth setup produced by `npx @convex-dev/auth`
- an existing configured Convex deployment, or the ability to create one with `npx convex dev`
- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods
- an existing configured Convex deployment, or the ability to create one with
`npx convex dev`
- `convex/auth.ts` starts with `providers: []` until the app configures actual
sign-in methods
- Decide whether the user is creating a new app or adding auth to an existing app
- For a new app, prefer the official starter flow instead of rebuilding setup by hand
- Decide whether the user is creating a new app or adding auth to an existing
app
- For a new app, prefer the official starter flow instead of rebuilding setup by
hand
- Decide which auth methods the app needs:
- magic links or OTPs
- OAuth providers
- passwords
- Decide whether the user wants local-only setup or production-ready setup now
- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough
- Decide whether the app actually needs a `users` table inside Convex, or
whether provider identity alone is enough
## Gotchas
- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior.
- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project.
- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`.
- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing.
- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method.
- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured.
- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough.
- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand.
- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too.
- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory.
- Do not assume a specific sign-in method. Ask which methods the app needs
before wiring UI and backend behavior.
- `npx @convex-dev/auth` is important because it initializes the auth setup,
including the key material. Do not skip it when adding Convex Auth to an
existing project.
- `npx @convex-dev/auth` will fail if the project does not already have a
configured `CONVEX_DEPLOYMENT`.
- `npx convex dev` may require interactive setup for deployment creation or
project selection. If that happens, ask the user explicitly for that human
step instead of guessing.
- `npx @convex-dev/auth` does not finish the whole integration by itself. You
still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at
least one auth method.
- A project can still build even if `convex/auth.ts` still has `providers: []`,
so do not treat a successful build as proof that sign-in is fully configured.
- Convex Auth does not mean every app needs a `users` table. If the app only
needs authentication gates, `ctx.auth.getUserIdentity()` may be enough.
- If the app is greenfield, starting from the official starter flow is usually
better than partially recreating it by hand.
- Do not stop at local dev setup if the user expects production-ready auth. The
production deployment needs the auth setup too.
- Keep provider-specific setup and Convex Auth authorization behavior in the
official docs instead of inventing shared patterns from memory.
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment
- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
- If the answer is production-ready, make sure the auth configuration is applied
to the production deployment, not just the dev deployment
- Verify production-specific redirect URLs, auth method configuration, and
deployment settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants
rollout or handoff docs, create one explicitly.
## Human Handoff
@@ -112,32 +146,43 @@ If `npx convex dev` or deployment setup requires human input:
## Validation
- Verify the user can complete a sign-in flow
- Offer to validate sign up, sign out, and sign back in with the configured auth method
- If browser automation is available in the environment, you can do this directly
- If browser automation is not available, give the user a short manual validation checklist instead
- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions
- Offer to validate sign up, sign out, and sign back in with the configured auth
method
- If browser automation is available in the environment, you can do this
directly
- If browser automation is not available, give the user a short manual
validation checklist instead
- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend
functions
- Verify protected UI only renders after Convex-authenticated state is ready
- Verify environment variables and redirect settings match the current app environment
- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in
- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed
- If production-ready setup was requested, verify the production deployment is also configured correctly
- Verify environment variables and redirect settings match the current app
environment
- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration
once the app is meant to support real sign-in
- Run `npx convex dev --once` or the normal dev flow after setup changes and
confirm Convex codegen and push succeed
- If production-ready setup was requested, verify the production deployment is
also configured correctly
## Checklist
- [ ] Confirm the user wants Convex Auth specifically
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Ensure a Convex deployment is configured before running auth initialization
- [ ] Ensure a Convex deployment is configured before running auth
initialization
- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0`
- [ ] Run `npx convex dev` first if needed
- [ ] Run `npx @convex-dev/auth`
- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created
- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts`
were created
- [ ] Follow the setup guide for package install and wiring
- [ ] Add `authTables` to `convex/schema.ts`
- [ ] Replace `ConvexProvider` with `ConvexAuthProvider`
- [ ] Configure at least one auth method in `convex/auth.ts`
- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes
- [ ] Confirm which sign-in methods the app needs
- [ ] Verify the client can sign in and the backend receives authenticated identity
- [ ] Verify the client can sign in and the backend receives authenticated
identity
- [ ] Offer end-to-end validation of sign up, sign out, and sign back in
- [ ] If requested, configure the production deployment too
- [ ] Only add extra `users` table sync if the app needs app-level user records
@@ -6,7 +6,8 @@ Official docs:
- https://docs.convex.dev/auth/authkit/add-to-app
- https://docs.convex.dev/auth/authkit/auto-provision
Use this when the app already uses WorkOS or the user wants AuthKit specifically.
Use this when the app already uses WorkOS or the user wants AuthKit
specifically.
## Workflow
@@ -22,21 +23,28 @@ Use this when the app already uses WorkOS or the user wants AuthKit specifically
8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs
9. Wire the client provider and callback flow
10. Verify authenticated requests reach Convex
11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too
12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
11. If the user wants production-ready setup, make sure the production WorkOS
configuration is covered too
12. Only add `storeUser` or a `users` table if the app needs first-class user
rows inside Convex
## What To Do
- Read the official Convex and WorkOS AuthKit guide before writing setup code
- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team
- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra
- Follow the current setup flow from the docs instead of relying on older examples
- Determine whether the user wants a Convex-managed WorkOS team or an existing
WorkOS team
- Treat `convex.json` as a first-class part of the AuthKit setup, not an
optional extra
- Follow the current setup flow from the docs instead of relying on older
examples
## Key Setup Areas
- package installation for the app's framework
- `convex.json` with the `authKit` section for dev, and preview or prod if needed
- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration
- `convex.json` with the `authKit` section for dev, and preview or prod if
needed
- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and
redirect configuration
- `convex/auth.config.ts` wiring for WorkOS-issued JWTs
- client provider setup and token flow into Convex
- login callback and redirect configuration
@@ -55,42 +63,65 @@ Use this when the app already uses WorkOS or the user wants AuthKit specifically
- `VITE_WORKOS_REDIRECT_URI`
- `NEXT_PUBLIC_WORKOS_REDIRECT_URI`
For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps.
For a managed WorkOS team, `convex dev` can provision the AuthKit environment
and write local env vars such as `VITE_WORKOS_CLIENT_ID` and
`VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps.
## Concrete Steps
1. Choose Convex-managed or existing WorkOS team
2. Create or update `convex.json` with the `authKit` section for the framework in use
3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port
4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow
5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set`
2. Create or update `convex.json` with the `authKit` section for the framework
in use
3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local
redirect env vars match the app's actual local port
4. For a managed WorkOS team, run `npx convex dev` and follow the interactive
onboarding flow
5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from
the WorkOS dashboard and set them with `npx convex env set`
6. Create or update `convex/auth.config.ts` for WorkOS JWT validation
7. Run the normal Convex dev or deploy flow so backend config is synced
8. Wire the WorkOS client provider in the app
9. Configure callback and redirect handling
10. Verify the user can sign in and return to the app
11. Verify Convex sees the authenticated user after login
12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too
12. If the user wants production-ready setup, configure the production client
ID, API key, redirect URI, and deployment settings too
## Gotchas
- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious
- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys
- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
- The docs split setup between Convex-managed and existing WorkOS teams, so ask
which path the user wants if it is not obvious
- Keep dev and prod WorkOS configuration separate where the docs call for
different client IDs or API keys
- Only add `storeUser` or a `users` table if the app needs first-class user rows
inside Convex
- Do not mix dev and prod WorkOS credentials or redirect URIs
- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it
- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts.
- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation.
- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again.
- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config.
- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded."
- If the repo already contains WorkOS setup, preserve the current tenant model
unless the user wants to change it
- For managed WorkOS setup, `convex dev` is interactive the first time. In
non-interactive terminals, stop and ask the user to complete the onboarding
prompts.
- `convex.json` is not optional for the managed AuthKit flow. It drives redirect
URI, homepage URL, CORS configuration, and local env var generation.
- If the frontend starts on a different port than the one in `convex.json`, the
hosted WorkOS sign-in flow will point to the wrong callback URL. Update
`convex.json`, update the local redirect env var, and run `npx convex dev`
again.
- Vite can fall off `5173` if other apps are already running. Do not assume the
default port still matches the generated AuthKit config.
- A successful WorkOS sign-in should redirect back to the local callback route
and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS
page loaded."
## Production
- Ask whether the user wants dev-only setup or production-ready setup
- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered
- Verify the production redirect and callback settings before calling the task complete
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
- If the answer is production-ready, make sure the production WorkOS client ID,
API key, redirect URI, and Convex deployment config are all covered
- Verify the production redirect and callback settings before calling the task
complete
- Do not silently write a notes file into the repo by default. If the user wants
rollout or handoff docs, create one explicitly.
## Validation
@@ -99,8 +130,10 @@ For a managed WorkOS team, `convex dev` can provision the AuthKit environment an
- Verify Convex receives authenticated requests after login
- Verify `convex.json` matches the framework and chosen WorkOS setup path
- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path
- Verify environment variables differ correctly between local and production where needed
- If production-ready setup was requested, verify the production WorkOS configuration is also covered
- Verify environment variables differ correctly between local and production
where needed
- If production-ready setup was requested, verify the production WorkOS
configuration is also covered
## Checklist
+9 -4
View File
@@ -1,6 +1,7 @@
---
name: convex
description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task.
description: Routes general Convex requests to the right project skill. Use when the user
asks which Convex skill to use or gives an underspecified Convex app task.
---
# Convex
@@ -11,7 +12,8 @@ If a more specific Convex skill clearly matches the request, use that instead.
## Start Here
If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first.
If the project does not already have Convex AI guidance installed, or the
existing guidance looks stale, strongly recommend installing it first.
Preferred:
@@ -19,7 +21,9 @@ Preferred:
npx convex ai-files install
```
This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs:
This installs or refreshes the managed Convex AI files. It is the recommended
starting point for getting the official Convex guidelines in place and following
the current Convex AI setup described in the docs:
- [Convex AI docs](https://docs.convex.dev/ai)
@@ -39,7 +43,8 @@ After that, use the most specific Convex skill for the task:
- Planning or running a migration: `convex-migration-helper`
- Investigating performance issues: `convex-performance-audit`
If one of those clearly matches the user's goal, switch to it instead of staying in this skill.
If one of those clearly matches the user's goal, switch to it instead of staying
in this skill.
## When Not to Use
+51
View File
@@ -0,0 +1,51 @@
---
name: crabbox
description: Use when ClawHub needs remote Linux validation, CI-parity checks, broad Bun gates, hosted-service checks, desktop/VNC inspection, or Crabbox lease cleanup.
---
# Crabbox
Crabbox is ClawHub's agent-facing isolation layer. Use direct `blacksmith`
commands only as a backend emergency fallback; normal agents should go through
the repo scripts below.
## Fast Checks
Run from the repo root:
```sh
bun run crabbox:run -- --help
bun run crabbox:warmup -- --provider blacksmith-testbox --blacksmith-org openclaw --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job check
```
The wrapper prefers `../crabbox/bin/crabbox` when present and rejects stale
binaries that do not support the Blacksmith Testbox provider. For desktop UI
proof, use a Crabbox-owned provider such as `hetzner` or `aws`; the
`blacksmith-testbox` provider cannot expose VNC, screenshots, or desktop
artifacts.
## Common Remote Validation
Broad ClawHub gates:
```sh
bun run crabbox:run -- --provider blacksmith-testbox --shell -- "bun run ci:static"
bun run crabbox:run -- --provider blacksmith-testbox --shell -- "VITE_CONVEX_URL=https://example.invalid bun run coverage"
```
Reusable desktop lease:
```sh
bun run crabbox:warmup -- --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 120m
bun run crabbox:run -- --provider hetzner --id <cbx_id-or-slug> --keep --shell -- "bun run test"
bun run crabbox:stop -- --provider hetzner <cbx_id-or-slug>
```
## Cleanup
Stop leases created for the task before handoff unless the user asked to keep
one open for WebVNC inspection:
```sh
bun run crabbox:stop -- --provider <provider> <id-or-slug>
```
+21
View File
@@ -0,0 +1,21 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "ClawHub"
[setup]
script = "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts --detach"
[[actions]]
name = "Run"
icon = "run"
command = "bun run dev:worktree"
[[actions]]
name = "Convex Dev"
icon = "tool"
command = "bun run setup:worktree -- --quiet && bunx convex dev --typecheck=disable"
[[actions]]
name = "Seed Dev DB"
icon = "tool"
command = "bun run seed:dev"
+20
View File
@@ -0,0 +1,20 @@
[list]
url = "http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"
[[pre-start]]
env = "bun run setup:worktree -- --quiet"
[[pre-start]]
deps = "wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install"
[post-start]
dev = "bun scripts/dev-worktree.ts --detach --port {{ (repo ~ '-' ~ branch) | hash_port }}"
[pre-remove]
dev = "if test -f .codex/runtime/dev-worktree.pid; then pid=$(cat .codex/runtime/dev-worktree.pid); kill -TERM -$pid 2>/dev/null || kill $pid 2>/dev/null || true; rm -f .codex/runtime/dev-worktree.pid; fi"
[aliases]
dev = "wt --yes hook pre-start && bun scripts/dev-worktree.ts --detach --port {{ (repo ~ '-' ~ branch) | hash_port }}"
setup = "wt --yes hook pre-start"
stop = "if test -f .codex/runtime/dev-worktree.pid; then pid=$(cat .codex/runtime/dev-worktree.pid); kill -TERM -$pid 2>/dev/null || kill $pid 2>/dev/null || true; rm -f .codex/runtime/dev-worktree.pid; fi"
url = "echo http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"
+32
View File
@@ -0,0 +1,32 @@
profile: clawhub-check
provider: blacksmith-testbox
blacksmith:
org: openclaw
workflow: .github/workflows/ci-check-testbox.yml
job: check
ref: main
idleTimeout: 90m
debug: false
sync:
delete: true
checksum: false
gitSeed: true
fingerprint: true
baseRef: main
exclude:
- .artifacts
- .codex
- .DS_Store
- coverage
- dist
- dist-ssr
- node_modules
- playwright-report
- test-results
env:
allow:
- CI
- NODE_OPTIONS
- CLAWHUB_*
- VITE_CONVEX_URL
- VITE_CONVEX_SITE_URL
+1
View File
@@ -4,6 +4,7 @@ VITE_CONVEX_SITE_URL=
VITE_SOULHUB_SITE_URL=
VITE_SOULHUB_HOST=
VITE_SITE_MODE=
VITE_ENABLE_DEV_AUTH=
SITE_URL=http://localhost:3000
CONVEX_SITE_URL=
+30 -20
View File
@@ -5,7 +5,10 @@
# If you add overlapping rules below the secops block, include @openclaw/openclaw-secops
# on those entries too or you can silently remove required secops review.
# Security-sensitive code, config, workflows, and docs require secops review.
/.github/actions/ @openclaw/openclaw-secops
/.github/actionlint.yaml @openclaw/openclaw-secops
/.github/codeql/ @openclaw/openclaw-secops
/.github/dependabot.yml @openclaw/openclaw-secops
/.github/workflows/ @openclaw/openclaw-secops
/scripts/check-staged-secrets.mjs @openclaw/openclaw-secops
/scripts/clawhub-cli-npm-publish.sh @openclaw/openclaw-secops
@@ -22,9 +25,9 @@
/convex/httpApiV1/ @openclaw/openclaw-secops @Patrick-Erichsen
/convex/packagePublishTokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/maintenance.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/publishers.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/rateLimits.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/rescanRequests.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/skills.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/skillTransfers.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/tokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
@@ -42,6 +45,7 @@
/convex/lib/moderationEngine.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/moderationReasonCodes.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/packageRegistry.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/packageSearchDigest.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/packageSecurity.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/publishers.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/publishLimits.ts @openclaw/openclaw-secops @Patrick-Erichsen
@@ -53,25 +57,24 @@
/convex/lib/staticPublishScan.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/tokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/webhooks.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/model/packages/rescans.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/model/rescans/policy.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/model/skills/rescans.ts @openclaw/openclaw-secops @Patrick-Erichsen
# Frontend auth, admin, publish, upload, and security-review surfaces.
/src/lib/packageApi.ts @openclaw/openclaw-secops @Patrick-Erichsen
/src/lib/packageUpload.ts @openclaw/openclaw-secops @Patrick-Erichsen
/src/lib/roles.ts @openclaw/openclaw-secops @Patrick-Erichsen
/src/lib/uploadFiles.ts @openclaw/openclaw-secops @Patrick-Erichsen
/src/lib/uploadUtils.ts @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/admin.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/cli/auth.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/packages/new.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/publish-plugin.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/publish-skill.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/upload.tsx @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/upload/ @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/$owner/$slug/security/ @openclaw/openclaw-secops @Patrick-Erichsen
/src/routes/plugins/$name/security/ @openclaw/openclaw-secops @Patrick-Erichsen
/src/lib/packageApi.ts @openclaw/openclaw-secops @BunsDev
/src/lib/packageUpload.ts @openclaw/openclaw-secops @BunsDev
/src/lib/roles.ts @openclaw/openclaw-secops @BunsDev
/src/lib/uploadFiles.ts @openclaw/openclaw-secops @BunsDev
/src/lib/uploadUtils.ts @openclaw/openclaw-secops @BunsDev
/src/routes/admin.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/cli/auth.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/packages/new.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/plugins/publish.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/publish-plugin.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/publish-skill.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/skills/publish.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/upload.tsx @openclaw/openclaw-secops @BunsDev
/src/routes/upload/ @openclaw/openclaw-secops @BunsDev
/src/routes/$owner/$slug/security/ @openclaw/openclaw-secops @BunsDev
/src/routes/plugins/$name/security/ @openclaw/openclaw-secops @BunsDev
# CLI auth, admin, publishing, ownership, and package-contract surfaces.
/packages/clawhub/src/browserAuth.ts @openclaw/openclaw-secops @Patrick-Erichsen
@@ -86,7 +89,6 @@
/packages/clawhub/src/cli/commands/ownership.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/publish.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/rescan.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/transfer.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/sync.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/scanSkills.ts @openclaw/openclaw-secops @Patrick-Erichsen
@@ -96,18 +98,26 @@
/packages/clawhub/src/schema/schemas.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/schema/textFiles.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/openclawContract.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/index.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/pluginCategories.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/routes.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/schemas.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/src/textFiles.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/index.d.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/index.js @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/index.js.map @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/pluginCategories.d.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/pluginCategories.js @openclaw/openclaw-secops @Patrick-Erichsen
/packages/schema/dist/pluginCategories.js.map @openclaw/openclaw-secops @Patrick-Erichsen
# Security, auth, API, webhook, and deployment documentation.
/docs/acceptable-usage.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/api.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/auth.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/deploy.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/github-import.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/http-api.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/security.md @openclaw/openclaw-secops @Patrick-Erichsen
/docs/webhook.md @openclaw/openclaw-secops @Patrick-Erichsen
/specs/github-import.md @openclaw/openclaw-secops @Patrick-Erichsen
/public/api/v1/openapi.json @openclaw/openclaw-secops @Patrick-Erichsen
+104
View File
@@ -0,0 +1,104 @@
name: RFC
description: Propose a ClawHub policy, product, trust, or interface decision for feedback.
title: "RFC: "
labels:
- "type: rfc"
- "status: review"
body:
- type: markdown
attributes:
value: |
Use RFCs for decisions that need visible feedback before they become policy, product behavior, or public API contract. Accepted repo RFC files live under `rfcs/`, not `docs/`, so draft/decision records do not publish to the docs site. Keep sensitive enforcement details, private reports, exploit specifics, and scanner thresholds out of the public issue.
- type: dropdown
id: area
attributes:
label: Area
description: Pick the primary area this RFC affects.
options:
- Moderation / policy
- Security / trust
- Product / UX
- API / CLI
- Documentation
- Other
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: What problem, decision, or ambiguity does this RFC address?
placeholder: |
ClawHub needs a clearer policy for...
validations:
required: true
- type: textarea
id: goals
attributes:
label: Goals
description: What should this RFC achieve?
placeholder: |
- Make enforcement expectations understandable to users.
- Give moderators a consistent decision boundary.
validations:
required: true
- type: textarea
id: non_goals
attributes:
label: Non-goals
description: What is intentionally out of scope?
placeholder: |
- This RFC does not expose internal scanner thresholds.
- This RFC does not decide implementation details for every moderation tool.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: Describe the proposed policy, behavior, or decision.
placeholder: |
ClawHub should...
validations:
required: true
- type: textarea
id: examples
attributes:
label: Examples
description: Give concrete allowed, not allowed, or edge-case examples.
placeholder: |
Allowed:
- Defensive security review with explicit scope and evidence.
Not allowed:
- Account takeover, evasion, or non-consensual surveillance workflows.
Edge cases:
- ...
- type: textarea
id: user_impact
attributes:
label: User impact
description: How does this affect authors, users, moderators, API consumers, or external contributors?
placeholder: |
Authors will...
Users will...
Moderators will...
- type: textarea
id: open_questions
attributes:
label: Open questions
description: What feedback would be most useful before a decision?
placeholder: |
- Should appeals be handled in-product, through GitHub, or both?
- What examples would make this clearer?
validations:
required: true
- type: input
id: feedback_deadline
attributes:
label: Feedback deadline
description: Use an absolute date. Normal RFCs should stay open for 7-14 days unless urgent.
placeholder: "YYYY-MM-DD"
validations:
required: true
+13
View File
@@ -0,0 +1,13 @@
name: Setup Bun
description: Install the pinned Bun runtime and workspace dependencies.
runs:
using: composite
steps:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
- name: Install dependencies
shell: bash
run: bun install --frozen-lockfile
@@ -24,7 +24,6 @@ paths:
- convex/packages.ts
- convex/publishers.ts
- convex/rateLimits.ts
- convex/rescanRequests.ts
- convex/skills.ts
- convex/skillTransfers.ts
- convex/tokens.ts
@@ -53,9 +52,6 @@ paths:
- convex/lib/staticPublishScan.ts
- convex/lib/tokens.ts
- convex/lib/webhooks.ts
- convex/model/packages/rescans.ts
- convex/model/rescans/policy.ts
- convex/model/skills/rescans.ts
paths-ignore:
- "**/node_modules"
@@ -26,7 +26,6 @@ paths:
- packages/clawhub/src/cli/commands/ownership.ts
- packages/clawhub/src/cli/commands/packages.ts
- packages/clawhub/src/cli/commands/publish.ts
- packages/clawhub/src/cli/commands/rescan.ts
- packages/clawhub/src/cli/commands/sync.ts
- packages/clawhub/src/cli/commands/transfer.ts
- packages/clawhub/src/cli/scanSkills.ts
@@ -17,8 +17,9 @@ paths:
- src/components/DetailSecuritySummary.tsx
- src/components/MarkdownPreview.tsx
- src/components/PackageSourceChooser.tsx
- src/components/SecurityScannerPage.tsx
- src/components/SecurityAuditPage.tsx
- src/components/SkillSecurityScanResults.tsx
- src/components/securityAuditModel.ts
- src/lib/authErrorMessage.ts
- src/lib/packageApi.ts
- src/lib/packageUpload.ts
@@ -32,12 +33,18 @@ paths:
- src/routes/admin.tsx
- src/routes/cli/auth.tsx
- src/routes/packages/new.tsx
- src/routes/plugins/publish.tsx
- src/routes/publish-plugin.tsx
- src/routes/publish-skill.tsx
- src/routes/skills/publish.tsx
- src/routes/upload.tsx
- src/routes/upload
- src/routes/$owner/$slug/security-audit.tsx
- src/routes/$owner/$slug/security
- src/routes/plugins/$name/security-audit.tsx
- src/routes/plugins/$name/security
- src/routes/plugins/$scope/$name/security-audit.tsx
- src/routes/plugins/$scope/$name/security
paths-ignore:
- "**/node_modules"
+39
View File
@@ -0,0 +1,39 @@
## Summary
- What changed:
- Why:
## Linked Issue
- Closes #
- Related #
## Screenshots
For website/UI changes, attach screenshots or recordings from the real app. Include mobile/narrow views when layout changes.
- [ ] Screenshots/recordings attached, or `N/A`
## Behavioural Proof
Describe how you verified the user-facing behavior. For UI changes, include the path tested and what changed on screen. For backend/API changes, include the request, command, or scenario that proves the behavior.
- [ ] Behavioural proof included, or `N/A`
## Security / Trust Impact
- [ ] No security/trust impact
- [ ] Security/trust impact explained
## Data / Deploy Impact
- [ ] No data/deploy impact
- [ ] Data/deploy impact explained
## Verification
- [ ] `bun run ci:static`
- [ ] Focused tests for touched behavior:
- [ ] `bun run ci:unit` or `N/A` for docs/config-only:
- [ ] Broader gate when required (`ci:types-build`, `ci:packages`, `ci:e2e-http`, `ci:playwright-smoke`, `test:pw:local-auth`, `proof:ui`):
- [ ] Other:
+1 -1
View File
@@ -1,4 +1,4 @@
name: Blacksmith Testbox
name: Crabbox Testbox Backend
on:
workflow_dispatch:
+105 -45
View File
@@ -4,12 +4,21 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
VITE_CONVEX_URL: https://example.invalid
jobs:
build:
static:
name: static
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -18,56 +27,107 @@ jobs:
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
- uses: ./.github/actions/setup-bun
- name: Install
run: bun install --frozen-lockfile
- name: Peer deps
run: bun run check:peers
- name: Audit dependencies
run: bun audit
- name: Static checks
run: bun run ci:static
- name: Format
if: github.event_name == 'pull_request'
run: |
mapfile -d '' changed_files < <(
git diff --name-only --diff-filter=ACMR -z \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- \
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
)
unit:
name: unit
runs-on: ubuntu-latest
timeout-minutes: 15
if (( ${#changed_files[@]} == 0 )); then
echo "No changed files supported by oxfmt."
exit 0
fi
steps:
- uses: actions/checkout@v6
bun run format:check -- "${changed_files[@]}"
- name: Lint
run: bun run lint
- name: Test
run: bun run test
env:
VITE_CONVEX_URL: https://example.invalid
- uses: ./.github/actions/setup-bun
- name: Coverage
run: bun run coverage
env:
VITE_CONVEX_URL: https://example.invalid
run: bun run ci:unit
- name: ClawHub CLI Verify
run: bun run --cwd packages/clawhub verify
packages:
name: packages
runs-on: ubuntu-latest
timeout-minutes: 15
- name: Typecheck
run: |
bunx tsc --noEmit
bunx tsc -p packages/schema/tsconfig.json --noEmit
bunx tsc -p packages/clawhub/tsconfig.json --noEmit
steps:
- uses: actions/checkout@v6
- name: Build
run: bun run build
- uses: ./.github/actions/setup-bun
- name: Package checks
run: bun run ci:packages
types-build:
name: types-build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Typecheck and build
run: bun run ci:types-build
e2e-http:
name: e2e-http
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: HTTP e2e
run: bun run ci:e2e-http
playwright-smoke:
name: playwright-smoke
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Browser e2e
run: bun run ci:playwright-smoke
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
if-no-files-found: ignore
playwright-local-auth:
name: playwright-local-auth
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
run: bunx playwright install --with-deps chromium
- name: Local-auth browser e2e
run: bun run test:pw:local-auth
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: playwright-local-auth-report
path: playwright-report/
if-no-files-found: ignore
@@ -0,0 +1,285 @@
name: ClawHub CLI GitHub Release
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to create or repair, for example v0.17.0
required: true
type: string
main_run_id:
description: Optional successful main CI run id to include in release proof
required: false
type: string
preflight_run_id:
description: Optional successful CLI npm preflight run id to include in release proof
required: false
type: string
publish_run_id:
description: Optional successful CLI npm publish run id to include in release proof
required: false
type: string
update_existing:
description: Update an existing GitHub Release instead of failing
required: true
default: false
type: boolean
concurrency:
group: clawhub-cli-github-release-${{ inputs.tag }}
cancel-in-progress: false
permissions: {}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
jobs:
create_or_update_github_release:
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: Checkout release tooling
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
path: release-tools
- name: Checkout release tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
path: release
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA="$(git rev-parse HEAD)"
echo "RELEASE_SHA=$RELEASE_SHA" >> "$GITHUB_ENV"
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$RELEASE_SHA" "$RELEASE_MAIN_REF"; then
echo "Tagged commit ${RELEASE_SHA} is not contained in ${RELEASE_MAIN_REF}." >&2
exit 1
fi
node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
const releaseTag = process.env.RELEASE_TAG ?? "";
const pkg = JSON.parse(readFileSync("./packages/clawhub/package.json", "utf8"));
const version = String(pkg.version ?? "").trim();
const errors = [];
if (pkg.name !== "clawhub") {
errors.push(`packages/clawhub/package.json name must be "clawhub"; found "${pkg.name ?? ""}".`);
}
if (!/^\d+\.\d+\.\d+$/.test(version)) {
errors.push(`packages/clawhub/package.json version must be stable semver (X.Y.Z); found "${version || "<missing>"}".`);
}
if (!/^v\d+\.\d+\.\d+$/.test(releaseTag)) {
errors.push(`Release tag must match vX.Y.Z; found "${releaseTag || "<missing>"}".`);
}
if (releaseTag !== `v${version}`) {
errors.push(`Release tag ${releaseTag} does not match packages/clawhub/package.json version ${version}; expected v${version}.`);
}
if (errors.length > 0) {
for (const error of errors) console.error(error);
process.exit(1);
}
console.log(`Release metadata OK for clawhub@${version} (${releaseTag}).`);
EOF
working-directory: release
- name: Resolve release metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync("./packages/clawhub/package.json", "utf8"));
process.stdout.write(String(pkg.version ?? "").trim());
EOF
)"
NPM_DIST_JSON=""
for attempt in {1..12}; do
if NPM_DIST_JSON="$(npm view "clawhub@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then
break
fi
if [[ "$attempt" == "12" ]]; then
cat /tmp/npm-view-error >&2 || true
exit 1
fi
sleep 5
done
NPM_TARBALL="$(NPM_DIST_JSON="$NPM_DIST_JSON" node --input-type=module <<'EOF'
const dist = JSON.parse(process.env.NPM_DIST_JSON ?? "{}");
process.stdout.write(String(dist["dist.tarball"] ?? ""));
EOF
)"
NPM_INTEGRITY="$(NPM_DIST_JSON="$NPM_DIST_JSON" node --input-type=module <<'EOF'
const dist = JSON.parse(process.env.NPM_DIST_JSON ?? "{}");
process.stdout.write(String(dist["dist.integrity"] ?? ""));
EOF
)"
if [[ -z "$NPM_TARBALL" || -z "$NPM_INTEGRITY" ]]; then
echo "npm dist metadata for clawhub@${PACKAGE_VERSION} is incomplete." >&2
exit 1
fi
{
echo "PACKAGE_VERSION=$PACKAGE_VERSION"
echo "NPM_PACKAGE_URL=https://www.npmjs.com/package/clawhub/v/${PACKAGE_VERSION}"
echo "NPM_TARBALL_URL=$NPM_TARBALL"
echo "NPM_INTEGRITY=$NPM_INTEGRITY"
echo "RELEASE_TITLE=clawhub ${PACKAGE_VERSION}"
} >> "$GITHUB_ENV"
working-directory: release
- name: Resolve proof workflow run URLs
env:
GH_TOKEN: ${{ github.token }}
MAIN_RUN_ID: ${{ inputs.main_run_id }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
PUBLISH_RUN_ID: ${{ inputs.publish_run_id }}
run: |
set -euo pipefail
resolve_run_url() {
local env_name="$1"
local run_id="$2"
local expected_workflow="$3"
local expected_event="$4"
local expected_branch="$5"
if [[ -z "$run_id" ]]; then
return 0
fi
local run_json
run_json="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json conclusion,event,headBranch,headSha,url,workflowName)"
RUN_JSON="$run_json" RUN_ID="$run_id" EXPECTED_WORKFLOW="$expected_workflow" EXPECTED_EVENT="$expected_event" EXPECTED_BRANCH="$expected_branch" node --input-type=module <<'EOF'
const run = JSON.parse(process.env.RUN_JSON);
const expectedWorkflow = process.env.EXPECTED_WORKFLOW;
if (expectedWorkflow && run.workflowName !== expectedWorkflow) {
console.error(`Run ${process.env.RUN_ID} must be ${expectedWorkflow}; got ${run.workflowName ?? "<missing>"}.`);
process.exit(1);
}
if (run.conclusion !== "success") {
console.error(`Run ${process.env.RUN_ID} must have conclusion=success; got ${run.conclusion ?? "<missing>"}.`);
process.exit(1);
}
if (run.headSha !== process.env.RELEASE_SHA) {
console.error(`Run ${process.env.RUN_ID} must use release SHA ${process.env.RELEASE_SHA}; got ${run.headSha ?? "<missing>"}.`);
process.exit(1);
}
if (process.env.EXPECTED_EVENT && run.event !== process.env.EXPECTED_EVENT) {
console.error(`Run ${process.env.RUN_ID} must have event=${process.env.EXPECTED_EVENT}; got ${run.event ?? "<missing>"}.`);
process.exit(1);
}
if (process.env.EXPECTED_BRANCH && run.headBranch !== process.env.EXPECTED_BRANCH) {
console.error(`Run ${process.env.RUN_ID} must have headBranch=${process.env.EXPECTED_BRANCH}; got ${run.headBranch ?? "<missing>"}.`);
process.exit(1);
}
process.stdout.write(run.url);
EOF
echo "${env_name}=$(RUN_JSON="$run_json" RUN_ID="$run_id" EXPECTED_WORKFLOW="$expected_workflow" node --input-type=module <<'EOF'
const run = JSON.parse(process.env.RUN_JSON);
process.stdout.write(run.url);
EOF
)" >> "$GITHUB_ENV"
}
resolve_run_url MAIN_RUN_URL "$MAIN_RUN_ID" "CI" "" ""
resolve_run_url PREFLIGHT_RUN_URL "$PREFLIGHT_RUN_ID" "ClawHub CLI NPM Release" "workflow_dispatch" "main"
resolve_run_url PUBLISH_RUN_URL "$PUBLISH_RUN_ID" "ClawHub CLI NPM Release" "workflow_dispatch" "main"
- name: Verify preflight proof artifact
if: ${{ inputs.preflight_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
PROOF_DIR="$RUNNER_TEMP/clawhub-cli-github-release-preflight-proof"
rm -rf "$PROOF_DIR"
mkdir -p "$PROOF_DIR"
gh run download "$PREFLIGHT_RUN_ID" \
--repo "$GITHUB_REPOSITORY" \
--name "clawhub-cli-npm-preflight-${RELEASE_TAG}" \
--dir "$PROOF_DIR"
if [[ "$(tr -d '\r\n' < "$PROOF_DIR/release-tag.txt")" != "$RELEASE_TAG" ]]; then
echo "Preflight artifact tag does not match ${RELEASE_TAG}." >&2
exit 1
fi
if [[ "$(tr -d '\r\n' < "$PROOF_DIR/release-sha.txt")" != "$RELEASE_SHA" ]]; then
echo "Preflight artifact SHA does not match ${RELEASE_SHA}." >&2
exit 1
fi
if [[ "$(tr -d '\r\n' < "$PROOF_DIR/package-version.txt")" != "$PACKAGE_VERSION" ]]; then
echo "Preflight artifact version does not match ${PACKAGE_VERSION}." >&2
exit 1
fi
- name: Build release notes
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
node ../release-tools/scripts/extract-changelog-release.mjs --tag "$RELEASE_TAG" --changelog CHANGELOG.md > ../release-body.md
{
echo
echo "### Release Proof"
echo
echo "- npm: ${NPM_PACKAGE_URL}"
echo "- tarball: ${NPM_TARBALL_URL}"
echo "- integrity: ${NPM_INTEGRITY}"
if [[ -n "${MAIN_RUN_URL:-}" ]]; then
echo "- main CI: ${MAIN_RUN_URL}"
fi
if [[ -n "${PREFLIGHT_RUN_URL:-}" ]]; then
echo "- npm preflight: ${PREFLIGHT_RUN_URL}"
fi
if [[ -n "${PUBLISH_RUN_URL:-}" ]]; then
echo "- npm publish: ${PUBLISH_RUN_URL}"
fi
} >> ../release-body.md
working-directory: release
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
UPDATE_EXISTING: ${{ inputs.update_existing }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
if [[ "$UPDATE_EXISTING" != "true" ]]; then
echo "GitHub Release ${RELEASE_TAG} already exists. Rerun with update_existing=true to repair it." >&2
exit 1
fi
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes-file release-body.md
else
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes-file release-body.md
fi
+103 -1
View File
@@ -21,6 +21,8 @@ concurrency:
group: clawhub-cli-npm-release-${{ inputs.tag }}
cancel-in-progress: false
permissions: {}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
@@ -45,6 +47,12 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Checkout release tooling
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
path: release-tools
- name: Setup Node
uses: actions/setup-node@v6
with:
@@ -106,6 +114,11 @@ jobs:
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
node scripts/clawhub-cli-npm-release-check.mjs
- name: Validate GitHub Release notes
env:
RELEASE_TAG: ${{ inputs.tag }}
run: node release-tools/scripts/extract-changelog-release.mjs --tag "$RELEASE_TAG" >/tmp/clawhub-cli-release-notes.md
- name: Verify CLI package
run: bun run --cwd "$PACKAGE_DIR" verify
@@ -181,7 +194,7 @@ jobs:
environment: npm-release
permissions:
actions: read
contents: read
contents: write
id-token: write
steps:
- name: Checkout
@@ -190,6 +203,12 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Checkout release tooling
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
path: release-tools
- name: Setup Node
uses: actions/setup-node@v6
with:
@@ -256,6 +275,11 @@ jobs:
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
node scripts/clawhub-cli-npm-release-check.mjs
- name: Validate GitHub Release notes
env:
RELEASE_TAG: ${{ inputs.tag }}
run: node release-tools/scripts/extract-changelog-release.mjs --tag "$RELEASE_TAG" >/tmp/clawhub-cli-release-notes.md
- name: Verify prepared tarball provenance
env:
RELEASE_TAG: ${{ inputs.tag }}
@@ -313,3 +337,81 @@ jobs:
publish_target="./${publish_target}"
fi
bash scripts/clawhub-cli-npm-publish.sh --publish "${publish_target}"
- name: Resolve npm release metadata
run: |
set -euo pipefail
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
process.stdout.write(String(pkg.version ?? "").trim());
EOF
)"
NPM_DIST_JSON=""
for attempt in {1..12}; do
if NPM_DIST_JSON="$(npm view "clawhub@${PACKAGE_VERSION}" dist.tarball dist.integrity --json 2>/tmp/npm-view-error)" && [[ -n "$NPM_DIST_JSON" ]]; then
break
fi
if [[ "$attempt" == "12" ]]; then
cat /tmp/npm-view-error >&2 || true
exit 1
fi
sleep 5
done
NPM_TARBALL="$(NPM_DIST_JSON="$NPM_DIST_JSON" node --input-type=module <<'EOF'
const dist = JSON.parse(process.env.NPM_DIST_JSON ?? "{}");
process.stdout.write(String(dist["dist.tarball"] ?? ""));
EOF
)"
NPM_INTEGRITY="$(NPM_DIST_JSON="$NPM_DIST_JSON" node --input-type=module <<'EOF'
const dist = JSON.parse(process.env.NPM_DIST_JSON ?? "{}");
process.stdout.write(String(dist["dist.integrity"] ?? ""));
EOF
)"
if [[ -z "$NPM_TARBALL" || -z "$NPM_INTEGRITY" ]]; then
echo "npm dist metadata for clawhub@${PACKAGE_VERSION} is incomplete." >&2
exit 1
fi
{
echo "PACKAGE_VERSION=$PACKAGE_VERSION"
echo "NPM_PACKAGE_URL=https://www.npmjs.com/package/clawhub/v/${PACKAGE_VERSION}"
echo "NPM_TARBALL_URL=$NPM_TARBALL"
echo "NPM_INTEGRITY=$NPM_INTEGRITY"
echo "RELEASE_TITLE=clawhub ${PACKAGE_VERSION}"
} >> "$GITHUB_ENV"
- name: Build GitHub Release notes
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
node release-tools/scripts/extract-changelog-release.mjs --tag "$RELEASE_TAG" > release-body.md
{
echo
echo "### Release Proof"
echo
echo "- npm: ${NPM_PACKAGE_URL}"
echo "- tarball: ${NPM_TARBALL_URL}"
echo "- integrity: ${NPM_INTEGRITY}"
echo "- npm preflight: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${{ inputs.preflight_run_id }}"
echo "- npm publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
} >> release-body.md
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes-file release-body.md
else
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes-file release-body.md
fi
+98 -2
View File
@@ -3,6 +3,8 @@ name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
issue_comment:
types: [created, edited]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
@@ -16,7 +18,7 @@ concurrency:
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
if: ${{ github.event_name == 'issue_comment' || !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
@@ -35,10 +37,24 @@ jobs:
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
permission-contents: write
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-issues: write
permission-pull-requests: read
- name: Dispatch exact ClawSweeper review
if: ${{ github.event_name == 'issues' || github.event_name == 'pull_request_target' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
GH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
@@ -60,3 +76,83 @@ jobs:
gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
- name: Acknowledge and dispatch ClawSweeper comment
if: ${{ github.event_name == 'issue_comment' }}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
COMMENT_BODY: ${{ github.event.comment.body }}
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
set -euo pipefail
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
exit 0
fi
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
err="$(mktemp)"
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
-H "Accept: application/vnd.github+json" \
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
-f content="eyes" 2>"$err" >/dev/null; then
echo "Acknowledged ClawSweeper command comment."
elif grep -qi "HTTP 422\\|already exists" "$err"; then
echo "ClawSweeper command comment already acknowledged."
else
cat "$err" >&2
echo "::warning::Could not acknowledge ClawSweeper command comment."
fi
rm -f "$err"
else
echo "::notice::Skipping ClawSweeper comment acknowledgement because no target token is configured."
fi
status_comment_id=""
if [ -n "$TARGET_TOKEN" ]; then
case "$AUTHOR_ASSOCIATION" in
OWNER|MEMBER|COLLABORATOR)
status_body="$(printf '%s\n' \
"<!-- clawsweeper-command-ack:$COMMENT_ID -->" \
"ClawSweeper picked this up." \
"" \
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
status_comment_id="$(jq -r '.id // empty' <<< "$status_response")"
else
cat "$status_err" >&2
echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one."
fi
rm -f "$status_err"
;;
esac
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--argjson comment_id "$COMMENT_ID" \
--arg status_comment_id "$status_comment_id" \
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
echo "Dispatched ClawSweeper comment router."
else
echo "::warning::Skipping ClawSweeper comment dispatch because the configured credential could not dispatch to openclaw/clawsweeper."
fi
+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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
category: "/codeql-light/${{ matrix.category }}"
+74 -8
View File
@@ -12,13 +12,18 @@ on:
- full
- backend
- frontend
allow_deleting_large_indexes:
description: "Allow Convex to delete large indexes"
required: true
default: false
type: boolean
concurrency:
group: deploy-production
cancel-in-progress: true
permissions:
contents: read
contents: write
statuses: read
jobs:
@@ -94,6 +99,7 @@ jobs:
fi
echo "Deploy target: ${{ needs.validate-deploy-request.outputs.target }}"
echo "Allow deleting large Convex indexes: ${{ inputs.allow_deleting_large_indexes }}"
if [[ -z "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" ]]; then
echo "PLAYWRIGHT_AUTH_STORAGE_STATE_JSON not set; authenticated smoke will be skipped."
@@ -118,13 +124,20 @@ jobs:
- name: Deploy Convex
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
run: bun run convex:deploy
run: |
set -euo pipefail
if [[ "${{ inputs.allow_deleting_large_indexes }}" == "true" ]]; then
bunx convex deploy --typecheck=disable --yes --allow-deleting-large-indexes
else
bun run convex:deploy
fi
- name: Verify Convex contract
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
run: bun run verify:convex-contract -- --prod
- name: Wait for Vercel production deployment
id: vercel
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
env:
GH_TOKEN: ${{ github.token }}
@@ -134,17 +147,26 @@ jobs:
run: |
set -euo pipefail
for attempt in {1..90}; do
if ! state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | .state' \
if ! status_json="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | {state, target_url, description} | @base64' \
2>/dev/null | head -n1)"; then
echo "GitHub status check failed for $GITHUB_SHA on attempt $attempt; retrying..."
sleep 10
continue
fi
if [[ -z "$status_json" ]]; then
state=""
target_url=""
else
state="$(printf '%s' "$status_json" | base64 -d | jq -r '.state // ""')"
target_url="$(printf '%s' "$status_json" | base64 -d | jq -r '.target_url // ""')"
fi
case "$state" in
success)
echo "Vercel production deployment ready for $GITHUB_SHA"
echo "deployment_url=$target_url" >> "$GITHUB_OUTPUT"
exit 0
;;
failure|error)
@@ -166,7 +188,7 @@ jobs:
exit 1
- name: Install Playwright browser
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
run: bunx playwright install --with-deps chromium webkit
- name: Smoke test production HTTP
@@ -174,11 +196,55 @@ jobs:
run: bun run test:e2e:prod-http
- name: Write authenticated storage state
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
run: |
echo "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" > "$RUNNER_TEMP/playwright-auth.json"
echo "PLAYWRIGHT_AUTH_STORAGE_STATE=$RUNNER_TEMP/playwright-auth.json" >> "$GITHUB_ENV"
- name: Smoke test production UI
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
run: bunx playwright test e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
run: bunx playwright test --workers=1 e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
- name: Tag production frontend deployment
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
env:
DEPLOY_TARGET: ${{ needs.validate-deploy-request.outputs.target }}
DEPLOYMENT_URL: ${{ steps.vercel.outputs.deployment_url }}
run: |
set -euo pipefail
deployed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
tag_name="deploy/prod/$(date -u +"%Y%m%d-%H%M%SZ")-${GITHUB_SHA::7}"
version_prefix="prod/v$(date -u +"%Y.%m.%d")."
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
next_version=1
while IFS= read -r existing_tag; do
existing_tag="${existing_tag#refs/tags/}"
existing_tag="${existing_tag%\^\{\}}"
suffix="${existing_tag##*.}"
if [[ "$existing_tag" == "$version_prefix"* && "$suffix" =~ ^[0-9]+$ && "$suffix" -ge "$next_version" ]]; then
next_version=$((suffix + 1))
fi
done < <(git ls-remote --tags origin "refs/tags/${version_prefix}*" | awk '{print $2}' | sort -u)
version_tag="${version_prefix}${next_version}"
git tag -a "$tag_name" "$GITHUB_SHA" \
-m "Production frontend deploy $tag_name" \
-m "SHA: $GITHUB_SHA" \
-m "Version: $version_tag" \
-m "Deployed at: $deployed_at" \
-m "Target: $DEPLOY_TARGET" \
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
-m "Run: $run_url"
git tag -a "$version_tag" "$GITHUB_SHA" \
-m "Production frontend deploy $version_tag" \
-m "SHA: $GITHUB_SHA" \
-m "Timestamp tag: $tag_name" \
-m "Deployed at: $deployed_at" \
-m "Target: $DEPLOY_TARGET" \
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
-m "Run: $run_url"
git push origin "refs/tags/$tag_name" "refs/tags/$version_tag"
@@ -0,0 +1,36 @@
name: OpenClaw Docs Sync Dispatch
on:
push:
branches:
- main
paths:
- docs/**
- .github/workflows/openclaw-docs-sync-dispatch.yml
workflow_dispatch:
permissions:
contents: read
jobs:
dispatch-openclaw-docs-sync:
runs-on: ubuntu-latest
steps:
- name: Dispatch OpenClaw docs sync
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${OPENCLAW_DOCS_SYNC_TOKEN:-}" ]; then
echo "::error::OPENCLAW_DOCS_SYNC_TOKEN is required to dispatch openclaw/openclaw docs sync."
exit 1
fi
curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${OPENCLAW_DOCS_SYNC_TOKEN}" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/openclaw/actions/workflows/docs-sync-publish.yml/dispatches \
--data '{"ref":"main"}'
+10
View File
@@ -57,6 +57,10 @@ on:
description: Optional source ref override for local-folder publishes.
required: false
type: string
source_path:
description: Optional source path inside the repository for monorepo package publishes.
required: false
type: string
clawhub_version:
description: Legacy npm CLI version input. Kept for compatibility; the workflow now runs the checked-out source.
required: false
@@ -76,6 +80,8 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
@@ -223,6 +229,7 @@ jobs:
INPUT_SOURCE_REPO: ${{ inputs.source_repo }}
INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }}
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
INPUT_SOURCE_PATH: ${{ inputs.source_path }}
INPUT_SITE: ${{ inputs.site }}
INPUT_REGISTRY: ${{ inputs.registry }}
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
@@ -287,6 +294,7 @@ jobs:
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
if source_repo:
cmd += ["--source-repo", source_repo]
if source_commit:
@@ -297,6 +305,8 @@ jobs:
github_ref = os.environ["GITHUB_REF"].strip()
if github_ref:
cmd += ["--source-ref", github_ref]
if source_path:
cmd += ["--source-path", source_path]
if os.environ["INPUT_DRY_RUN"] != "true" and os.environ["CLAWHUB_TOKEN"].strip():
cmd += [
"--manual-override-reason",
+3 -1
View File
@@ -6,6 +6,8 @@ on:
pull_request:
branches: [main, master]
permissions: {}
jobs:
trufflehog:
name: Scan for Verified Secrets
@@ -49,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.2
uses: trufflesecurity/trufflehog@v3.95.3
with:
path: ./
base: ${{ steps.scan_range.outputs.base }}
+106
View File
@@ -0,0 +1,106 @@
name: Security Scan Codex Worker
on:
workflow_dispatch:
inputs:
limit:
description: "Deprecated alias for batch-limit"
required: false
default: ""
batch-limit:
description: "Maximum Codex scans to run in parallel per worker shard"
required: true
default: "6"
max-jobs:
description: "Optional total jobs cap per worker shard"
required: false
default: ""
max-runtime-minutes:
description: "Stop claiming new batches after this many minutes"
required: true
default: "40"
schedule:
- cron: "*/5 * * * *"
permissions:
contents: read
jobs:
codex-security-scan:
name: Codex security scan shard ${{ matrix.shard }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
environment: Production
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3, 4, 5, 6, 7]
env:
CONVEX_URL: ${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }}
SECURITY_SCAN_WORKER_TOKEN: ${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_SECURITY_SCAN_LIMIT: ${{ inputs.limit || inputs['batch-limit'] || '6' }}
CODEX_SECURITY_SCAN_MAX_JOBS: ${{ inputs['max-jobs'] || '' }}
CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES: ${{ inputs['max-runtime-minutes'] || '40' }}
CODEX_SECURITY_SCAN_LEASE_MINUTES: "60"
CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR: codex-security-scan-diagnostics-${{ matrix.shard }}
CODEX_SECURITY_SCAN_SHARD: ${{ matrix.shard }}
CODEX_SECURITY_SCAN_WORKER_ID: "github-actions:${{ github.run_id }}:${{ github.run_attempt }}:${{ matrix.shard }}"
SKILLSPECTOR_PROVIDER: openai
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-bun
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check configuration
run: |
set -euo pipefail
if [[ -z "$SECURITY_SCAN_WORKER_TOKEN" ]]; then
echo "::error::SECURITY_SCAN_WORKER_TOKEN is required"
exit 1
fi
if [[ -z "$OPENAI_API_KEY" ]]; then
echo "::error::OPENAI_API_KEY is required"
exit 1
fi
- name: Install Codex CLI
run: |
set -euo pipefail
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex@latest
fi
codex --version
- name: Install SkillSpector
run: |
set -euo pipefail
python -m venv "$RUNNER_TEMP/skillspector-venv"
source "$RUNNER_TEMP/skillspector-venv/bin/activate"
python -m pip install --upgrade pip
python -m pip install 'git+https://github.com/NVIDIA/skillspector.git'
echo "$RUNNER_TEMP/skillspector-venv/bin" >> "$GITHUB_PATH"
skillspector --help >/dev/null
- name: Authenticate Codex CLI
run: printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
- name: Run Codex security worker
run: |
bun scripts/security/run-codex-scan-worker.ts \
--batch-limit "$CODEX_SECURITY_SCAN_LIMIT" \
--max-jobs "$CODEX_SECURITY_SCAN_MAX_JOBS" \
--max-runtime-minutes "$CODEX_SECURITY_SCAN_MAX_RUNTIME_MINUTES" \
--lease-minutes "$CODEX_SECURITY_SCAN_LEASE_MINUTES"
- name: Upload Codex security diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: codex-security-scan-diagnostics-${{ github.run_id }}-${{ matrix.shard }}
path: ${{ env.CODEX_SECURITY_SCAN_DIAGNOSTICS_DIR }}
if-no-files-found: ignore
+91
View File
@@ -0,0 +1,91 @@
name: Skill Card Worker
on:
workflow_run:
workflows: ["Security Scan Codex Worker"]
types: [completed]
workflow_dispatch:
inputs:
batch-limit:
description: "Maximum Skill Card jobs to run in parallel per batch"
required: true
default: "5"
max-jobs:
description: "Optional total jobs cap for this run"
required: false
default: ""
max-runtime-minutes:
description: "Stop claiming new batches after this many minutes"
required: true
default: "30"
concurrency:
group: skill-card-worker
cancel-in-progress: false
permissions:
contents: read
jobs:
skill-card-worker:
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
environment: Production
env:
CONVEX_URL: ${{ vars.CONVEX_URL || vars.VITE_CONVEX_URL || 'https://wry-manatee-359.convex.cloud' }}
# Shared Convex worker credential used by security and Skill Card workers.
SECURITY_SCAN_WORKER_TOKEN: ${{ secrets.SECURITY_SCAN_WORKER_TOKEN }}
SKILL_CARD_WORKER_LIMIT: ${{ github.event.inputs['batch-limit'] || '5' }}
SKILL_CARD_WORKER_MAX_JOBS: ${{ github.event.inputs['max-jobs'] || '' }}
SKILL_CARD_WORKER_MAX_RUNTIME_MINUTES: ${{ github.event.inputs['max-runtime-minutes'] || '30' }}
SKILL_CARD_WORKER_LEASE_MINUTES: "30"
NVIDIA_TRUSTWORTHY_AI_DIR: ${{ github.workspace }}/.artifacts/nvidia-trustworthy-ai
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
repository: NVIDIA/Trustworthy-AI
ref: fb5867e9070b4080d28818242e20334e10ac55fc
path: .artifacts/nvidia-trustworthy-ai
- uses: ./.github/actions/setup-bun
- name: Check configuration
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [[ -z "$SECURITY_SCAN_WORKER_TOKEN" ]]; then
echo "::error::SECURITY_SCAN_WORKER_TOKEN is required"
exit 1
fi
if [[ -z "$OPENAI_API_KEY" ]]; then
echo "::error::OPENAI_API_KEY is required"
exit 1
fi
- name: Install Codex CLI and renderer dependencies
run: |
set -euo pipefail
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex@latest
fi
python3 -m pip install --user jinja2
codex --version
- name: Authenticate Codex CLI
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
- name: Run Skill Card worker
run: |
args=(
--batch-limit "$SKILL_CARD_WORKER_LIMIT"
--max-jobs "$SKILL_CARD_WORKER_MAX_JOBS"
--max-runtime-minutes "$SKILL_CARD_WORKER_MAX_RUNTIME_MINUTES"
--lease-minutes "$SKILL_CARD_WORKER_LEASE_MINUTES"
--nvidia-tool-dir "$NVIDIA_TRUSTWORTHY_AI_DIR"
)
bun scripts/skill-cards/run-skill-card-worker.ts "${args[@]}"
+18 -1
View File
@@ -10,6 +10,8 @@ concurrency:
group: update-convex-ai-files
cancel-in-progress: false
permissions: {}
env:
BUN_VERSION: "1.3.10"
UPDATE_BRANCH: automation/update-convex-ai-files
@@ -32,6 +34,21 @@ jobs:
with:
bun-version: ${{ env.BUN_VERSION }}
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/create-github-app-token@v3
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -71,7 +88,7 @@ jobs:
- name: Open or update pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
run: |
set -euo pipefail
+16 -2
View File
@@ -2,6 +2,8 @@ node_modules
.DS_Store
.bun-build
*.bun-build
.artifacts/
.cache/
.data/
bin/docs-list
dist
@@ -39,7 +41,19 @@ skills-lock.json
!.agents/skills/
!.agents/skills/convex*/
!.agents/skills/convex*/**
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/crabbox/
!.agents/skills/crabbox/**
!.agents/skills/clawhub-ui-proof/
!.agents/skills/clawhub-ui-proof/**
!.agents/skills/clawhub-pr-maintainer/
!.agents/skills/clawhub-pr-maintainer/**
!.agents/skills/clawhub-moderation/
!.agents/skills/clawhub-moderation/**
!.agents/skills/autoreview/
!.agents/skills/autoreview/**
skills/*
.codex/*
!.codex/environments/
!.codex/environments/environment.toml
/.comux-hooks
/.comux
+36 -36
View File
@@ -1,38 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {
"correctness": "error",
"perf": "error",
"suspicious": "error"
},
"rules": {
"curly": "off",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint-plugin-unicorn/no-array-sort": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-underscore-dangle": "off",
"eslint/no-new": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
},
"ignorePatterns": [
".output/",
".tanstack/",
"convex/_generated/",
"coverage/",
"dist/",
"node_modules/",
"public/",
"src/routeTree.gen.ts",
"test-results/"
]
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {
"correctness": "error",
"perf": "error",
"suspicious": "error"
},
"rules": {
"curly": "off",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint-plugin-unicorn/no-array-sort": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-underscore-dangle": "off",
"eslint/no-new": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
},
"ignorePatterns": [
".output/",
".tanstack/",
"convex/_generated/",
"coverage/",
"dist/",
"node_modules/",
"public/",
"src/routeTree.gen.ts",
"test-results/"
]
}
+1
View File
@@ -0,0 +1 @@
node_modules/
+30 -10
View File
@@ -5,20 +5,37 @@
- `src/` — TanStack Start app code (routes, components, styles).
- `convex/` — Convex backend (schema, queries/mutations/actions, HTTP routes).
- `convex/_generated/` — generated Convex API/types; committed for builds.
- `docs/` — product/spec docs (see `docs/spec.md`).
- `docs/` — publishable public/operator docs for the ClawHub docs tab.
- `specs/` — product specs, plans, regression notes, design history (see `specs/spec.md`).
- `public/` — static assets.
## Durable Intent & Specs
- Use `specs/` to persist system/subsystem intent, invariants, and design rationale that future agents should preserve.
- Keep intended behavior for security-sensitive flows there, especially moderation, upload gating, scanner outcomes, appeals, bans, ownership, package installability, and API trust boundaries.
- If code changes reveal or change how a subsystem is supposed to work, update the relevant spec or add a focused spec note instead of burying the intent only in PR text or public docs.
- Keep `docs/` user/operator-facing: explain current behavior and commands there, but put internal “why this must work this way” context in `specs/`.
## Build, Test, and Development Commands
- `bun run dev` — local app server at `http://localhost:3000`.
Keep this section as the command map agents normally need, not a full `package.json` script index.
- `bun run dev` — foreground local app server at `http://localhost:3000`.
- `bunx convex dev --typecheck=disable` — local Convex backend/function watcher for manual setup.
- `bunx convex codegen` — regenerate `convex/_generated` after Convex API/schema changes.
- `bun run setup:worktree` — link `.env.local` and `.convex` from a usable source worktree into the current worktree. Use `-- --from <path>` or `CLAWHUB_WORKTREE_SOURCE=<path>` when auto-discovery picks the wrong source.
- `bun run dev:worktree` — Worktrunk-managed detached worktree server. Requires `wt` on `PATH`; from that worktree use `wt --yes url` to print the branch URL and `wt --yes stop` to stop it.
- `bun run seed:dev` — canonical local seed path; runs worktree setup, waits for local Convex, seeds local fixtures plus the public corpus, and refreshes stats.
- `bun run build` — production build (Vite + Nitro).
- `bun run preview` — preview built app.
- `bunx convex dev` — Convex dev deployment + function watcher.
- `bunx convex codegen` — regenerate `convex/_generated`.
- `bun run format:check` — formatting check.
- `bun run lint` — Biome + oxlint (type-aware).
- `bun run test` — Vitest (unit tests).
- `bun run coverage` — coverage run; keep global >= 80%.
- `bun run ci:static` — required pre-handoff static gate: peer checks, audit, formatting, lint, and dead-code checks.
- `bun run ci:unit` — Vitest coverage gate; required for source/test PRs unless docs/config-only.
- `bun run ci:types-build` — full TypeScript/build gate for app, Convex, and packages.
- `bun run ci:packages` — schema, CLI, and moderation package verification.
- `bun run ci:e2e-http` — secretless HTTP and CLI e2e subset.
- `bun run ci:playwright-smoke` — chromium smoke against the public read backend.
- `bun run test:pw:local-auth` — local Convex/dev-auth browser gate for signed-in/write flows.
Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbox, docs-authoring, and dataset scripts are real maintenance tools, but they should stay in the relevant specs, skills, or package script lookup unless the task touches that subsystem.
## Coding Style & Naming Conventions
@@ -26,6 +43,7 @@
- Indentation: 2 spaces, single quotes (Biome).
- Lint/format: Biome + oxlint (type-aware).
- Convex function names: verb-first (`getBySlug`, `publishVersion`).
- Inline code comments: add brief comments for tricky, bug-prone, or previously buggy logic.
## Testing Guidelines
@@ -38,7 +56,9 @@
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
- Keep changes scoped; avoid repo-wide search/replace.
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
- Before commit/PR handoff, run `bun run ci:static` so formatting, linting, audit/peer checks, and dead-code export checks match the CI `static` job. For faster inner loops, targeted `bun run format:check -- <files>` / `bun run lint` are fine, but do not treat them as the final pre-push gate.
- 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.
- 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.
+172
View File
@@ -2,9 +2,181 @@
## Unreleased
## 0.18.0 - 2026-05-25
### Changes
- CLI/API: add Skill Card verification surfaces, including `clawhub skill verify <slug>` JSON output and `--card` Markdown retrieval (#2382).
- Web/API: surface an "API key required" attribute on skills so listings, cards, and detail views show whether a skill needs an LLM API key, with publish-time inference from skill prompts and metadata (#2353) (thanks @momothemage).
### Fixes
- API: fix `GET /api/v1/skills` pagination so `cursor` advances to the next page instead of repeating the first page for supported non-trending sorts (#2275) (thanks @vyctorbrzezowski, @enerj).
## 0.17.0 - 2026-05-19
- CLI/API: add self-serve org publisher creation with `clawhub publisher create <handle>` and scoped package publish errors that point to the command.
## 0.16.0 - 2026-05-18
### Fixes
- CLI/API: make package publishes robust under parallel same-publisher release jobs by avoiding unnecessary shared publisher writes, retrying transient Convex contention, and labeling contention separately from package validation failures (#2291).
- Security: move upload ClawScan classification to a GitHub Actions Codex worker, treat VirusTotal as telemetry-only signal, and trust verified `@openclaw/*` plugin packages by default.
- Security: cancel pending skill ownership transfers before rejecting accept attempts when the requester is inactive or the skill is hidden, removed, or malicious (#2276, #2277) (thanks @vyctorbrzezowski).
- API/CLI: fix package delete returning 500 for packages with capability tags when no capability search digest row existed yet (#2212) (thanks @momothemage).
- API: return a clear 400 for `/api/v1/packages/search` without a non-empty `q` instead of treating `search` as a package name (thanks @vyctorbrzezowski).
- Web/API: keep search results limited to items with match evidence, preserve trust and popularity as tie-breakers, and show `N+` counts without exact count queries (#2206) (thanks @vyctorbrzezowski).
- Web: preserve `ownerHandle` through legacy skill publish redirects so org admins land in the correct new-version owner context (#2177).
- Settings: save display name/bio changes even when a legacy personal publisher handle conflict prevents publisher profile sync (#1199).
- Auth: show a visible error if the GitHub sign-in request fails before the provider redirect starts (#2197).
- Schema: include `.tsv`, `.conf`, `.properties`, and `.dat` in the exported text-file allowlist and regenerate the committed schema package runtime (#2172, #874) (thanks @alexuser).
- API: return `400` for invalid known public package filters and invalid skill list sort values, while continuing to ignore unknown query parameters (#2184).
- API/docs: document v1 plain-text error responses and expose owner metadata in the OpenAPI search result schema (#2187) (thanks @vyctorbrzezowski).
- Web: rank publisher card preview items by downloads instead of recent publish order (thanks @vyctorbrzezowski).
- Web: remove the desktop Files tab height cap and make mobile truncation explicit (thanks @vyctorbrzezowski).
- Web: keep skill/plugin detail tabs at mobile-friendly touch target height.
### Changes
- CLI/API: include skill owner handles in search results so duplicate/common slugs are easier to disambiguate (thanks @vyctorbrzezowski).
- Web: let skill publishers pick a curated lucide icon for cards and listings (#2174) (thanks @momothemage).
- Web/API: add keyword-based plugin categories plus API-backed plugin search sorting for recently updated, newest, and name (#2118) (thanks @vyctorbrzezowski).
- Web: polish the starred skills page with grid/list controls, sorting, and optimistic unstar behavior (#2159) (thanks @vyctorbrzezowski).
- API/docs: expand the v1 OpenAPI contract with package/plugin catalog endpoints and align documented rate limits with the server constants (#2186) (thanks @vyctorbrzezowski).
- Admin/Ops: audit profile syncs, self-service account/profile changes, personal publisher syncs, and org trusted-publisher changes so slug and ownership investigations have a complete ledger.
- Dependencies: update production `@clack/prompts`, `tailwind-merge`, and `yaml` dependencies (#2198).
## 0.15.0 - 2026-05-12
### Changes
- Web: polish dashboard artifact cards, loading skeletons, skill summary/detail layout, and adoption metrics after the 0.14 release (#2150, #2153, #2156, #2157, #2158, #2160).
- Docs/dev: clarify pre-PR validation gates for local contributors (#2161).
### Fixes
- Web: show plugin settings actions to package managers and preserve manager access in dashboard rows (#2163, #2168).
- Web: refresh skill star state after mutations and keep skill tabs from causing horizontal scroll (#2154, #2155).
- Web: show owner names when handles are hidden, and clarify editable skill summary settings copy (#2151, #2162).
- Dashboard: add a publisher switcher so org-owned skills and plugins are visible to org admins after transfer or publish (#2132).
- Web: let org publishers/admins republish transferred org-owned skills without the publish form treating the existing slug as taken, including legacy users with synthesized personal publishers (#2171).
- CLI: send skill ownership command payloads as JSON objects so rename/merge operations reach the API correctly (#1300).
- CLI: keep an install fingerprint in skill origin metadata so `clawhub update <skill>` does not report fresh installs as local changes when the server cannot resolve the current hash (#169).
- CLI: migrate cached `registry.clawhub.ai` registries back to `clawhub.ai` so `clawhub explore` no longer talks to the retired Vercel deployment (#1098).
- CLI: publish `.tsv`, `.conf`, `.properties`, `.dat`, and safe extensionless text files while excluding dotfiles and sampling extensionless files before full reads (#874).
- Tests: remove obsolete rescan e2e probes that no longer match current moderation behavior (#2152).
## 0.14.0 - 2026-05-11
### Changes
- Web: add publisher notes and unify ClawScan review pages (#2111).
- Dev: auto-start services for Codex worktrees and add a local dev persona FAB (#2146, #2147).
- Dev: add a local ClawScan dry-run helper script (#2143).
### Fixes
- API: return deterministic 403 responses for skill/package rescan and package transfer permission denials, with CI e2e coverage for protected write endpoints.
## 0.13.0 - 2026-05-11
### Changes
- Web: redesign Settings into focused account, organization, API token, and account deletion views with responsive desktop and mobile layouts (#2134) (thanks @vyctorbrzezowski).
- Web: replace the Users directory with a Publishers discovery surface covering builders and organizations, add `/publishers` as the canonical route, and keep `/users` compatibility (#2087) (thanks @vyctorbrzezowski).
- Web: polish browse/listing surfaces across skills, plugins, and search, including plugin card view parity, clearer search controls, visible safety filtering, and more consistent card metadata treatment (#2084) (thanks @vyctorbrzezowski).
- Web: allow skill owners and publisher admins to edit a skill summary from the detail page (#1411) (thanks @SylvanXiao).
- CLI/Auth: add device-code login for remote or headless shells, backed by ClawHub device authorization endpoints (#1867) (thanks @LumenFromTheFuture).
- CLI: add per-skill pinning so installed skills can be frozen against direct updates, bulk updates, and force reinstalls (#1806) (thanks @deepujain).
- Web: rename the skills and plugins browse alternate view from Cards to Grid while keeping legacy `view=cards` URLs compatible (#2119) (thanks @vyctorbrzezowski).
- Dev docs: refresh generated Convex AI guidance files (#2000).
### Fixes
- Moderation: stop treating VirusTotal Code Insight/Palm verdicts as a hide authority for skills; real AV-engine hits and ClawScan findings still contribute moderation verdicts.
- Moderation: stop treating static suspicious-only findings as a verdict; keep file/line evidence for review while VT/LLM decide public suspicious status.
- ClawScan: reduce false positives for scoped uninstall cleanup, declared provider login flows, Basic Auth/base64 handling, and user-directed provider uploads while hard-blocking stealth browser abuse patterns.
- ClawScan: lower false positives by treating purpose-aligned notes as benign unless structured LLM findings contain a material concern, and add targeted rescan batches for suspicious skills/plugins.
- Moderation: split visible ClawScan review guidance from hidden suspicious filtering, and add operator cleanup for stale aggregate rows and obvious test/placeholder suspicious skills.
- Security: add an admin-only moderation hold lift path for false-positive publisher holds, with audited skill restoration that preserves independently hidden skills (#1133) (thanks @Justincredible-tech).
- Moderation: let platform moderators and admins trigger skill/package security rescans for any owner from the CLI, without consuming the owner recovery cap.
- ClawScan: include package `openclaw.environment` env/config declarations in package review prompts so declared plugin runtime requirements are not reported as missing (#2013).
- Skills/Packages: let publisher admins manage owned lifecycle operations consistently, including skill rename/delete/restore, direct skill moves into org publishers they administer, package restore from the CLI/API, and direct moves back to their personal publisher.
- Skills: repair publisher-owned skill merges, bound historical slug redirects, block protected slug namespaces, and expire owner-unpublished slug reservations after 30 days (#2115) (thanks @fuller-stack-dev).
- Skills: allow confirmed owner migration when republishing an existing skill to another publisher, preserving versions, stats, aliases, and audit history (#1998, #2102) (thanks @momothemage).
- Security: block owner delete/undelete paths from overriding moderator or scanner hides, and return explicit 403 authz responses for owner restore denials (#2078) (thanks @momothemage).
- CLI/API: send skill transfer payloads as JSON objects so transfer requests reach the API correctly.
- Packages: keep package search digests schema-safe during delete/restore so package lifecycle CLI calls do not fail after provenance updates.
- Search: recall skill matches by non-first slug/display-name tokens while keeping multi-token queries on the direct recall path constrained to all query tokens (#2140) (thanks @momothemage).
- Search/Web: disclose when `/search` is hiding suspicious skills and add an explicit opt-out so unified search no longer silently differs from `/skills` for the same query (#2079) (thanks @momothemage).
- Uploads: accept PowerShell `.ps1`, `.psm1`, and `.psd1` files as text-based skill files while keeping normal scan coverage (#897) (thanks @cute-omega).
- Packages: count package install stat events separately from package downloads and record npm tarball fetches as installs (#1712).
- Web: keep the Publishers directory responsive for high-volume publishers by using bounded published-item previews, and abort stale unified-search plugin requests during route changes.
- Web: point skill, plugin, and soul owner links directly at canonical `/p/:handle` publisher profiles instead of legacy redirect routes.
- Web/API: ignore stale public skill-list cursors from older sort or safety-filter indexes instead of throwing pagination errors.
- Web: restore dashboard skill metrics for owned skills and use pointer cursors on dropdown menu items (#2113) (thanks @fuller-stack-dev).
- Web: show the skills browse `Hide suspicious` control only when the loaded results include suspicious skills (thanks @vyctorbrzezowski).
- Web: align signed-in header avatar controls across desktop and mobile so the menu trigger keeps consistent sizing, truncation, and dropdown styling (#2124) (thanks @vyctorbrzezowski).
- Web: constrain settings, profile content, skill detail, and plugin detail pages to the header content width while preserving profile hero bleed (thanks @vyctorbrzezowski).
- Web: show publish-page validation next to the relevant fields and upload picker so invalid inputs are not buried below the form (#908) (thanks @AndyZhengyan).
- Docs: remove README references to the inactive onlycrabs.ai domain while leaving the internal SoulHub configuration generic (#951) (thanks @muescha).
- Docs/dev: document the local Convex site proxy URL and make worktree setup reject misconfigured local site URLs that break HTTP routes (#2060) (thanks @vyctorbrzezowski).
- Dev setup: make local seed reset deterministic by cleaning stale seed lookup and badge rows for repeated Convex dev runs (#2057) (thanks @vyctorbrzezowski).
## 0.12.3 - 2026-05-06
### Fixes
- CLI/API: allow skill publishes to target an org/user publisher with `--owner` / `ownerHandle`, and keep root `SKILL.md` publishable even when broad ignore rules match Markdown files (thanks @deepujain).
- Packages: expose owned plugin/package soft-delete in the CLI and dashboard, keep moderator takedown access, and remove deleted packages from package search surfaces (thanks @Patrick-Erichsen).
- Packages: support monorepo package publishes, infer package owners from scoped names, and keep dry-run publishes metadata-only.
- Packages: validate code-plugin runtime entries against extracted files, allow admin plugin release publishes, and raise trusted-publish/admin API rate limits for legitimate publish bursts.
- API/Search: return lean skill list payloads, route package search through digest indexes, decode scoped package paths, and bound fallback scans to reduce production read pressure.
- Web: restore skill downloads and search paging, canonicalize scoped plugin paths, and improve mobile layout responsiveness.
- Security: add scanner checks for confirmation bypasses and Python file upload exfiltration while reducing generic false-positive package tags.
## 0.12.2 - 2026-05-02
### Fixes
- CLI: publish code plugins as clawpacks and allow legacy package downloads to keep older install flows working.
- API: resolve scoped package routes and accept scoped npm packuments.
- Schema: allow nullable package SHA values in package responses and refresh generated schema artifacts.
## 0.12.1 - 2026-05-02
### Added
- Packages: add clawpack parsing, uploads, mirror artifact routes, artifact downloads, release moderation, reports, appeals, and official migration management across API, dashboard, and CLI.
- Security: add ClawScan security surfaces, owner rescan guidance, scanner-specific report pages, security dataset snapshots, and redacted skill-content exports.
- CLI: add unban support, moderation diagnostics in `inspect`, manual skill-directory listing, package environment filters, and package migration-status commands.
- Web: add skills/plugins search typeahead, featured plugin curation, plugin management tools, skill upload shortcuts, and dashboard pagination.
### Fixes
- API: raise public read rate limits to reduce false-positive 429s from browser pages and production smoke tests (thanks @steipete).
- CLI/moderation: allow `delete`, `hide`, `undelete`, and `unhide` to record moderation reasons in skill notes and audit logs for legal or policy reviews (thanks @steipete).
- Packages: make package publish retries idempotent, constrain catalog queries, keep package list queries single-page, count package archive downloads, and keep beta plugin packages off `latest`.
- Search: add soul lexical fallback, non-suspicious digest indexes, normalized skill prefix recall, and more stable relevance recall windows.
- Security: broaden static scanner coverage for unsafe credential, subprocess, browser-file, provider-secret, and remote-recipe patterns while hardening prompt-boundary handling.
- Deploy/CI: harden production smoke checks, expand PR validation coverage, add dead-code gates, and stabilize CodeQL light coverage.
- Dependencies: pin `undici` on the Node 20-compatible line after reverting the incompatible v8 update.
## 0.12.0 - 2026-04-28
### Added
- Security: add owner rescan requests, owner flagged inventory, scanner-specific security pages, and in-progress scan states.
- UI: adopt shadcn-managed primitives and polish the rescan/security surfaces for mobile.
### Fixes
- Moderation: calibrate VirusTotal Code Insight suspicious verdicts so uncorroborated AI-only findings do not keep otherwise clean skills quarantined (#1830, #1841) (thanks @deepujain).
- Security: flag exposed secrets in skill docs and normalize VirusTotal engine stats before caching.
- Packages: constrain plugin catalog queries and avoid catalog/package-list query limits.
- Auth: tolerate stale auth state when reading star status.
- CI: harden and debounce ClawSweeper dispatch workflows and fix production smoke coverage.
## 0.11.0 - 2026-04-28
+2
View File
@@ -47,9 +47,11 @@
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
+97 -40
View File
@@ -12,6 +12,7 @@ Welcome! ClawHub is the public skill registry for [OpenClaw](https://github.com/
- [Bun](https://bun.sh/) (Convex CLI runs via `bunx`, no global install needed)
- [Node.js](https://nodejs.org/) v18, 20, 22, or 24 (required by the local Convex backend; v25+ is not yet supported)
- [Worktrunk](https://github.com/max-sixty/worktrunk) (`wt`) for `bun run dev:worktree` and disposable/Codex worktrees. On macOS, `brew install worktrunk` is the quickest path; shell integration is optional.
### Install and configure
@@ -25,18 +26,23 @@ Edit `.env.local` with the following values for **local Convex**:
```bash
# Frontend
VITE_CONVEX_URL=http://127.0.0.1:3210
VITE_CONVEX_SITE_URL=http://127.0.0.1:3210
VITE_CONVEX_SITE_URL=http://127.0.0.1:3211
SITE_URL=http://localhost:3000
# Convex Auth / HTTP routes
CONVEX_SITE_URL=http://127.0.0.1:3211
# Deployment used by `bunx convex dev`
CONVEX_DEPLOYMENT=anonymous:anonymous-clawhub
```
Local Convex serves the function endpoint on port 3210 and HTTP routes (`/api/*` and auth callbacks) through the site proxy on port 3211.
### GitHub OAuth App (for login)
1. Go to [github.com/settings/developers](https://github.com/settings/developers) and create a new OAuth App.
2. Set **Homepage URL** to `http://localhost:3000`.
3. Set **Authorization callback URL** to `http://127.0.0.1:3210/api/auth/callback/github`.
3. Set **Authorization callback URL** to `http://127.0.0.1:3211/api/auth/callback/github`.
4. Copy the Client ID and generate a Client Secret.
### Run the Convex backend
@@ -75,27 +81,78 @@ bun run dev -- --port 3000
Change the port if 3000 is already in use, and update `SITE_URL` in both `.env.local` and the Convex backend (`bunx convex env set SITE_URL ...`) to match.
### Seed the database
### Worktree/Codex fast path
Populate sample data so the UI isn't empty:
Use this path for disposable branches, Codex sessions, or parallel worktrees after one source worktree already has a working `.env.local` and `.convex` local Convex setup:
```bash
# 3 sample skills (padel, gohome, xuezh)
bunx convex run --no-push devSeed:seedNixSkills
bun run setup:worktree
bun run dev:worktree
wt --yes url
wt --yes stop
```
`setup:worktree` finds a usable source worktree and symlinks `.env.local` plus `.convex` into the current checkout. If discovery picks the wrong source, pass one explicitly:
```bash
bun run setup:worktree -- --from /path/to/source/worktree
CLAWHUB_WORKTREE_SOURCE=/path/to/source/worktree bun run setup:worktree
```
`dev:worktree` is the Worktrunk entrypoint. It runs the hooks in `.config/wt.toml`, copies ignored dependencies listed in `.worktreeinclude` when possible, falls back to `bun install` if Vite is missing, and starts detached services on a branch-hashed loopback port. Use `wt --yes url` from the same worktree to print the URL.
The detached server writes runtime state under `.codex/runtime/`. Stop it with `wt --yes stop` before removing the worktree.
### Seed the database
Populate local QA fixtures and the committed public corpus so the UI isn't empty:
```bash
bun run seed:dev
```
`seed:dev` runs worktree setup, starts or waits for local Convex, seeds the hand-authored local QA fixtures, imports the committed public corpus, and refreshes cached global stats. It is safe to rerun after fixture or schema changes.
Lower-level seed commands are available for manual recovery or focused fixture work:
```bash
# local moderation/security fixtures only
bunx convex run --no-push devSeed:seedLocalFixtures
# committed public corpus only
bun run seed:public-corpus
# validate the committed public corpus fixture
bun run validate:public-corpus
# 50 extra skills for pagination testing (optional)
bunx convex run --no-push devSeedExtra:seedExtraSkillsInternal
# Refresh the cached skills count (required after seeding)
# Refresh cached global stats after manual seeding
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
```
To reset and re-seed:
```bash
bunx convex run --no-push devSeed:seedNixSkills '{"reset": true}'
bunx convex run --no-push devSeed:seedLocalFixtures '{"reset": true}'
bun run seed:public-corpus -- --reset
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
```
Without `OPENAI_API_KEY`, public corpus import still works, but semantic search quality degrades because embeddings fall back to zero vectors.
### Worktree troubleshooting
- `wt: command not found`: install Worktrunk, then rerun `bun run dev:worktree`. Manual `bun run dev` plus `bunx convex dev --typecheck=disable` still works without Worktrunk.
- Missing `.env.local` or `.convex`: run `bun run setup:worktree -- --from /path/to/source/worktree`. The source must contain `.env.local` and, for local Convex deployments, `.convex/local/default/config.json`.
- Wrong local Convex deployment: make sure `CONVEX_DEPLOYMENT` in `.env.local` matches the local Convex deployment in `.convex/local/default/config.json` when using a `local:` deployment.
- Port mismatch: local Convex normally serves cloud functions at `http://127.0.0.1:3210` and HTTP routes/auth callbacks at `http://127.0.0.1:3211`. Keep `VITE_CONVEX_URL`, `VITE_CONVEX_SITE_URL`, and `CONVEX_SITE_URL` aligned with the local config.
- `wt step copy-ignored` reports that `.convex` cannot be copied: this can happen when `.convex` is a symlink to the source worktree. The Worktrunk hook continues; confirm `.env.local`, `.convex`, and `node_modules/.bin/vite` exist before debugging deeper.
- Local Convex functions are not queryable yet during seeding: leave `bunx convex dev --typecheck=disable` running or rerun `bun run seed:dev`; the seed runner retries while Convex finishes pushing functions.
- Local seeding hits a transient Convex write conflict: `seed:public-corpus` retries retryable batch conflicts. If retries are exhausted, stop other local writers and rerun `bun run seed:dev`.
- Stale detached services: run `wt --yes stop`, then inspect `.codex/runtime/dev-worktree.log` if the server still does not restart cleanly.
### Optional environment variables
These features degrade gracefully without their keys:
@@ -114,7 +171,7 @@ The CLI source lives in [`packages/clawhub/`](packages/clawhub/). Both `clawhub`
To test the CLI against your local instance:
```bash
CLAWHUB_REGISTRY=http://127.0.0.1:3210 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
CLAWHUB_REGISTRY=http://127.0.0.1:3211 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
```
Use the package-local verification contract when working on the CLI:
@@ -128,7 +185,7 @@ bun run --cwd packages/clawhub verify
`bun test packages/clawhub/` is not the supported workflow. Source tests and built-artifact smoke tests are intentionally split.
Manual smoke tests are documented in [`docs/manual-testing.md`](docs/manual-testing.md).
Manual smoke tests are documented in [`specs/manual-testing.md`](specs/manual-testing.md).
## Skill & Soul Publishing
@@ -144,38 +201,37 @@ clawhub publish <path-to-skill-directory>
## Before Submitting a PR
```bash
bun run lint # oxlint
bun run test # Vitest (80% coverage threshold)
bun run build # Vite + Nitro
bun run --cwd packages/clawhub verify
```
Run the narrowest meaningful check while iterating, then run the matching CI aliases before handoff:
These are the same checks that run in CI (`.github/workflows/ci.yml`).
- All PRs: `bun run ci:static`.
- Source or test changes: focused tests for the touched behavior plus `bun run ci:unit` unless the change is docs/config-only or a maintainer asks to rely on CI.
- App runtime, Convex, or build changes: `bun run ci:types-build`.
- Package changes: `bun run ci:packages`.
- HTTP/API/CLI integration changes: `bun run ci:e2e-http`.
- Browser smoke or visual behavior changes: `bun run ci:playwright-smoke`, `bun run test:pw:local-auth`, and/or `bun run proof:ui` depending on the touched flow.
### Blacksmith Testbox checks
`bun run ci:pr` is the local aggregate for the non-browser PR gates. See [`specs/ci.md`](specs/ci.md) for the full CI contract.
Maintainers with Blacksmith access can run the same checks in a warmed Testbox
instead of spending local CPU:
### Crabbox remote checks
Maintainers can run the same checks in a Crabbox lease instead of spending local
CPU. ClawHub uses Crabbox as the agent-facing command surface; the Testbox
workflow is only the backend for the default Blacksmith provider.
```bash
export CLAWHUB_TESTBOX=1
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
bun run testbox:claim -- --id <tbx_id>
bun run testbox:sanity -- --id <tbx_id>
bun run testbox:run -- --id <tbx_id> -- bun run lint
bun run testbox:run -- --id <tbx_id> -- bun run test
bun run testbox:run -- --id <tbx_id> -- bun run build
bun run crabbox:warmup -- --provider blacksmith-testbox
bun run crabbox:run -- --provider blacksmith-testbox --shell -- "bun run lint"
bun run crabbox:run -- --provider blacksmith-testbox --shell -- "bun run test"
bun run crabbox:run -- --provider blacksmith-testbox --shell -- "bun run build"
```
Use the `tbx_...` id from the current warmup output. The wrapper refuses ids
that are missing the local SSH key or were claimed by a different checkout.
Use `--id <id-or-slug>` with `crabbox:run` when reusing an existing warmed lease,
and stop disposable leases with `bun run crabbox:stop -- --provider <provider>
<id-or-slug>`.
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled` or `CLAWHUB_LOCAL_CHECK_MODE=full` as
the explicit local escape hatch when you intentionally want laptop-side proof.
If Blacksmith auth/org access is missing, report that instead of falling back
If Crabbox auth/provider access is missing, report that instead of falling back
to a broad local gate that can bog down a dev machine.
For the initial bootstrap only, the Testbox workflow must land on `main` before
`blacksmith testbox warmup ci-check-testbox.yml --ref <branch>` can dispatch it.
**PR guidelines:**
@@ -206,11 +262,12 @@ See [`docs/security.md`](docs/security.md) for moderation and upload gating deta
## Reading Order for New Contributors
1. This file (local setup)
2. [`docs/quickstart.md`](docs/quickstart.md) — end-to-end workflows
3. [`docs/architecture.md`](docs/architecture.md) — system design
4. [`docs/skill-format.md`](docs/skill-format.md) — skill structure
5. [`docs/cli.md`](docs/cli.md) — CLI reference
6. [`docs/http-api.md`](docs/http-api.md) — HTTP endpoints
7. [`docs/auth.md`](docs/auth.md) — authentication
8. [`docs/deploy.md`](docs/deploy.md) — deployment
9. [`docs/troubleshooting.md`](docs/troubleshooting.md) — common issues
2. [`docs/clawhub.md`](docs/clawhub.md) — public registry overview
3. [`docs/quickstart.md`](docs/quickstart.md) — end-to-end workflows
4. [`docs/architecture.md`](docs/architecture.md) — system design
5. [`docs/skill-format.md`](docs/skill-format.md) — skill structure
6. [`docs/cli.md`](docs/cli.md) — CLI reference
7. [`docs/http-api.md`](docs/http-api.md) — HTTP endpoints
8. [`docs/auth.md`](docs/auth.md) — authentication
9. [`docs/deploy.md`](docs/deploy.md) — deployment
10. [`docs/troubleshooting.md`](docs/troubleshooting.md) — common issues
+47 -41
View File
@@ -10,14 +10,14 @@ This document outlines the design rules, patterns, and guidelines for the ClawHu
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
| Token | Light Mode | Dark Mode | Usage |
| --------------- | ---------- | --------- | ----------------------------------------------- |
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
@@ -33,21 +33,21 @@ ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
--font-sans: "Geist", system-ui, sans-serif;
--font-mono: "Geist Mono", monospace;
--font-display: "Geist", system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
| Token | Size | Usage |
| ----------- | --------------- | ------------------------ |
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
@@ -72,25 +72,24 @@ Use this hierarchy for layout decisions:
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
--space-1: 0.25rem /* 4px */ --space-2: 0.5rem /* 8px */ --space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */ --space-5: 1.5rem /* 24px */ --space-6: 2rem /* 32px */;
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -106,11 +105,11 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
| Size | Max Width | Usage |
| ------- | ----------------------- | ----------------------- |
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
@@ -128,20 +127,22 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
| Variant | Usage |
| ------------- | ------------------------------------- |
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
@@ -314,17 +315,22 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
```css
/* Component */
.component-name { }
.component-name {
}
/* Component modifier */
.component-name.variant { }
.component-name.variant {
}
/* Component child */
.component-name-child { }
.component-name-child {
}
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
.component-name.is-active {
}
.component-name[data-state="open"] {
}
```
### File Organization
+24 -22
View File
@@ -14,13 +14,10 @@ ClawHub is the **public skill registry for OpenClaw**: publish, version, and sea
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
<p align="center">
<a href="https://clawhub.ai">ClawHub</a> ·
<a href="https://onlycrabs.ai">onlycrabs.ai</a> ·
<a href="VISION.md">Vision</a> ·
<a href="docs/README.md">Docs</a> ·
<a href="docs/clawhub.md">Docs</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="https://discord.gg/clawd">Discord</a>
</p>
@@ -35,16 +32,10 @@ onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same
- Publish new soul versions with changelogs + tags.
- Search via embeddings (vector index) instead of brittle keywords.
- Star + comment; admins/mods can curate and approve skills.
- Pin local skill installs so updates and force reinstalls cannot overwrite frozen copies.
- Browse OpenClaw packages with family/trust/capability metadata.
- Publish native code plugins and bundle plugins through `/packages` APIs and CLI flows.
## onlycrabs.ai (SOUL.md registry)
- Entry point is host-based: `onlycrabs.ai`.
- On the onlycrabs.ai host, the home page and nav default to souls.
- On ClawHub, souls live under `/souls`.
- Soul bundles only accept `SOUL.md` for now (no extra files).
## How it works (high level)
- Web app: TanStack Start (React, Vite/Nitro).
@@ -57,9 +48,10 @@ onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same
Common CLI flows:
- Auth: `clawhub login`, `clawhub whoami`
- Remote/headless auth: `clawhub login --device`
- Discover: `clawhub search ...`, `clawhub explore`
- Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect <name>`
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
- Manage local installs: `clawhub install <slug>`, `clawhub pin <slug>`, `clawhub unpin <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
- Inspect without installing: `clawhub inspect <slug>`
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
- Publish plugins: `clawhub package publish <source>`
@@ -72,7 +64,8 @@ Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
- `clawhub uninstall <slug>` only removes a local install on your machine.
- Uploaded registry skills use soft-delete/restore (`clawhub delete <slug>` / `clawhub undelete <slug>` or API equivalents).
- Soft-delete/restore is allowed for the skill owner, moderators, and admins.
- Soft-delete/restore is allowed for the skill or package owner, publisher owner/admin, moderators, and admins.
- Packages use `clawhub package delete <name>` / `clawhub package undelete <name>`.
- Hard delete is admin-only (management tools / ban flows).
- Owner rename keeps the old slug as a redirect alias.
- Owner merge hides the source listing and redirects the old slug to the canonical target.
@@ -93,12 +86,13 @@ Details: [`docs/telemetry.md`](docs/telemetry.md).
- `src/` — TanStack Start app (routes, components, styles).
- `convex/` — schema + queries/mutations/actions + HTTP API routes.
- `packages/schema/` — shared API types/routes for the CLI and app.
- [`docs/`](docs/README.md) — project documentation (architecture, CLI, auth, deployment, and more).
- [`docs/spec.md`](docs/spec.md) — product + implementation spec (good first read).
- [`docs/`](docs/README.md) — publishable ClawHub public/operator docs for users, publishers, API clients, and deploy operators.
- [`specs/`](specs/README.md) — product specs, plans, regression notes, and design history.
- [`specs/spec.md`](specs/spec.md) — product + implementation spec (good first read for maintainers).
## Local dev
Prereqs: [Bun](https://bun.sh/) (Convex runs via `bunx`, no global install needed).
Prereqs: [Bun](https://bun.sh/) (Convex runs via `bunx`, no global install needed). The detached worktree path also requires [Worktrunk](https://github.com/max-sixty/worktrunk) (`wt`).
```bash
bun install
@@ -111,18 +105,26 @@ bunx convex dev
# terminal B: web app (port 3000)
bun run dev
# seed sample data
bunx convex run --no-push devSeed:seedNixSkills
# detached/Codex worktree preview
bun run setup:worktree
bun run dev:worktree
wt --yes url
# seed local QA fixtures and the public corpus
bun run seed:dev
```
For full setup instructions (env vars, GitHub OAuth, JWT keys, database seeding), see [CONTRIBUTING.md](CONTRIBUTING.md).
`bun run seed:dev` waits for the local Convex deployment, runs the dev fixture seed, and refreshes
global stats. The fixtures are owned by `@local` and are safe to rerun after fixture or schema
changes. For reset/manual commands and full setup instructions (env vars, GitHub OAuth, JWT keys,
database seeding), see [CONTRIBUTING.md](CONTRIBUTING.md).
## Environment
- `VITE_CONVEX_URL`: Convex deployment URL (`https://<deployment>.convex.cloud`).
- `VITE_CONVEX_SITE_URL`: Convex site URL (`https://<deployment>.convex.site`).
- `VITE_SOULHUB_SITE_URL`: onlycrabs.ai site URL (`https://onlycrabs.ai`).
- `VITE_SOULHUB_HOST`: onlycrabs.ai host match (`onlycrabs.ai`).
- `VITE_SOULHUB_SITE_URL`: SoulHub site URL.
- `VITE_SOULHUB_HOST`: SoulHub host match.
- `VITE_SITE_MODE`: Optional override (`skills` or `souls`) for SSR builds.
- `CONVEX_SITE_URL`: same as `VITE_CONVEX_SITE_URL` (auth + cookies).
- `SITE_URL`: App URL (local: `http://localhost:3000`).
@@ -199,7 +201,7 @@ metadata: { "clawdbot": { "cliHelp": "padel --help\\nUsage: padel [command]\\n"
## Skill metadata
Skills declare their runtime requirements (env vars, binaries, install specs) in the `SKILL.md` frontmatter. ClawHub's security analysis checks these declarations against actual skill behavior.
Skills declare their runtime requirements (env vars, binaries, install specs) in the `SKILL.md` frontmatter. ClawHub's security analysis checks these declarations against actual skill behavior; purpose-aligned ClawScan notes stay as guidance, medium review findings stay visible, and the suspicious filter is reserved for high-impact or malicious concerns.
Full reference: [`docs/skill-format.md`](docs/skill-format.md#frontmatter-metadata)
+434 -481
View File
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -2,13 +2,5 @@
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
"agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d",
"installedSkillNames": [
"convex",
"convex-create-component",
"convex-migration-helper",
"convex-performance-audit",
"convex-quickstart",
"convex-setup-auth"
]
"agentSkillsSha": "b86618b5c3c4789c9fed98e84bbc34b3e8e70f20"
}
+32 -8
View File
@@ -10,6 +10,7 @@
import type * as appMeta from "../appMeta.js";
import type * as auth from "../auth.js";
import type * as cliDeviceAuth from "../cliDeviceAuth.js";
import type * as commentModeration from "../commentModeration.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
@@ -29,7 +30,9 @@ import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
import type * as http from "../http.js";
import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
@@ -40,13 +43,18 @@ import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
import type * as httpPreflight from "../httpPreflight.js";
import type * as leaderboards from "../leaderboards.js";
import type * as lib_access from "../lib/access.js";
import type * as lib_apiKeyRequirementPrompt from "../lib/apiKeyRequirementPrompt.js";
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_artifactModeration from "../lib/artifactModeration.js";
import type * as lib_badges from "../lib/badges.js";
import type * as lib_batching from "../lib/batching.js";
import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_clawScanNote from "../lib/clawScanNote.js";
import type * as lib_clawpack from "../lib/clawpack.js";
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_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
@@ -70,8 +78,11 @@ import type * as lib_openaiResponse from "../lib/openaiResponse.js";
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
import type * as lib_parsedEnvSignals from "../lib/parsedEnvSignals.js";
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_publisherStats from "../lib/publisherStats.js";
import type * as lib_publishers from "../lib/publishers.js";
import type * as lib_reporting from "../lib/reporting.js";
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
@@ -80,10 +91,13 @@ import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
import type * as lib_skillCards from "../lib/skillCards.js";
import type * as lib_skillIcon from "../lib/skillIcon.js";
import type * as lib_skillPublish from "../lib/skillPublish.js";
import type * as lib_skillQuality from "../lib/skillQuality.js";
import type * as lib_skillSafety from "../lib/skillSafety.js";
import type * as lib_skillSearchDigest from "../lib/skillSearchDigest.js";
import type * as lib_skillSlugValidator from "../lib/skillSlugValidator.js";
import type * as lib_skillStats from "../lib/skillStats.js";
import type * as lib_skillSummary from "../lib/skillSummary.js";
import type * as lib_skillZip from "../lib/skillZip.js";
@@ -97,19 +111,17 @@ import type * as lib_userSkillStats from "../lib/userSkillStats.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
import type * as model_packages_rescans from "../model/packages/rescans.js";
import type * as model_rescans_policy from "../model/rescans/policy.js";
import type * as model_skills_rescans from "../model/skills/rescans.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as rescanRequests from "../rescanRequests.js";
import type * as search from "../search.js";
import type * as securityDataset from "../securityDataset.js";
import type * as securityDatasetNode from "../securityDatasetNode.js";
import type * as securityScan from "../securityScan.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillCards from "../skillCards.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
import type * as skills from "../skills.js";
@@ -135,6 +147,7 @@ import type {
declare const fullApi: ApiFromModules<{
appMeta: typeof appMeta;
auth: typeof auth;
cliDeviceAuth: typeof cliDeviceAuth;
commentModeration: typeof commentModeration;
comments: typeof comments;
crons: typeof crons;
@@ -154,7 +167,9 @@ declare const fullApi: ApiFromModules<{
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
@@ -165,13 +180,18 @@ declare const fullApi: ApiFromModules<{
httpPreflight: typeof httpPreflight;
leaderboards: typeof leaderboards;
"lib/access": typeof lib_access;
"lib/apiKeyRequirementPrompt": typeof lib_apiKeyRequirementPrompt;
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/artifactModeration": typeof lib_artifactModeration;
"lib/badges": typeof lib_badges;
"lib/batching": typeof lib_batching;
"lib/changelog": typeof lib_changelog;
"lib/clawScanNote": typeof lib_clawScanNote;
"lib/clawpack": typeof lib_clawpack;
"lib/commentScamPrompt": typeof lib_commentScamPrompt;
"lib/contentTypes": typeof lib_contentTypes;
"lib/depRegistryScan": typeof lib_depRegistryScan;
"lib/devAuth": typeof lib_devAuth;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
@@ -195,8 +215,11 @@ declare const fullApi: ApiFromModules<{
"lib/packageRegistry": typeof lib_packageRegistry;
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
"lib/packageSecurity": typeof lib_packageSecurity;
"lib/parsedEnvSignals": typeof lib_parsedEnvSignals;
"lib/public": typeof lib_public;
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
"lib/publishLimits": typeof lib_publishLimits;
"lib/publisherStats": typeof lib_publisherStats;
"lib/publishers": typeof lib_publishers;
"lib/reporting": typeof lib_reporting;
"lib/reservedHandles": typeof lib_reservedHandles;
@@ -205,10 +228,13 @@ declare const fullApi: ApiFromModules<{
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/skillBackfill": typeof lib_skillBackfill;
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
"lib/skillCards": typeof lib_skillCards;
"lib/skillIcon": typeof lib_skillIcon;
"lib/skillPublish": typeof lib_skillPublish;
"lib/skillQuality": typeof lib_skillQuality;
"lib/skillSafety": typeof lib_skillSafety;
"lib/skillSearchDigest": typeof lib_skillSearchDigest;
"lib/skillSlugValidator": typeof lib_skillSlugValidator;
"lib/skillStats": typeof lib_skillStats;
"lib/skillSummary": typeof lib_skillSummary;
"lib/skillZip": typeof lib_skillZip;
@@ -222,19 +248,17 @@ declare const fullApi: ApiFromModules<{
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
"model/packages/rescans": typeof model_packages_rescans;
"model/rescans/policy": typeof model_rescans_policy;
"model/skills/rescans": typeof model_skills_rescans;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
publishers: typeof publishers;
rateLimits: typeof rateLimits;
rescanRequests: typeof rescanRequests;
search: typeof search;
securityDataset: typeof securityDataset;
securityDatasetNode: typeof securityDatasetNode;
securityScan: typeof securityScan;
seed: typeof seed;
seedSouls: typeof seedSouls;
skillCards: typeof skillCards;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
skills: typeof skills;
+15
View File
@@ -1,9 +1,11 @@
import GitHub from "@auth/core/providers/github";
import { ConvexCredentials } from "@convex-dev/auth/providers/ConvexCredentials";
import { convexAuth } from "@convex-dev/auth/server";
import type { GenericMutationCtx } from "convex/server";
import { ConvexError } from "convex/values";
import { internal } from "./_generated/api";
import type { DataModel, Id } from "./_generated/dataModel";
import { isLocalDevAuthEnabled } from "./lib/devAuth";
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
export const BANNED_REAUTH_MESSAGE =
@@ -12,6 +14,7 @@ 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"]);
function getBannedReauthMessage(reason: string | undefined) {
const normalizedReason = reason?.trim();
@@ -84,6 +87,18 @@ 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 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",
});
return { userId };
},
}),
],
callbacks: {
/**
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requireUser } from "./lib/access";
import { updateLatestClawScanNoteAndRequestRescan as updatePackageClawScanNoteAndRequestRescan } from "./packages";
import { updateLatestClawScanNoteAndRequestRescan as updateSkillClawScanNoteAndRequestRescan } from "./skills";
vi.mock("./lib/access", () => ({
requireUser: vi.fn(),
}));
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const updateSkillClawScanNoteAndRequestRescanHandler = (
updateSkillClawScanNoteAndRequestRescan as unknown as WrappedHandler<{
skillId: string;
clawScanNote?: string;
}>
)._handler;
const updatePackageClawScanNoteAndRequestRescanHandler = (
updatePackageClawScanNoteAndRequestRescan as unknown as WrappedHandler<{
packageId: string;
clawScanNote?: string;
}>
)._handler;
function createDb() {
const auditLogs: Array<Record<string, unknown>> = [];
const skill = {
_id: "skills:1",
slug: "flagged-skill",
ownerUserId: "users:owner",
latestVersionId: "skillVersions:latest",
softDeletedAt: undefined,
};
const version = {
_id: "skillVersions:latest",
skillId: "skills:1",
version: "1.2.3",
clawScanNote: "old skill note",
softDeletedAt: undefined,
};
const pkg = {
_id: "packages:1",
name: "flagged-plugin",
family: "code-plugin",
ownerUserId: "users:owner",
latestReleaseId: "packageReleases:latest",
softDeletedAt: undefined,
};
const release = {
_id: "packageReleases:latest",
packageId: "packages:1",
version: "2.0.0",
clawScanNote: "old plugin note",
softDeletedAt: undefined,
};
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const id = maybeId ?? tableOrId;
if (id === "skills:1") return skill;
if (id === "skillVersions:latest") return version;
if (id === "packages:1") return pkg;
if (id === "packageReleases:latest") return release;
return null;
}),
insert: vi.fn(async (table: string, doc: Record<string, unknown>) => {
if (table !== "auditLogs") throw new Error(`unexpected insert ${table}`);
auditLogs.push(doc);
return `auditLogs:${auditLogs.length}`;
}),
patch: vi.fn(
async (
tableOrId: string,
idOrPatch: string | Record<string, unknown>,
maybePatch?: Record<string, unknown>,
) => {
const id = maybePatch ? (idOrPatch as string) : tableOrId;
const patch = maybePatch ?? (idOrPatch as Record<string, unknown>);
if (id === "skillVersions:latest") Object.assign(version, patch);
if (id === "packageReleases:latest") Object.assign(release, patch);
},
),
query: vi.fn((table: string) => {
throw new Error(`unexpected table ${table}`);
}),
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
system: {},
};
return { db, auditLogs, version, release };
}
beforeEach(() => {
vi.mocked(requireUser).mockReset();
vi.mocked(requireUser).mockResolvedValue({
userId: "users:owner",
user: { _id: "users:owner", role: "user" },
} as never);
});
describe("publisher ClawScan note updates", () => {
it("updates a latest skill publisher note, writes audit metadata, and schedules ClawScan", async () => {
const { db, auditLogs, version } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await updateSkillClawScanNoteAndRequestRescanHandler({ db, scheduler } as never, {
skillId: "skills:1",
clawScanNote: "New context for the scanner.",
});
expect(version).toMatchObject({
clawScanNote: "New context for the scanner.",
clawScanNoteUpdatedAt: expect.any(Number),
});
expect(auditLogs[0]).toMatchObject({
action: "skill.clawscan_note.update",
targetType: "skillVersion",
targetId: "skillVersions:latest",
metadata: expect.objectContaining({
hadPreviousNote: true,
hasNextNote: true,
nextLength: 28,
}),
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
versionId: "skillVersions:latest",
}),
);
});
it("clears a latest skill publisher note while preserving the update timestamp", async () => {
const { db, auditLogs, version } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await updateSkillClawScanNoteAndRequestRescanHandler({ db, scheduler } as never, {
skillId: "skills:1",
clawScanNote: " ",
});
expect(version).toMatchObject({
clawScanNote: "",
clawScanNoteUpdatedAt: expect.any(Number),
});
expect(auditLogs[0]).toMatchObject({
action: "skill.clawscan_note.update",
metadata: expect.objectContaining({
hadPreviousNote: true,
hasNextNote: false,
nextLength: 0,
}),
});
});
it("updates a latest plugin publisher note, writes audit metadata, and schedules ClawScan", async () => {
const { db, auditLogs, release } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await updatePackageClawScanNoteAndRequestRescanHandler({ db, scheduler } as never, {
packageId: "packages:1",
clawScanNote: "Plugin native host is scoped to local files.",
});
expect(release).toMatchObject({
clawScanNote: "Plugin native host is scoped to local files.",
clawScanNoteUpdatedAt: expect.any(Number),
});
expect(auditLogs[0]).toMatchObject({
action: "package.clawscan_note.update",
targetType: "packageRelease",
targetId: "packageReleases:latest",
metadata: expect.objectContaining({
hadPreviousNote: true,
hasNextNote: true,
}),
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
releaseId: "packageReleases:latest",
}),
);
});
it("allows platform moderators to update latest skill publisher notes", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const { db, version } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await updateSkillClawScanNoteAndRequestRescanHandler({ db, scheduler } as never, {
skillId: "skills:1",
clawScanNote: "Moderator context.",
});
expect(version).toMatchObject({
clawScanNote: "Moderator context.",
clawScanNoteUpdatedAt: expect.any(Number),
});
});
it("allows platform moderators to update latest plugin publisher notes", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const { db, release } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await updatePackageClawScanNoteAndRequestRescanHandler({ db, scheduler } as never, {
packageId: "packages:1",
clawScanNote: "Moderator plugin context.",
});
expect(release).toMatchObject({
clawScanNote: "Moderator plugin context.",
clawScanNoteUpdatedAt: expect.any(Number),
});
});
});
+172
View File
@@ -0,0 +1,172 @@
import { v } from "convex/values";
import { internalMutation, mutation } from "./functions";
import { requireUser } from "./lib/access";
import { generateToken, hashToken } from "./lib/tokens";
const DEVICE_CODE_TTL_MS = 15 * 60_000;
const DEVICE_POLL_INTERVAL_SECONDS = 5;
const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export const createInternal = internalMutation({
args: {
scope: v.optional(v.string()),
label: v.optional(v.string()),
siteUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const deviceCode = generateOpaqueCode();
const userCode = generateUserCode();
const now = Date.now();
const label = (args.label?.trim() || "CLI device login").slice(0, 120);
const scope = (args.scope?.trim() || "read write").slice(0, 200);
await ctx.db.insert("cliDeviceCodes", {
deviceCodeHash: await hashToken(deviceCode),
userCodeHash: await hashToken(normalizeUserCode(userCode)),
userCode,
label,
scope,
status: "pending",
createdAt: now,
expiresAt: now + DEVICE_CODE_TTL_MS,
});
const verificationUrl = getVerificationUrl(args.siteUrl, userCode);
return {
device_code: deviceCode,
user_code: userCode,
verification_uri: verificationUrl.toString(),
expires_in: Math.floor(DEVICE_CODE_TTL_MS / 1000),
interval: DEVICE_POLL_INTERVAL_SECONDS,
};
},
});
export const pollInternal = internalMutation({
args: { deviceCode: v.string() },
handler: async (ctx, args) => {
const deviceCodeHash = await hashToken(args.deviceCode);
const row = await ctx.db
.query("cliDeviceCodes")
.withIndex("by_device_code_hash", (q) => q.eq("deviceCodeHash", deviceCodeHash))
.unique();
if (!row) return { error: "expired_token" as const };
const now = Date.now();
if (row.expiresAt <= now) {
if (row.status !== "expired") await ctx.db.patch(row._id, { status: "expired" });
return { error: "expired_token" as const };
}
if (row.status === "pending") return { error: "authorization_pending" as const };
if (row.status === "denied") return { error: "access_denied" as const };
if (row.status === "consumed" || row.status === "expired") {
return { error: "expired_token" as const };
}
if (!row.approvedByUserId) return { error: "authorization_pending" as const };
const { token, prefix } = generateToken();
await ctx.db.insert("apiTokens", {
userId: row.approvedByUserId,
label: row.label,
prefix,
tokenHash: await hashToken(token),
createdAt: now,
lastUsedAt: undefined,
revokedAt: undefined,
});
await ctx.db.patch(row._id, { status: "consumed", consumedAt: now });
return { access_token: token, token_type: "bearer" as const, scope: row.scope };
},
});
export const approve = mutation({
args: { userCode: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx);
const normalized = normalizeUserCode(args.userCode);
if (!normalized) throw new Error("Code required");
const userCodeHash = await hashToken(normalized);
const row = await ctx.db
.query("cliDeviceCodes")
.withIndex("by_user_code_hash", (q) => q.eq("userCodeHash", userCodeHash))
.unique();
if (!row) throw new Error("Device code not found");
const now = Date.now();
if (row.expiresAt <= now) {
if (row.status !== "expired") await ctx.db.patch(row._id, { status: "expired" });
throw new Error("Device code expired");
}
if (row.status === "consumed") throw new Error("Device code already used");
if (row.status === "approved") throw new Error("Device code already authorized");
if (row.status === "denied") throw new Error("Device code was denied");
await ctx.db.patch(row._id, {
status: "approved",
approvedByUserId: userId,
approvedAt: now,
});
return { ok: true, userCode: row.userCode, expiresAt: row.expiresAt };
},
});
export const deny = mutation({
args: { userCode: v.string() },
handler: async (ctx, args) => {
await requireUser(ctx);
const normalized = normalizeUserCode(args.userCode);
if (!normalized) throw new Error("Code required");
const userCodeHash = await hashToken(normalized);
const row = await ctx.db
.query("cliDeviceCodes")
.withIndex("by_user_code_hash", (q) => q.eq("userCodeHash", userCodeHash))
.unique();
if (!row) throw new Error("Device code not found");
const now = Date.now();
if (row.status === "approved") throw new Error("Device code already authorized");
if (row.status === "pending") {
await ctx.db.patch(row._id, { status: "denied", deniedAt: now });
}
return { ok: true };
},
});
function normalizeUserCode(value: string) {
return value
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
function generateUserCode() {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
const raw = Array.from(
bytes,
(byte) => USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length],
).join("");
return `${raw.slice(0, 4)}-${raw.slice(4)}`;
}
function generateOpaqueCode() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function getVerificationUrl(siteUrlValue: string | undefined, userCode: string) {
const baseUrl = siteUrlValue?.trim() || "https://clawhub.ai";
let verificationUrl: URL;
try {
verificationUrl = new URL("/cli/device", baseUrl);
if (verificationUrl.protocol !== "http:" && verificationUrl.protocol !== "https:") {
verificationUrl = new URL("/cli/device", "https://clawhub.ai");
}
} catch {
verificationUrl = new URL("/cli/device", "https://clawhub.ai");
}
verificationUrl.searchParams.set("code", userCode);
return verificationUrl;
}
+8 -4
View File
@@ -33,6 +33,13 @@ crons.interval(
{},
);
crons.interval(
"package-stat-events",
{ minutes: 15 },
internal.packages.processPackageStatEventsInternal,
{ batchSize: 500 },
);
// Syncs accumulated stat deltas to skill documents every 6 hours.
// Runs infrequently to avoid thundering-herd reactive query invalidation.
// Uses processedAt field to track progress (independent of the action cursor).
@@ -40,7 +47,7 @@ crons.interval(
"skill-doc-stat-sync",
{ hours: 6 },
internal.skillStatEvents.processSkillStatEventsInternal,
{ batchSize: 500 },
{ batchSize: 100 },
);
crons.interval(
@@ -65,9 +72,6 @@ crons.interval(
{ batchSize: 100 },
);
// Daily re-scan of all active skills at 3am UTC
crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {});
crons.interval(
"download-dedupe-prune",
{ hours: 24 },
+449
View File
@@ -0,0 +1,449 @@
import { describe, expect, it } from "vitest";
import type { Id } from "./_generated/dataModel";
import {
currentUserSeedPackageName,
currentUserSeedSkillSlug,
seedFeaturedPluginPackagesMutation,
seedLocalModerationFixturesHandler,
seedSkillMutation,
} from "./devSeed";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
};
const seedSkillMutationHandler = (
seedSkillMutation as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
const seedFeaturedPluginPackagesHandler = (
seedFeaturedPluginPackagesMutation as unknown as WrappedHandler<Record<string, unknown>>
)._handler;
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const operations: Array<{ type: "delete"; table: string; id: string }> = [];
const list = (table: string) => {
tables[table] ??= [];
return tables[table];
};
const db = {
get: async (arg0: string, arg1?: string) => {
const id = arg1 ?? arg0;
const table = id.split(":")[0] ?? "";
return list(table).find((doc) => doc._id === id) ?? null;
},
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
_id: `${table}:${counters[table]}`,
_creationTime: counters[table],
...doc,
};
list(table).push(inserted);
return inserted._id;
},
patch: async (
arg0: string,
arg1: string | Record<string, unknown>,
arg2?: Record<string, unknown>,
) => {
const id = arg2 ? (arg1 as string) : arg0;
const patch = arg2 ?? (arg1 as Record<string, unknown>);
const table = id.split(":")[0] ?? "";
const doc = list(table).find((candidate) => candidate._id === id);
if (doc) Object.assign(doc, patch);
},
replace: async (
arg0: string,
arg1: string | Record<string, unknown>,
arg2?: Record<string, unknown>,
) => {
const id = arg2 ? (arg1 as string) : arg0;
const replacement = arg2 ?? (arg1 as Record<string, unknown>);
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows[index] = { ...rows[index], ...replacement, _id: id };
},
delete: async (arg0: string, arg1?: string) => {
const id = arg1 ?? arg0;
const table = id.split(":")[0] ?? "";
operations.push({ type: "delete", table, id });
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows.splice(index, 1);
},
normalizeId: (tableName: string, id: string) => (id.startsWith(`${tableName}:`) ? id : null),
query: (table: string) => ({
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
collect: async () => matched(),
unique: async () => matched()[0] ?? null,
paginate: async () => ({
page: matched(),
isDone: true,
continueCursor: null,
}),
order: () => ({
collect: async () => matched(),
paginate: async () => ({
page: matched(),
isDone: true,
continueCursor: null,
}),
}),
};
},
}),
};
return { db, tables, operations };
}
function createMutationCtx(db: ReturnType<typeof createDb>["db"]) {
return { db, scheduler: { runAfter: async () => null } };
}
function seedSkillArgs(storageId: string) {
const clawdis = {
os: ["linux"],
nix: {
plugin: "github:example/catalog-demo",
systems: ["x86_64-linux"],
},
};
return {
storageId,
metadata: { clawdbot: { nix: clawdis.nix } },
frontmatter: { name: "catalog-demo", description: "Catalog demo" },
clawdis,
skillMd: "# Catalog demo",
slug: "catalog-demo",
displayName: "Catalog Demo",
summary: "Seeded catalog demo.",
version: "0.1.0",
};
}
describe("devSeed local 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", {
handle: "fuller-stack-dev",
displayName: "Fuller Stack Dev",
role: "user",
createdAt: 1,
updatedAt: 1,
})) as Id<"users">;
const scopedSlug = currentUserSeedSkillSlug(userId, "catalog-demo");
await seedSkillMutationHandler(
createMutationCtx(db) as never,
{
...seedSkillArgs("storage:first"),
ownerUserId: userId,
slug: scopedSlug,
} as never,
);
await seedSkillMutationHandler(
createMutationCtx(db) as never,
{
...seedSkillArgs("storage:second"),
ownerUserId: userId,
slug: scopedSlug,
} as never,
);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "fuller-stack-dev" }));
expect(tables.publishers).toHaveLength(1);
expect(tables.publishers?.[0]).toEqual(
expect.objectContaining({ handle: "fuller-stack-dev", linkedUserId: userId }),
);
expect(tables.skills).toHaveLength(1);
expect(tables.skills?.[0]).toEqual(
expect.objectContaining({
slug: scopedSlug,
ownerUserId: userId,
ownerPublisherId: tables.publishers?.[0]?._id,
}),
);
});
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", {
handle: "fuller-stack-dev",
displayName: "Fuller Stack Dev",
role: "user",
createdAt: 1,
updatedAt: 1,
})) as Id<"users">;
const flaggedSkillSlug = currentUserSeedSkillSlug(userId, "local-flagged-wallet-sync");
const scannedSkillSlug = currentUserSeedSkillSlug(userId, "local-agentic-risk-demo");
const flaggedPluginName = currentUserSeedPackageName(userId, "local-flagged-runtime-plugin");
const scannedPluginName = currentUserSeedPackageName(userId, "local-scanned-runtime-plugin");
await seedLocalModerationFixturesHandler(
createMutationCtx(db) as never,
{
ownerUserId: userId,
flaggedSkillSlug,
scannedSkillSlug,
flaggedPluginName,
scannedPluginName,
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: `---\nname: ${flaggedSkillSlug}\n---\n# Flagged skill`,
scannedSkillStorageId: "storage:scanned-skill",
scannedSkillMd: `---\nname: ${scannedSkillSlug}\n---\n# Scanned skill`,
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
} as never,
);
await seedFeaturedPluginPackagesHandler(
createMutationCtx(db) as never,
{
ownerUserId: userId,
packages: [
{
name: currentUserSeedPackageName(userId, "local-merge-notes-plugin"),
displayName: "Local Merge Notes",
summary: "Seeded local owner plugin.",
version: "0.1.0",
runtimeId: "local.merge.notes",
sourceRepo: "openclaw/local-merge-notes-plugin",
isOfficial: false,
capabilityTags: ["notes"],
stats: { downloads: 1, installs: 1, stars: 1, versions: 1 },
storageId: "storage:plugin-notes",
readmeSize: 16,
},
],
} as never,
);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "fuller-stack-dev" }));
expect(
tables.skills?.map((skill) => String(skill.slug)).sort((a, b) => a.localeCompare(b)),
).toEqual([scannedSkillSlug, flaggedSkillSlug]);
expect(tables.skills?.every((skill) => skill.ownerUserId === userId)).toBe(true);
expect(
tables.packages?.map((pkg) => String(pkg.name)).sort((a, b) => a.localeCompare(b)),
).toEqual([
flaggedPluginName,
currentUserSeedPackageName(userId, "local-merge-notes-plugin"),
scannedPluginName,
]);
expect(tables.packages?.every((pkg) => pkg.ownerUserId === userId)).toBe(true);
});
it("retires legacy @local-owner seed publishers so dev-auth users can claim the handle", async () => {
const { db, tables } = createDb();
const legacyUserId = (await db.insert("users", {
handle: "Local Owner",
displayName: "Local Owner",
role: "user",
createdAt: 1,
updatedAt: 1,
})) as Id<"users">;
const legacyPublisherId = (await db.insert("publishers", {
kind: "user",
handle: "local-owner",
displayName: "Local Owner",
linkedUserId: legacyUserId,
createdAt: 1,
updatedAt: 1,
})) as Id<"publishers">;
await db.patch(legacyUserId, { personalPublisherId: legacyPublisherId });
await db.insert("publisherMembers", {
publisherId: legacyPublisherId,
userId: legacyUserId,
role: "owner",
createdAt: 1,
updatedAt: 1,
});
await db.insert("packages", {
name: "local-scanned-runtime-plugin",
normalizedName: "local-scanned-runtime-plugin",
ownerUserId: legacyUserId,
ownerPublisherId: legacyPublisherId,
softDeletedAt: undefined,
createdAt: 1,
updatedAt: 1,
});
await seedLocalModerationFixturesHandler(
createMutationCtx(db) as never,
{
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
scannedSkillStorageId: "storage:scanned-skill",
scannedSkillMd: "# Scanned skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
} as never,
);
expect(tables.publishers?.some((publisher) => publisher.handle === "local-owner")).toBe(false);
expect(tables.publishers).toContainEqual(
expect.objectContaining({
_id: legacyPublisherId,
handle: expect.stringMatching(/^legacy-local-owner-/),
deactivatedAt: expect.any(Number),
deletedAt: expect.any(Number),
}),
);
expect(
tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")?.ownerPublisherId,
).not.toBe(legacyPublisherId);
});
it("adopts a legacy @local publisher instead of creating a conflicting seed user", async () => {
const { db, tables } = createDb();
const legacyUserId = (await db.insert("users", {
handle: "Local Owner",
displayName: "Local Owner",
name: "Local Owner",
role: "user",
createdAt: 1,
updatedAt: 1,
})) as Id<"users">;
const legacyPublisherId = (await db.insert("publishers", {
kind: "user",
handle: "local",
displayName: "Local Owner",
linkedUserId: legacyUserId,
createdAt: 1,
updatedAt: 1,
})) as Id<"publishers">;
await db.patch(legacyUserId, { personalPublisherId: legacyPublisherId });
await db.insert("publisherMembers", {
publisherId: legacyPublisherId,
userId: legacyUserId,
role: "owner",
createdAt: 1,
updatedAt: 1,
});
await seedLocalModerationFixturesHandler(
createMutationCtx(db) as never,
{
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
scannedSkillStorageId: "storage:scanned-skill",
scannedSkillMd: "# Scanned skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
} as never,
);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(
expect.objectContaining({
_id: legacyUserId,
handle: "local",
role: "admin",
personalPublisherId: legacyPublisherId,
}),
);
expect(tables.publishers?.filter((publisher) => publisher.handle === "local")).toHaveLength(1);
});
it("resets core skill fixtures without stale badges or embedding maps", async () => {
const { db, tables } = createDb();
await seedSkillMutationHandler(
createMutationCtx(db) as never,
seedSkillArgs("storage:first") as never,
);
await seedSkillMutationHandler(
createMutationCtx(db) as never,
{ ...seedSkillArgs("storage:second"), reset: true } as never,
);
expect(tables.skills).toHaveLength(1);
expect(tables.skillVersions).toHaveLength(1);
expect(tables.skillEmbeddings).toHaveLength(1);
expect(tables.embeddingSkillMap).toHaveLength(1);
expect(tables.skillBadges).toHaveLength(1);
expect(tables.skills?.[0]?.latestVersionSummary).toBeUndefined();
expect(tables.skillVersions?.[0]).toEqual(
expect.objectContaining({
parsed: expect.objectContaining({
clawdis: expect.objectContaining({
os: ["linux"],
nix: expect.objectContaining({ systems: ["x86_64-linux"] }),
}),
}),
}),
);
});
it("resets featured plugin fixtures without stale package badges", async () => {
const { db, tables, operations } = createDb();
const args = {
packages: [
{
name: "@local/catalog-plugin",
displayName: "Catalog Plugin",
summary: "Seeded catalog plugin.",
version: "1.0.0",
runtimeId: "catalog-plugin",
sourceRepo: "openclaw/catalog-plugin",
isOfficial: false,
capabilityTags: ["catalog"],
stats: { downloads: 1, installs: 1, stars: 1, versions: 1 },
storageId: "storage:plugin",
readmeSize: 16,
},
],
};
await seedFeaturedPluginPackagesHandler(createMutationCtx(db) as never, args as never);
const oldPackageId = tables.packages?.[0]?._id;
const oldReleaseId = tables.packageReleases?.[0]?._id;
await seedFeaturedPluginPackagesHandler(
createMutationCtx(db) as never,
{ ...args, reset: true } as never,
);
expect(tables.packages).toHaveLength(1);
expect(tables.packageReleases).toHaveLength(1);
expect(tables.packageBadges).toHaveLength(1);
const oldPackageDeleteIndex = operations.findIndex(
(op) => op.table === "packages" && op.id === oldPackageId,
);
const oldReleaseDeleteIndex = operations.findIndex(
(op) => op.table === "packageReleases" && op.id === oldReleaseId,
);
expect(oldPackageDeleteIndex).toBeGreaterThanOrEqual(0);
expect(oldReleaseDeleteIndex).toBeGreaterThan(oldPackageDeleteIndex);
});
});
-175
View File
@@ -1,175 +0,0 @@
import { describe, expect, it } from "vitest";
import { seedRescanUxFixturesHandler } from "./devSeed";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const list = (table: string) => {
tables[table] ??= [];
return tables[table];
};
const db = {
get: async (id: string) => {
const table = id.split(":")[0] ?? "";
return list(table).find((doc) => doc._id === id) ?? null;
},
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
_id: `${table}:${counters[table]}`,
_creationTime: counters[table],
...doc,
};
list(table).push(inserted);
return inserted._id;
},
patch: async (id: string, patch: Record<string, unknown>) => {
const table = id.split(":")[0] ?? "";
const doc = list(table).find((candidate) => candidate._id === id);
if (doc) Object.assign(doc, patch);
},
delete: async (id: string) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows.splice(index, 1);
},
query: (table: string) => ({
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
collect: async () => matched(),
unique: async () => matched()[0] ?? null,
order: () => ({
collect: async () => matched(),
}),
};
},
}),
};
return { db, tables };
}
describe("devSeed rescan UX fixtures", () => {
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
const { db, tables } = createDb();
const args = {
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
scannedSkillStorageId: "storage:scanned-skill",
scannedSkillMd: "# Scanned skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
};
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
expect(tables.publishers).toHaveLength(1);
expect(tables.skills).toHaveLength(2);
expect(tables.skills?.find((skill) => skill.slug === "local-flagged-wallet-sync")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
moderationStatus: "hidden",
moderationVerdict: "malicious",
}),
);
expect(tables.skills?.find((skill) => skill.slug === "local-agentic-risk-demo")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
moderationStatus: "active",
moderationVerdict: "suspicious",
}),
);
expect(tables.packages).toHaveLength(2);
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "malicious",
}),
);
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "suspicious",
}),
);
const scannedPackage = tables.packages?.find(
(pkg) => pkg.name === "local-scanned-runtime-plugin",
);
const scannedRelease = tables.packageReleases?.find(
(release) => release.packageId === scannedPackage?._id,
);
expect(scannedRelease).toEqual(
expect.objectContaining({
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: expect.objectContaining({ status: "clean" }),
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
const scannedSkill = tables.skills?.find((skill) => skill.slug === "local-agentic-risk-demo");
const scannedSkillVersion = tables.skillVersions?.find(
(version) => version.skillId === scannedSkill?._id,
);
expect(scannedSkillVersion).toEqual(
expect.objectContaining({
sha256hash: "seeded-agentic-risk-skill-hash",
vtAnalysis: expect.objectContaining({ status: "clean" }),
llmAnalysis: expect.objectContaining({
status: "suspicious",
riskSummary: expect.objectContaining({
sensitive_data_protection: expect.objectContaining({ status: "concern" }),
}),
agenticRiskFindings: expect.arrayContaining([
expect.objectContaining({
categoryId: "ASI06",
riskBucket: "sensitive_data_protection",
status: "concern",
evidence: expect.objectContaining({ path: "SKILL.md" }),
}),
]),
}),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
const skillRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
const pluginRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
expect(skillRequests).toHaveLength(1);
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
});
});
+1230 -393
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -62,7 +62,7 @@ describe("downloads helpers", () => {
expect(__test.getDownloadIdentityValue(request, null)).toBeNull();
});
it("records zip downloads through the internal mutation path", async () => {
it("schedules zip download stats outside the response path", async () => {
class MockResponse {
status: number;
headers: Headers;
@@ -103,12 +103,14 @@ describe("downloads helpers", () => {
if (isRateLimitArgs(args)) return okRate();
return { mutation, 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", {
@@ -120,7 +122,7 @@ describe("downloads helpers", () => {
expect(response.headers.get("Content-Type")).toBe("application/zip");
expect(storageGet).toHaveBeenCalledWith("_storage:1");
const recordCalls = runMutation.mock.calls.filter(([, args]) => {
const recordCalls = runAfter.mock.calls.filter(([, , args]) => {
if (!args || typeof args !== "object") return false;
const value = args as Record<string, unknown>;
return (
@@ -130,7 +132,10 @@ describe("downloads helpers", () => {
);
});
expect(recordCalls).toHaveLength(1);
expect(recordCalls[0]?.[1]).toEqual({
expect(recordCalls[0]?.[0]).toEqual(expect.any(Number));
expect(recordCalls[0]?.[0]).toBeGreaterThanOrEqual(0);
expect(recordCalls[0]?.[0]).toBeLessThan(60_000);
expect(recordCalls[0]?.[2]).toEqual({
skillId: "skills:1",
identityHash: expect.any(String),
hourStart: expect.any(Number),
+13 -8
View File
@@ -12,6 +12,7 @@ const HOUR_MS = 3_600_000;
const DEDUPE_RETENTION_MS = 7 * 24 * HOUR_MS;
const PRUNE_BATCH_SIZE = 200;
const PRUNE_MAX_BATCHES = 50;
const DOWNLOAD_STAT_JITTER_MS = 60_000;
export async function downloadZipHandler(
ctx: Parameters<Parameters<typeof httpAction>[0]>[0],
@@ -44,7 +45,7 @@ export async function downloadZipHandler(
const mod = skillResult.moderationInfo;
if (mod?.isMalwareBlocked) {
return new Response(
"Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.",
"Blocked: this skill has been flagged as malicious by ClawScan and cannot be downloaded.",
{
status: 403,
headers: mergeHeaders(rate.headers, corsHeaders()),
@@ -53,7 +54,7 @@ export async function downloadZipHandler(
}
if (mod?.isPendingScan) {
return new Response(
"This skill is pending a security scan by VirusTotal. Please try again in a few minutes.",
"This skill is pending a ClawScan security review. Please try again in a few minutes.",
{
status: 423,
headers: mergeHeaders(rate.headers, corsHeaders()),
@@ -124,11 +125,15 @@ export async function downloadZipHandler(
const userId = await getOptionalApiTokenUserId(ctx, request);
const identity = getDownloadIdentityValue(request, userId ? String(userId) : null);
if (identity) {
await ctx.runMutation(internal.downloads.recordDownloadInternal, {
skillId: skill._id,
identityHash: await hashToken(identity),
hourStart: getHourStart(Date.now()),
});
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()),
},
);
}
} catch {
// Best-effort metric path; do not fail downloads.
@@ -165,7 +170,7 @@ export const recordDownloadInternal = internalMutation({
.eq("identityHash", args.identityHash)
.eq("hourStart", args.hourStart),
)
.unique();
.first();
if (existing) return;
await ctx.db.insert("downloadDedupes", {
+20 -3
View File
@@ -143,6 +143,14 @@ describe("package digest sync", () => {
packageId: "packages:demo",
latestVersion: undefined,
ownerHandle: "owner",
pluginCategoryTags: ["dev-tools"],
}),
);
expect(ctx.db.insert).toHaveBeenCalledWith(
"packagePluginCategorySearchDigest",
expect.objectContaining({
packageId: "packages:demo",
pluginCategory: "dev-tools",
}),
);
});
@@ -302,7 +310,10 @@ describe("package digest sync", () => {
})),
};
}
if (table === "packageCapabilitySearchDigest") {
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
@@ -438,7 +449,10 @@ describe("package digest sync", () => {
})),
};
}
if (table === "packageCapabilitySearchDigest") {
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
@@ -537,7 +551,10 @@ describe("package digest sync", () => {
})),
};
}
if (table === "packageCapabilitySearchDigest") {
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
+25
View File
@@ -20,6 +20,10 @@ import {
upsertPackageSearchDigest,
} from "./lib/packageSearchDigest";
import { getOwnerPublisher } from "./lib/publishers";
import {
adjustPublisherStatsForPackageChange,
adjustPublisherStatsForSkillChange,
} from "./lib/publisherStats";
import { extractDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest";
const triggers = new Triggers<DataModel>();
@@ -370,6 +374,11 @@ export async function repointPackageLatestRelease(
}
triggers.register("skills", async (ctx, change) => {
await adjustPublisherStatsForSkillChange(
ctx,
change.operation === "insert" ? null : change.oldDoc,
change.operation === "delete" ? null : change.newDoc,
);
if (change.operation === "delete") {
await scheduleGitHubBackupDeletionForSkill(ctx, change.oldDoc);
const existing = await ctx.db
@@ -390,6 +399,11 @@ triggers.register("skills", async (ctx, change) => {
});
triggers.register("packages", async (ctx, change) => {
await adjustPublisherStatsForPackageChange(
ctx,
change.operation === "insert" ? null : change.oldDoc,
change.operation === "delete" ? null : change.newDoc,
);
if (change.operation === "delete") {
await deletePackageSearchDigests(ctx, change.id);
return;
@@ -430,6 +444,17 @@ triggers.register("users", async (ctx, change) => {
});
triggers.register("publishers", async (ctx, change) => {
if (
change.operation === "update" &&
change.oldDoc.handle === change.newDoc.handle &&
change.oldDoc.kind === change.newDoc.kind &&
change.oldDoc.displayName === change.newDoc.displayName &&
change.oldDoc.image === change.newDoc.image &&
change.oldDoc.deletedAt === change.newDoc.deletedAt &&
change.oldDoc.deactivatedAt === change.newDoc.deactivatedAt
) {
return;
}
const ownerPublisherId = change.operation === "delete" ? change.id : change.newDoc._id;
await scheduleOwnerPublisherDigestSync(ctx, ownerPublisherId);
});
+23 -16
View File
@@ -219,25 +219,32 @@ export const importGitHubSkill = action({
if (!displayName) throw new ConvexError("Display name required");
if (!version || !semver.valid(version)) throw new ConvexError("Version must be valid semver");
const sourceProvenance = {
kind: "github" as const,
url: resolved.originalUrl,
repo: `${resolved.owner}/${resolved.repo}`,
ref: resolved.ref,
commit: resolved.commit,
path: candidate.path,
importedAt: Date.now(),
};
let result: Awaited<ReturnType<typeof publishVersionForUser>>;
try {
result = await publishVersionForUser(ctx, userId, {
slug: slugBase,
displayName,
version,
changelog: "",
tags,
files: storedFiles,
source: {
kind: "github",
url: resolved.originalUrl,
repo: `${resolved.owner}/${resolved.repo}`,
ref: resolved.ref,
commit: resolved.commit,
path: candidate.path,
importedAt: Date.now(),
result = await publishVersionForUser(
ctx,
userId,
{
slug: slugBase,
displayName,
version,
changelog: "",
tags,
files: storedFiles,
source: sourceProvenance,
},
});
{ sourceProvenance },
);
} catch (error) {
throw new ConvexError(buildPublishFailureMessage(error));
}
+42
View File
@@ -4,6 +4,8 @@ import { auth } from "./auth";
import { downloadZip } from "./downloads";
import {
cliPublishHttp,
cliDeviceCodeHttp,
cliDeviceTokenHttp,
cliSkillDeleteHttp,
cliSkillUndeleteHttp,
cliTelemetrySyncHttp,
@@ -14,6 +16,7 @@ import {
searchSkillsHttp,
} from "./httpApi";
import {
exportSkillsV1Http,
listBundlePluginsV1Http,
listCodePluginsV1Http,
listPackagesV1Http,
@@ -21,10 +24,12 @@ import {
listSkillsV1Http,
listSoulsV1Http,
mintPublishTokenV1Http,
npmMirrorGetHttp,
packagesDeleteRouterV1Http,
packagesGetRouterV1Http,
packagesPostRouterV1Http,
pluginsGetRouterV1Http,
createPublisherV1Http,
publishPackageV1Http,
publishSkillV1Http,
publishSoulV1Http,
@@ -41,6 +46,7 @@ import {
transfersGetRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
verifyDocsSessionV1Http,
whoamiV1Http,
} from "./httpApiV1";
import { preflightHandler } from "./httpPreflight";
@@ -67,6 +73,12 @@ http.route({
handler: resolveSkillVersionV1Http,
});
http.route({
path: ApiRoutes.skillsExport,
method: "GET",
handler: exportSkillsV1Http,
});
http.route({
path: ApiRoutes.skills,
method: "GET",
@@ -109,6 +121,12 @@ http.route({
handler: packagesGetRouterV1Http,
});
http.route({
pathPrefix: "/api/npm/",
method: "GET",
handler: npmMirrorGetHttp,
});
http.route({
pathPrefix: `${ApiRoutes.plugins}/`,
method: "GET",
@@ -175,12 +193,36 @@ http.route({
handler: transfersGetRouterV1Http,
});
http.route({
path: ApiRoutes.publishers,
method: "POST",
handler: createPublisherV1Http,
});
http.route({
path: ApiRoutes.whoami,
method: "GET",
handler: whoamiV1Http,
});
http.route({
path: "/api/cli/device/code",
method: "POST",
handler: cliDeviceCodeHttp,
});
http.route({
path: "/api/cli/device/token",
method: "POST",
handler: cliDeviceTokenHttp,
});
http.route({
path: "/api/v1/docs/session/verify",
method: "GET",
handler: verifyDocsSessionV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.users}/`,
method: "POST",
+105 -2
View File
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/apiTokenAuth", () => ({
getOptionalApiTokenUser: vi.fn(),
requireApiTokenUser: vi.fn(),
}));
@@ -9,7 +10,7 @@ vi.mock("./skills", () => ({
publishVersionForUser: vi.fn(),
}));
const { requireApiTokenUser } = await import("./lib/apiTokenAuth");
const { getOptionalApiTokenUser, requireApiTokenUser } = await import("./lib/apiTokenAuth");
const { publishVersionForUser } = await import("./skills");
const { __handlers } = await import("./httpApi");
const { hashSkillFiles } = await import("./lib/skills");
@@ -20,6 +21,7 @@ function makeCtx(partial: Record<string, unknown>) {
describe("httpApi handlers", () => {
afterEach(() => {
vi.mocked(getOptionalApiTokenUser).mockReset();
vi.mocked(requireApiTokenUser).mockReset();
vi.mocked(publishVersionForUser).mockReset();
});
@@ -236,12 +238,17 @@ describe("httpApi handlers", () => {
});
it("cliWhoamiHttp returns 401 on auth failure", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
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.",
),
);
const response = await __handlers.cliWhoamiHandler(
makeCtx({}),
new Request("https://x/api/cli/whoami"),
);
expect(response.status).toBe(401);
expect(await response.text()).toContain("not in good standing");
});
it("cliWhoamiHttp returns user payload on success", async () => {
@@ -340,6 +347,102 @@ describe("httpApi handlers", () => {
expect(response.status).toBe(401);
});
it("cliDeviceCodeHttp rate limits and creates a device code", async () => {
vi.mocked(getOptionalApiTokenUser).mockResolvedValueOnce(null);
const result = {
device_code: "device",
user_code: "ABCD-2345",
verification_uri: "https://clawhub.ai/cli/device?code=ABCD-2345",
expires_in: 900,
interval: 5,
};
const runQuery = vi.fn().mockResolvedValue({
allowed: true,
remaining: 300,
limit: 300,
resetAt: Date.now() + 60_000,
});
const runMutation = vi
.fn()
.mockResolvedValueOnce({ allowed: true, remaining: 299 })
.mockResolvedValueOnce(result);
const response = await __handlers.cliDeviceCodeHandler(
makeCtx({ runQuery, runMutation }),
new Request("https://x/api/cli/device/code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "read write",
label: "ssh box",
site_url: "https://clawhub.ai",
}),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("RateLimit-Limit")).toBe("300");
expect(await response.json()).toEqual(result);
expect(runMutation).toHaveBeenLastCalledWith(expect.anything(), {
scope: "read write",
label: "ssh box",
siteUrl: "https://clawhub.ai",
});
});
it("cliDeviceTokenHttp requires the device grant type", async () => {
vi.mocked(getOptionalApiTokenUser).mockResolvedValueOnce(null);
const runQuery = vi.fn().mockResolvedValue({
allowed: true,
remaining: 300,
limit: 300,
resetAt: Date.now() + 60_000,
});
const runMutation = vi.fn().mockResolvedValueOnce({ allowed: true, remaining: 299 });
const response = await __handlers.cliDeviceTokenHandler(
makeCtx({ runQuery, runMutation }),
new Request("https://x/api/cli/device/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: "device" }),
}),
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: "unsupported_grant_type" });
expect(runMutation).toHaveBeenCalledTimes(1);
});
it("cliDeviceTokenHttp returns pending as retryable", async () => {
vi.mocked(getOptionalApiTokenUser).mockResolvedValueOnce(null);
const runQuery = vi.fn().mockResolvedValue({
allowed: true,
remaining: 300,
limit: 300,
resetAt: Date.now() + 60_000,
});
const runMutation = vi
.fn()
.mockResolvedValueOnce({ allowed: true, remaining: 299 })
.mockResolvedValueOnce({ error: "authorization_pending" });
const response = await __handlers.cliDeviceTokenHandler(
makeCtx({ runQuery, runMutation }),
new Request("https://x/api/cli/device/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_code: "device",
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
}),
);
expect(response.status).toBe(428);
expect(await response.json()).toEqual({ error: "authorization_pending" });
});
it("cliUploadUrlHttp returns uploadUrl", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "user1" } as never);
const runMutation = vi.fn().mockResolvedValue("https://upload.local");
+76 -9
View File
@@ -12,6 +12,7 @@ import type { ActionCtx } from "./_generated/server";
import { httpAction } from "./functions";
import { requireApiTokenUser } from "./lib/apiTokenAuth";
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
import { applyRateLimit } from "./lib/httpRateLimit";
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "./lib/httpUtils";
import { publishVersionForUser } from "./skills";
@@ -138,8 +139,8 @@ async function cliWhoamiHandler(ctx: ActionCtx, request: Request) {
image: user.image ?? null,
},
});
} catch {
return text("Unauthorized", 401);
} catch (error) {
return text(formatAuthFailure(error), 401);
}
}
@@ -152,8 +153,8 @@ async function cliUploadUrlHandler(ctx: ActionCtx, request: Request) {
userId,
});
return json({ uploadUrl });
} catch {
return text("Unauthorized", 401);
} catch (error) {
return text(formatAuthFailure(error), 401);
}
}
@@ -177,7 +178,7 @@ async function cliPublishHandler(ctx: ActionCtx, request: Request) {
return json({ ok: true, ...result });
} catch (error) {
const message = error instanceof Error ? error.message : "Publish failed";
if (message.toLowerCase().includes("unauthorized")) return text("Unauthorized", 401);
if (message.toLowerCase().includes("unauthorized")) return text(formatAuthFailure(error), 401);
return text(message, 400);
}
}
@@ -203,12 +204,13 @@ async function cliSkillDeleteHandler(ctx: ActionCtx, request: Request, deleted:
userId,
slug: args.slug,
deleted,
reason: args.reason,
});
const ok = parseArk(ApiCliSkillDeleteResponseSchema, { ok: true }, "Delete response");
return json(ok);
} catch (error) {
const message = error instanceof Error ? error.message : "Delete failed";
if (message.toLowerCase().includes("unauthorized")) return text("Unauthorized", 401);
if (message.toLowerCase().includes("unauthorized")) return text(formatAuthFailure(error), 401);
return text(message, 400);
}
}
@@ -246,14 +248,68 @@ async function cliTelemetrySyncHandler(ctx: ActionCtx, request: Request) {
return json(ok);
} catch (error) {
const message = error instanceof Error ? error.message : "Telemetry failed";
if (message.toLowerCase().includes("unauthorized")) return text("Unauthorized", 401);
if (message.toLowerCase().includes("unauthorized")) return text(formatAuthFailure(error), 401);
return text(message, 400);
}
}
export const cliTelemetrySyncHttp = httpAction(cliTelemetrySyncHandler);
function json(value: unknown, status = 200) {
async function cliDeviceCodeHandler(ctx: ActionCtx, request: Request) {
if (request.method !== "POST") return text("Method not allowed", 405);
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const body = (await request.json().catch(() => ({}))) as {
scope?: unknown;
label?: unknown;
site_url?: unknown;
};
const result = await ctx.runMutation(internal.cliDeviceAuth.createInternal, {
scope: typeof body.scope === "string" ? body.scope : undefined,
label: typeof body.label === "string" ? body.label : undefined,
siteUrl: typeof body.site_url === "string" ? body.site_url : undefined,
});
return json(result, 200, rate.headers);
}
export const cliDeviceCodeHttp = httpAction(cliDeviceCodeHandler);
async function cliDeviceTokenHandler(ctx: ActionCtx, request: Request) {
if (request.method !== "POST") return text("Method not allowed", 405);
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const body = (await request.json().catch(() => null)) as {
device_code?: unknown;
grant_type?: unknown;
} | null;
const deviceCode = typeof body?.device_code === "string" ? body.device_code.trim() : "";
const grantType = typeof body?.grant_type === "string" ? body.grant_type.trim() : "";
if (!deviceCode) {
return json(
{ error: "invalid_request", error_description: "device_code required" },
400,
rate.headers,
);
}
if (grantType !== "urn:ietf:params:oauth:grant-type:device_code") {
return json(
{ error: "unsupported_grant_type", error_description: "device_code grant required" },
400,
rate.headers,
);
}
const result = await ctx.runMutation(internal.cliDeviceAuth.pollInternal, { deviceCode });
if ("access_token" in result) return json(result, 200, rate.headers);
const status = result.error === "authorization_pending" ? 428 : 400;
return json(result, status, rate.headers);
}
export const cliDeviceTokenHttp = httpAction(cliDeviceTokenHandler);
function json(value: unknown, status = 200, headers?: HeadersInit) {
return new Response(JSON.stringify(value), {
status,
headers: mergeHeaders(
@@ -261,12 +317,13 @@ function json(value: unknown, status = 200) {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
headers,
corsHeaders(),
),
});
}
function text(value: string, status: number) {
function text(value: string, status: number, headers?: HeadersInit) {
return new Response(value, {
status,
headers: mergeHeaders(
@@ -274,11 +331,18 @@ function text(value: string, status: number) {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
headers,
corsHeaders(),
),
});
}
function formatAuthFailure(error: unknown) {
const message = error instanceof Error ? error.message.trim() : "";
if (!message || /^unauthorized$/i.test(message)) return "Unauthorized";
return message.replace(/^ConvexError:\s*/i, "").trim() || "Unauthorized";
}
function toOptionalNumber(value: string | null) {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
@@ -294,6 +358,7 @@ function parsePublishBody(body: unknown) {
displayName: parsed.displayName,
version: parsed.version,
changelog: parsed.changelog,
clawScanNote: parsed.clawScanNote?.trim() || undefined,
acceptLicenseTerms: parsed.acceptLicenseTerms,
tags,
source: parsed.source ?? undefined,
@@ -324,4 +389,6 @@ export const __handlers = {
cliPublishHandler,
cliSkillDeleteHandler,
cliTelemetrySyncHandler,
cliDeviceCodeHandler,
cliDeviceTokenHandler,
};
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { formatUserFacingErrorMessage, resolveVersionTagsBatch } from "./httpApiV1/shared";
function makeCtx() {
return {
runQuery: vi.fn(),
} as unknown as ActionCtx & { runQuery: ReturnType<typeof vi.fn> };
}
describe("http API v1 shared helpers", () => {
it("removes Convex transport wrappers from user-facing errors", () => {
expect(
formatUserFacingErrorMessage(
new Error(
"[CONVEX A] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Bad publish payload",
),
"Request failed",
),
).toBe("Bad publish payload");
expect(
formatUserFacingErrorMessage(
new Error("Uncaught ConvexError: Uncaught ConvexError: Publisher not found"),
"Request failed",
),
).toBe("Publisher not found");
});
it("resolves latest tags without reading version documents", async () => {
const ctx = makeCtx();
const versionId = "skillVersions:latest" as Id<"skillVersions">;
const result = await resolveVersionTagsBatch(ctx, [{ latest: versionId }], {} as never, [
{ _id: versionId, version: "2.0.0" },
]);
expect(result).toEqual([{ latest: "2.0.0" }]);
expect(ctx.runQuery).not.toHaveBeenCalled();
});
it("only fetches tag versions that cannot be resolved from latest", async () => {
const ctx = makeCtx();
const latestId = "skillVersions:latest" as Id<"skillVersions">;
const stableId = "skillVersions:stable" as Id<"skillVersions">;
ctx.runQuery.mockResolvedValueOnce([{ _id: stableId, version: "1.5.0" }]);
const result = await resolveVersionTagsBatch(
ctx,
[{ latest: latestId, stable: stableId }],
{} as never,
[{ _id: latestId, version: "2.0.0" }],
);
expect(ctx.runQuery).toHaveBeenCalledWith({}, { versionIds: [stableId] });
expect(result).toEqual([{ latest: "2.0.0", stable: "1.5.0" }]);
});
});
+12
View File
@@ -1,17 +1,21 @@
import { httpAction } from "./functions";
import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1";
import {
listBundlePluginsV1Handler,
listCodePluginsV1Handler,
listPackagesV1Handler,
listPluginsV1Handler,
mintPublishTokenV1Handler,
npmMirrorGetHandler,
packagesDeleteRouterV1Handler,
packagesGetRouterV1Handler,
packagesPostRouterV1Handler,
pluginsGetRouterV1Handler,
publishPackageV1Handler,
} from "./httpApiV1/packagesV1";
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
import {
exportSkillsV1Handler,
listSkillsV1Handler,
publishSkillV1Handler,
resolveSkillVersionV1Handler,
@@ -40,8 +44,11 @@ export const packagesDeleteRouterV1Http = httpAction(packagesDeleteRouterV1Handl
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
export const mintPublishTokenV1Http = httpAction(mintPublishTokenV1Handler);
export const npmMirrorGetHttp = httpAction(npmMirrorGetHandler);
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
export const createPublisherV1Http = httpAction(createPublisherV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
@@ -50,6 +57,7 @@ export const skillsGetRouterV1Http = httpAction(skillsGetRouterV1Handler);
export const publishSkillV1Http = httpAction(publishSkillV1Handler);
export const skillsPostRouterV1Http = httpAction(skillsPostRouterV1Handler);
export const skillsDeleteRouterV1Http = httpAction(skillsDeleteRouterV1Handler);
export const exportSkillsV1Http = httpAction(exportSkillsV1Handler);
export const listSoulsV1Http = httpAction(listSoulsV1Handler);
export const soulsGetRouterV1Http = httpAction(soulsGetRouterV1Handler);
@@ -74,8 +82,11 @@ export const __handlers = {
pluginsGetRouterV1Handler,
publishPackageV1Handler,
mintPublishTokenV1Handler,
npmMirrorGetHandler,
listCodePluginsV1Handler,
listBundlePluginsV1Handler,
verifyDocsSessionV1Handler,
createPublisherV1Handler,
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
@@ -83,6 +94,7 @@ export const __handlers = {
publishSkillV1Handler,
skillsPostRouterV1Handler,
skillsDeleteRouterV1Handler,
exportSkillsV1Handler,
listSoulsV1Handler,
soulsGetRouterV1Handler,
publishSoulV1Handler,
+34
View File
@@ -0,0 +1,34 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { applyRateLimit } from "../lib/httpRateLimit";
import { json, text } from "./shared";
export async function verifyDocsSessionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
try {
const userId = await getOptionalActiveAuthUserIdFromAction(ctx);
if (!userId) return text("Unauthorized", 401, rate.headers);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) {
return text("Unauthorized", 401, rate.headers);
}
return json(
{
provider: "github",
user: {
id: user._id,
handle: user.handle ?? user.name ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
},
},
200,
rate.headers,
);
} catch {
return text("Unauthorized", 401, rate.headers);
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { applyRateLimit } from "../lib/httpRateLimit";
import { json, parseJsonPayload, requireApiTokenUserOrResponse, text } from "./shared";
const publisherInternalRefs = internal as unknown as {
publishers: {
createOrgPublisherForUserInternal: unknown;
};
};
export async function createPublisherV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const payloadResult = await parseJsonPayload(request, rate.headers);
if (!payloadResult.ok) return payloadResult.response;
const payload = payloadResult.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return text("JSON body must be an object", 400, rate.headers);
}
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!authResult.ok) return authResult.response;
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
if (!handle) return text("Missing handle", 400, rate.headers);
const displayName =
typeof payload.displayName === "string" ? payload.displayName.trim() || undefined : undefined;
try {
const result = await ctx.runMutation(
publisherInternalRefs.publishers.createOrgPublisherForUserInternal as never,
{
actorUserId: authResult.userId,
handle,
...(displayName ? { displayName } : {}),
} as never,
);
return json(result, 201, rate.headers);
} catch (error) {
const message = error instanceof Error ? error.message : "Publisher create failed";
if (/already exists|already used/i.test(message)) return text(message, 409, rate.headers);
if (/unauthorized/i.test(message)) return text("Unauthorized", 401, rate.headers);
return text(message, 400, rate.headers);
}
}
+103 -34
View File
@@ -35,9 +35,7 @@ export function safeTextFileResponse(params: {
const headers = mergeHeaders(
params.headers,
{
"Content-Type": contentType
? `${contentType}; charset=utf-8`
: "text/plain; charset=utf-8",
"Content-Type": contentType ? `${contentType}; charset=utf-8` : "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=60",
ETag: params.sha256,
"X-Content-SHA256": params.sha256,
@@ -98,8 +96,8 @@ export async function requireApiTokenUserOrResponse(
try {
const auth = await requireApiTokenUser(ctx, request);
return { ok: true as const, userId: auth.userId, user: auth.user as Doc<"users"> };
} catch {
return { ok: false as const, response: text("Unauthorized", 401, headers) };
} catch (error) {
return { ok: false as const, response: text(formatAuthFailure(error), 401, headers) };
}
}
@@ -110,8 +108,8 @@ export async function requirePackagePublishAuthOrResponse(
) {
try {
return { ok: true as const, auth: await requirePackagePublishAuth(ctx, request) };
} catch {
return { ok: false as const, response: text("Unauthorized", 401, headers) };
} catch (error) {
return { ok: false as const, response: text(formatAuthFailure(error), 401, headers) };
}
}
@@ -120,7 +118,7 @@ export function requireAdminOrResponse(user: Doc<"users">, headers: HeadersInit)
assertAdmin(user);
return { ok: true as const };
} catch {
return { ok: false as const, response: text("Forbidden", 403, headers) };
return { ok: false as const, response: text("Admin role required.", 403, headers) };
}
}
@@ -149,17 +147,38 @@ export function toOptionalNumber(value: string | null) {
export async function resolveSoulTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<"soulVersions">>>,
latestVersions?: Array<LatestVersionTag<"soulVersions">>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.souls.getVersionsByIdsInternal);
return resolveVersionTagsBatch(
ctx,
tagsList,
internal.souls.getVersionsByIdsInternal,
latestVersions,
);
}
export async function resolveTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<"skillVersions">>>,
latestVersions?: Array<LatestVersionTag<"skillVersions">>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.skills.getVersionsByIdsInternal);
return resolveVersionTagsBatch(
ctx,
tagsList,
internal.skills.getVersionsByIdsInternal,
latestVersions,
);
}
type LatestVersionTag<TTable extends "skillVersions" | "soulVersions"> =
| {
_id: Id<TTable>;
version?: string;
softDeletedAt?: unknown;
}
| null
| undefined;
/**
* Batch resolve version tags to version strings.
* Collects all version IDs, fetches them in a single query, then maps back.
@@ -172,13 +191,25 @@ export async function resolveVersionTagsBatch<TTable extends "skillVersions" | "
ctx: ActionCtx,
tagsList: Array<Record<string, Id<TTable>>>,
getVersionsByIdsQuery: unknown,
latestVersions?: Array<LatestVersionTag<TTable>>,
): Promise<Array<Record<string, string>>> {
const allVersionIds = new Set<Id<TTable>>();
for (const tags of tagsList) {
for (const versionId of Object.values(tags)) allVersionIds.add(versionId);
}
const preResolvedTags = tagsList.map((tags, idx) => {
const resolved: Record<string, string> = {};
const latest = latestVersions?.[idx];
for (const [tag, versionId] of Object.entries(tags)) {
if (latest?._id === versionId && latest.version && !latest.softDeletedAt) {
resolved[tag] = latest.version;
} else {
allVersionIds.add(versionId);
}
}
return resolved;
});
if (allVersionIds.size === 0) return tagsList.map(() => ({}));
if (allVersionIds.size === 0) {
return preResolvedTags;
}
const versionIds = [...allVersionIds].sort() as Array<Id<TTable>>;
const versions =
@@ -193,9 +224,10 @@ export async function resolveVersionTagsBatch<TTable extends "skillVersions" | "
if (!v?.softDeletedAt) versionMap.set(v._id, v.version);
}
return tagsList.map((tags) => {
const resolved: Record<string, string> = {};
return tagsList.map((tags, idx) => {
const resolved = { ...preResolvedTags[idx] };
for (const [tag, versionId] of Object.entries(tags)) {
if (resolved[tag]) continue;
const version = versionMap.get(versionId);
if (version) resolved[tag] = version;
}
@@ -236,22 +268,7 @@ function toFileLike(entry: FormDataEntryValue): FileLikeEntry | null {
export async function parseMultipartPublish(
ctx: ActionCtx,
request: Request,
): Promise<{
slug: string;
displayName: string;
version: string;
changelog: string;
acceptLicenseTerms?: boolean;
tags?: string[];
forkOf?: { slug: string; version?: string };
files: Array<{
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
contentType?: string;
}>;
}> {
): Promise<ReturnType<typeof parsePublishBody>> {
const form = await request.formData();
const payloadRaw = form.get("payload");
if (!payloadRaw || typeof payloadRaw !== "string") {
@@ -293,8 +310,11 @@ export async function parseMultipartPublish(
const body = {
slug: payload.slug,
displayName: payload.displayName,
...(typeof payload.ownerHandle === "string" ? { ownerHandle: payload.ownerHandle } : {}),
...(typeof payload.migrateOwner === "boolean" ? { migrateOwner: payload.migrateOwner } : {}),
version: payload.version,
changelog: typeof payload.changelog === "string" ? payload.changelog : "",
...(typeof payload.clawScanNote === "string" ? { clawScanNote: payload.clawScanNote } : {}),
...(hasAcceptLicenseTerms ? { acceptLicenseTerms: payload.acceptLicenseTerms } : {}),
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
...(payload.source ? { source: payload.source } : {}),
@@ -312,6 +332,8 @@ export function parsePublishBody(body: unknown) {
return {
slug: parsed.slug,
displayName: parsed.displayName,
ownerHandle: parsed.ownerHandle?.trim().replace(/^@+/, "") || undefined,
migrateOwner: parsed.migrateOwner === true ? true : undefined,
version: parsed.version,
changelog: parsed.changelog,
acceptLicenseTerms: parsed.acceptLicenseTerms,
@@ -338,11 +360,58 @@ export function softDeleteErrorToResponse(
const message = error instanceof Error ? error.message : `${entity} delete failed`;
const lower = message.toLowerCase();
if (lower.includes("unauthorized")) return text("Unauthorized", 401, headers);
if (lower.includes("forbidden")) return text("Forbidden", 403, headers);
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);
// Unknown: server-side failure. Keep body generic.
return text("Internal Server Error", 500, headers);
}
export function cleanUserFacingErrorMessage(message: string) {
let cleaned = message
.replace(/\[CONVEX[^\]]*\]\s*/g, "")
.replace(/\[Request ID:[^\]]*\]\s*/g, "")
.replace(/^Server Error Called by client\s*/i, "")
.trim();
for (let i = 0; i < 3; i += 1) {
const next = cleaned
.replace(/^Error:\s*/i, "")
.replace(/^(?:Uncaught\s+)?ConvexError:\s*/i, "")
.trim();
if (next === cleaned) break;
cleaned = next;
}
return cleaned;
}
export function formatUserFacingErrorMessage(error: unknown, fallback: string) {
const message = error instanceof Error ? error.message : fallback;
return cleanUserFacingErrorMessage(message) || fallback;
}
function formatAuthFailure(error: unknown) {
const message = formatUserFacingErrorMessage(error, "");
if (!message || /^unauthorized$/i.test(message)) return "Unauthorized";
return message || "Unauthorized";
}
// Shared formatter for authz responses.
// - Returns the clean fallback ("Unauthorized" | "Forbidden") when the error
// carries no additional context beyond the fallback itself (so existing
// callers that throw `new Error("Forbidden")` keep their body unchanged).
// - Otherwise returns the full message (minus the `ConvexError:` prefix) so
// CLI/API clients can surface actionable reasons such as
// "Forbidden: This skill was hidden by moderation ...".
export function formatAuthzMessage(error: unknown, fallback: "Unauthorized" | "Forbidden") {
const message = formatUserFacingErrorMessage(error, "");
if (!message) return fallback;
const stripped = cleanUserFacingErrorMessage(message);
if (!stripped || stripped.toLowerCase() === fallback.toLowerCase()) return fallback;
return stripped;
}
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -2,7 +2,7 @@ import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
import { applyRateLimit } from "../lib/httpRateLimit";
import { publishSoulVersionForUser } from "../souls";
import {
MAX_RAW_FILE_BYTES,
@@ -10,6 +10,7 @@ import {
json,
parseMultipartPublish,
parsePublishBody,
requireApiTokenUserOrResponse,
resolveSoulTagsBatch,
safeTextFileResponse,
softDeleteErrorToResponse,
@@ -283,25 +284,21 @@ export async function publishSoulV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
try {
if (!parseBearerToken(request)) return text("Unauthorized", 401, rate.headers);
} catch {
return text("Unauthorized", 401, rate.headers);
}
const { userId } = await requireApiTokenUser(ctx, request);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const contentType = request.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
const body = await request.json();
const payload = parsePublishBody(body);
const result = await publishSoulVersionForUser(ctx, userId, payload);
const result = await publishSoulVersionForUser(ctx, auth.userId, payload);
return json({ ok: true, ...result }, 200, rate.headers);
}
if (contentType.includes("multipart/form-data")) {
const payload = await parseMultipartPublish(ctx, request);
const result = await publishSoulVersionForUser(ctx, userId, payload);
const result = await publishSoulVersionForUser(ctx, auth.userId, payload);
return json({ ok: true, ...result }, 200, rate.headers);
}
} catch (error) {
+16 -9
View File
@@ -1,8 +1,7 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit } from "../lib/httpRateLimit";
import { getPathSegments, json, text } from "./shared";
import { getPathSegments, json, requireApiTokenUserOrResponse, text } from "./shared";
export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
@@ -12,13 +11,15 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
if (segments.length !== 1) return text("Not found", 404, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const { userId } = await requireApiTokenUser(ctx, request);
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug });
if (!skill) return text("Skill not found", 404, rate.headers);
const result = await ctx.runMutation(internal.stars.addStarInternal, {
userId,
userId: auth.userId,
skillId: skill._id,
});
return json(result, 200, rate.headers);
@@ -26,7 +27,7 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
if (e instanceof Error && e.message === "Skill not found") {
return text("Skill not found", 404, rate.headers);
}
return text("Unauthorized", 401, rate.headers);
return text(errorMessage(e, "Unable to star skill."), 400, rate.headers);
}
}
@@ -38,17 +39,23 @@ export async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Reques
if (segments.length !== 1) return text("Not found", 404, rate.headers);
const slug = segments[0]?.trim().toLowerCase() ?? "";
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const { userId } = await requireApiTokenUser(ctx, request);
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug });
if (!skill) return text("Skill not found", 404, rate.headers);
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
userId,
userId: auth.userId,
skillId: skill._id,
});
return json(result, 200, rate.headers);
} catch {
return text("Unauthorized", 401, rate.headers);
} catch (error) {
return text(errorMessage(error, "Unable to unstar skill."), 400, rate.headers);
}
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message.trim() ? error.message.trim() : fallback;
}
+260 -11
View File
@@ -1,7 +1,6 @@
import { api, internal } from "../_generated/api";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit } from "../lib/httpRateLimit";
import {
getPathSegments,
@@ -13,6 +12,30 @@ import {
toOptionalNumber,
} from "./shared";
const usersV1InternalRefs = internal as unknown as {
users: {
getByHandleInternal: unknown;
remediateAutobansInternal: unknown;
reclassifyBanInternal: unknown;
};
};
async function runUsersV1QueryRef<T>(
ctx: Pick<ActionCtx, "runQuery">,
ref: unknown,
args: unknown,
): Promise<T> {
return (await ctx.runQuery(ref as never, args as never)) as T;
}
async function runUsersV1MutationRef<T>(
ctx: Pick<ActionCtx, "runMutation">,
ref: unknown,
args: unknown,
): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
@@ -27,7 +50,10 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
action !== "unban" &&
action !== "role" &&
action !== "restore" &&
action !== "remediate-autobans" &&
action !== "reclassify-ban" &&
action !== "reclaim" &&
action !== "reserve" &&
action !== "publisher"
) {
return text("Not found", 404, rate.headers);
@@ -49,12 +75,30 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers);
}
if (action === "remediate-autobans") {
const admin = requireAdminOrResponse(actorUser, rate.headers);
if (!admin.ok) return admin.response;
return handleAdminRemediateAutobans(ctx, payload, actorUserId, rate.headers);
}
if (action === "reclassify-ban") {
const admin = requireAdminOrResponse(actorUser, rate.headers);
if (!admin.ok) return admin.response;
return handleAdminReclassifyBan(ctx, payload, actorUserId, rate.headers);
}
if (action === "reclaim") {
const admin = requireAdminOrResponse(actorUser, rate.headers);
if (!admin.ok) return admin.response;
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers);
}
if (action === "reserve") {
const admin = requireAdminOrResponse(actorUser, rate.headers);
if (!admin.ok) return admin.response;
return handleAdminReserve(ctx, payload, actorUserId, rate.headers);
}
if (action === "publisher") {
const admin = requireAdminOrResponse(actorUser, rate.headers);
if (!admin.ok) return admin.response;
@@ -157,6 +201,117 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
}
}
async function handleAdminReclassifyBan(
ctx: ActionCtx,
payload: unknown,
actorUserId: Id<"users">,
headers: HeadersInit,
) {
const body = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const handle = typeof body.handle === "string" ? body.handle.trim() : "";
const userId = typeof body.userId === "string" ? body.userId.trim() : "";
const reason = typeof body.reason === "string" ? body.reason.trim() : "";
const dryRun = body.dryRun !== false;
if (handle && userId) return text("Pass handle or userId, not both", 400, headers);
if (!handle && !userId) return text("Missing userId or 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);
let targetUserId: Id<"users"> | null = userId ? (userId as Id<"users">) : null;
if (!targetUserId) {
const user = await runUsersV1QueryRef<{ _id?: Id<"users"> } | null>(
ctx,
usersV1InternalRefs.users.getByHandleInternal,
{ handle: handle.toLowerCase() },
);
if (!user?._id) return text("User not found", 404, headers);
targetUserId = user._id;
}
try {
const result = await runUsersV1MutationRef(
ctx,
usersV1InternalRefs.users.reclassifyBanInternal,
{
actorUserId,
targetUserId,
reason,
dryRun,
},
);
return json(result, 200, headers);
} catch (error) {
const message = error instanceof Error ? error.message : "Ban reclassification failed";
if (message.toLowerCase().includes("forbidden")) {
return text("Forbidden", 403, headers);
}
if (message.toLowerCase().includes("not found")) {
return text(message, 404, headers);
}
return text(message, 400, headers);
}
}
async function handleAdminRemediateAutobans(
ctx: ActionCtx,
payload: unknown,
actorUserId: Id<"users">,
headers: HeadersInit,
) {
const body = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
const handle = typeof body.handle === "string" ? body.handle.trim() : "";
const userId = typeof body.userId === "string" ? body.userId.trim() : "";
const reason = typeof body.reason === "string" ? body.reason.trim() : "";
const since = typeof body.since === "string" ? body.since.trim() : "";
const cursor = typeof body.cursor === "string" ? body.cursor.trim() : "";
const dryRun = body.dryRun !== false;
const limit =
typeof body.limit === "number"
? body.limit
: typeof body.limit === "string" || body.limit === null
? toOptionalNumber(body.limit)
: undefined;
if (handle && userId) return text("Pass handle or userId, not both", 400, headers);
if (reason && reason.length > 500) {
return text("Reason too long (max 500 chars)", 400, headers);
}
if (since && Number.isNaN(Date.parse(since))) {
return text("Invalid since date", 400, headers);
}
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
return text("Invalid limit", 400, headers);
}
try {
const result = await runUsersV1MutationRef(
ctx,
usersV1InternalRefs.users.remediateAutobansInternal,
{
actorUserId,
...(userId ? { targetUserId: userId as Id<"users"> } : {}),
...(handle ? { handle } : {}),
dryRun,
...(reason ? { reason } : {}),
...(since ? { since } : {}),
...(cursor ? { cursor } : {}),
...(limit !== undefined ? { limit } : {}),
},
);
return json(result, 200, headers);
} catch (error) {
const message = error instanceof Error ? error.message : "Autoban remediation failed";
if (message.toLowerCase().includes("forbidden")) {
return text("Forbidden", 403, headers);
}
if (message.toLowerCase().includes("not found")) {
return text(message, 404, headers);
}
return text(message, 400, headers);
}
}
/**
* POST /api/v1/users/restore
* Admin-only: restore skills from GitHub backup for a user.
@@ -252,6 +407,91 @@ async function handleAdminReclaim(
return json({ ok: true, results, succeeded, failed }, 200, headers);
}
/**
* POST /api/v1/users/reserve
* Admin-only: reserve root slugs and package names for a rightful owner.
* Package reservations are private placeholder packages with no releases.
* Body: { handle: string, slugs?: string[], packageNames?: string[], reason?: string }
*/
async function handleAdminReserve(
ctx: ActionCtx,
payload: Record<string, unknown>,
actorUserId: Id<"users">,
headers: HeadersInit,
) {
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
if (!handle) return text("Missing handle", 400, headers);
const slugs = Array.isArray(payload.slugs)
? payload.slugs.filter((s): s is string => typeof s === "string")
: [];
const packageNames = Array.isArray(payload.packageNames)
? payload.packageNames.filter((s): s is string => typeof s === "string")
: [];
const total = slugs.length + packageNames.length;
if (total === 0) return text("Missing slugs or packageNames array", 400, headers);
if (total > 200) return text("Too many reservations (max 200)", 400, headers);
const reason = typeof payload.reason === "string" ? payload.reason.trim() : undefined;
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle });
if (!targetUser?._id) return text("User not found", 404, headers);
const targetPublisher = (await ctx.runQuery(internal.publishers.getByHandleInternal, {
handle,
})) as { _id?: Id<"publishers">; deletedAt?: number; deactivatedAt?: number } | null;
const ownerPublisherId =
targetPublisher?._id && !targetPublisher.deletedAt && !targetPublisher.deactivatedAt
? targetPublisher._id
: undefined;
const results: Array<{
kind: "slug" | "package";
name: string;
ok: boolean;
action?: string;
error?: string;
}> = [];
for (const slug of slugs) {
const name = slug.trim().toLowerCase();
try {
const result = (await ctx.runMutation(internal.skills.reserveSlugInternal, {
actorUserId,
slug: name,
rightfulOwnerUserId: targetUser._id,
reason,
})) as { action?: string };
results.push({ kind: "slug", name, ok: true, action: result.action });
} catch (error) {
const message = error instanceof Error ? error.message : "Slug reservation failed";
results.push({ kind: "slug", name, ok: false, error: message });
}
}
for (const packageName of packageNames) {
const name = packageName.trim();
try {
const result = (await ctx.runMutation(internal.packages.reservePackageNameInternal, {
actorUserId,
ownerUserId: targetUser._id,
ownerPublisherId,
name,
reason,
})) as { action?: string };
results.push({ kind: "package", name, ok: true, action: result.action });
} catch (error) {
const message = error instanceof Error ? error.message : "Package reservation failed";
results.push({ kind: "package", name, ok: false, error: message });
}
}
const succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
return json({ ok: true, results, succeeded, failed }, 200, headers);
}
async function handleAdminEnsurePublisher(
ctx: ActionCtx,
payload: Record<string, unknown>,
@@ -263,14 +503,27 @@ async function handleAdminEnsurePublisher(
const displayName =
typeof payload.displayName === "string" ? payload.displayName.trim() : undefined;
const trusted = typeof payload.trusted === "boolean" ? payload.trusted : true;
const trusted = typeof payload.trusted === "boolean" ? payload.trusted : undefined;
const memberHandle =
typeof payload.memberHandle === "string" ? payload.memberHandle.trim().toLowerCase() : "";
const memberRoleRaw =
typeof payload.memberRole === "string" ? payload.memberRole.trim().toLowerCase() : "";
const memberRole =
memberRoleRaw === "owner" || memberRoleRaw === "admin" || memberRoleRaw === "publisher"
? memberRoleRaw
: undefined;
if (memberRoleRaw && !memberRole) {
return text("memberRole must be owner, admin, or publisher", 400, headers);
}
try {
const result = await ctx.runMutation(internal.publishers.ensureOrgPublisherHandleInternal, {
actorUserId,
handle,
displayName,
trusted,
...(typeof trusted === "boolean" ? { trusted } : {}),
...(memberHandle ? { memberHandle } : {}),
...(memberRole ? { memberRole } : {}),
});
return json(result, 200, headers);
} catch (error) {
@@ -293,13 +546,9 @@ export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
const limitRaw = toOptionalNumber(url.searchParams.get("limit"));
const query = url.searchParams.get("q") ?? url.searchParams.get("query") ?? "";
let actorUserId: Id<"users">;
try {
const auth = await requireApiTokenUser(ctx, request);
actorUserId = auth.userId;
} catch {
return text("Unauthorized", 401, rate.headers);
}
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const actorUserId = auth.userId;
const limit = Math.min(Math.max(limitRaw ?? 20, 1), 200);
try {
@@ -315,7 +564,7 @@ export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
return text("Forbidden", 403, rate.headers);
}
if (message.toLowerCase().includes("unauthorized")) {
return text("Unauthorized", 401, rate.headers);
return text(message, 401, rate.headers);
}
return text(message, 400, rate.headers);
}
+15 -18
View File
@@ -1,27 +1,24 @@
import type { ActionCtx } from "../_generated/server";
import { requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit } from "../lib/httpRateLimit";
import { json, text } from "./shared";
import { json, requireApiTokenUserOrResponse } from "./shared";
export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
try {
const { user } = await requireApiTokenUser(ctx, request);
return json(
{
user: {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const { user } = auth;
return json(
{
user: {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
200,
rate.headers,
);
} catch {
return text("Unauthorized", 401, rate.headers);
}
},
200,
rate.headers,
);
}
+44 -3
View File
@@ -1,12 +1,20 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { rebuildTrendingLeaderboardInternal } from "./leaderboards";
import {
rebuildTrendingLeaderboardAction,
rebuildTrendingLeaderboardInternal,
} from "./leaderboards";
const handler = (
const mutationHandler = (
rebuildTrendingLeaderboardInternal as unknown as {
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
}
)._handler;
const actionHandler = (
rebuildTrendingLeaderboardAction as unknown as {
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
}
)._handler;
describe("leaderboards.rebuildTrendingLeaderboardInternal", () => {
it("schedules the action-based rebuild instead of reading daily stats inline", async () => {
@@ -30,11 +38,44 @@ describe("leaderboards.rebuildTrendingLeaderboardInternal", () => {
},
} as never;
const result = await handler(ctx, { limit: 500 });
const result = await mutationHandler(ctx, { limit: 500 });
expect(runAfter).toHaveBeenCalledTimes(1);
expect(runAfter.mock.calls[0]?.[0]).toBe(0);
expect(runAfter.mock.calls[0]?.[2]).toEqual({ limit: 200 });
expect(result).toEqual({ ok: true, count: 0, scheduled: true });
});
it("rebuild action pages daily stats instead of collecting a whole day", async () => {
const runQuery = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if (Array.isArray(args.entries)) return args.entries;
return {
rows: [
{
skillId: "skills:one",
installs: 1,
downloads: 2,
},
],
isDone: true,
continueCursor: "",
};
});
const runMutation = vi.fn(async () => ({ ok: true }));
const result = await actionHandler(
{
runQuery,
runMutation,
},
{ limit: 5 },
);
expect(result).toEqual({ ok: true, count: 1 });
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ cursor: null, limit: 1000 }),
);
expect(runMutation).toHaveBeenCalledTimes(2);
});
});
+60 -12
View File
@@ -1,10 +1,10 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction, internalMutation, internalQuery } from "./functions";
import {
buildTrendingEntriesFromDailyRows,
compareTrendingEntries,
getTrendingRange,
queryDailyStats,
takeTopNonSuspiciousTrendingEntries,
takeTopTrendingEntries,
TRENDING_LEADERBOARD_KIND,
@@ -13,17 +13,37 @@ import {
const MAX_TRENDING_LIMIT = 200;
const KEEP_LEADERBOARD_ENTRIES = 3;
const DAILY_STATS_PAGE_SIZE = 1_000;
// ---------------------------------------------------------------------------
// Action → Query → Mutation pattern (avoids 32K document-read limit)
// ---------------------------------------------------------------------------
/** Reads a single day's skillDailyStats in its own query transaction. */
export const getDailyStats = internalQuery({
args: { day: v.number() },
handler: async (ctx, { day }) => {
const rows = await queryDailyStats(ctx, day);
return rows.map((r) => ({ skillId: r.skillId, installs: r.installs, downloads: r.downloads }));
/** Reads one page of a single day's skillDailyStats in its own query transaction. */
export const getDailyStatsPage = internalQuery({
args: {
day: v.number(),
cursor: v.union(v.string(), v.null()),
limit: v.optional(v.number()),
},
handler: async (ctx, { day, cursor, limit }) => {
const page = await ctx.db
.query("skillDailyStats")
.withIndex("by_day", (q) => q.eq("day", day))
.paginate({
cursor,
numItems: Math.min(limit ?? DAILY_STATS_PAGE_SIZE, DAILY_STATS_PAGE_SIZE),
});
return {
rows: page.page.map((r) => ({
skillId: r.skillId,
installs: r.installs,
downloads: r.downloads,
})),
isDone: page.isDone,
continueCursor: page.continueCursor,
};
},
});
@@ -92,10 +112,38 @@ export const rebuildTrendingLeaderboardAction = internalAction({
const now = Date.now();
const { startDay, endDay } = getTrendingRange(now);
const dayKeys = Array.from({ length: endDay - startDay + 1 }, (_, i) => startDay + i);
const perDayRows = await Promise.all(
dayKeys.map((day) => ctx.runQuery(internal.leaderboards.getDailyStats, { day })),
);
const entries = buildTrendingEntriesFromDailyRows(perDayRows);
const totals = new Map<Id<"skills">, { installs: number; downloads: number }>();
for (const day of dayKeys) {
let cursor: string | null = null;
let isDone = false;
while (!isDone) {
const page: {
rows: Array<{ skillId: Id<"skills">; installs: number; downloads: number }>;
isDone: boolean;
continueCursor: string;
} = await ctx.runQuery(internal.leaderboards.getDailyStatsPage, {
day,
cursor,
limit: DAILY_STATS_PAGE_SIZE,
});
for (const row of page.rows) {
const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 };
current.installs += row.installs;
current.downloads += row.downloads;
totals.set(row.skillId, current);
}
cursor = page.continueCursor;
isDone = page.isDone;
}
}
const entries = Array.from(totals, ([skillId, entry]) => ({
skillId,
installs: entry.installs,
downloads: entry.downloads,
score: entry.installs,
})).sort((a, b) => compareTrendingEntries(b, a));
const items = takeTopTrendingEntries(entries, limit);
const nonSuspicious = await ctx.runQuery(
internal.leaderboards.filterTopNonSuspiciousTrendingEntries,
+1 -1
View File
@@ -3,7 +3,7 @@ import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
export type Role = "admin" | "moderator" | "user";
export type Role = "admin" | "moderator" | "user" | "mirror";
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
+267
View File
@@ -0,0 +1,267 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import {
API_KEY_REQUIREMENT_MAX_OUTPUT_TOKENS,
API_KEY_REQUIREMENT_SYSTEM_PROMPT,
assembleApiKeyRequirementUserMessage,
getApiKeyRequirementModel,
parseApiKeyRequirementResponse,
toApiKeyRequiredBoolean,
} from "./apiKeyRequirementPrompt";
describe("apiKeyRequirementPrompt", () => {
describe("constants and config", () => {
it("exposes a sane output-token budget", () => {
expect(API_KEY_REQUIREMENT_MAX_OUTPUT_TOKENS).toBe(600);
});
it("system prompt fixes the JSON-only output schema", () => {
expect(API_KEY_REQUIREMENT_SYSTEM_PROMPT).toContain('"status"');
expect(API_KEY_REQUIREMENT_SYSTEM_PROMPT).toContain('"envVars"');
expect(API_KEY_REQUIREMENT_SYSTEM_PROMPT).toContain("QUOTED SOURCE MATERIAL");
});
it("model resolution prefers the dedicated env over the generic one", () => {
const before = {
dedicated: process.env.OPENAI_API_KEY_EVAL_MODEL,
generic: process.env.OPENAI_EVAL_MODEL,
};
try {
delete process.env.OPENAI_API_KEY_EVAL_MODEL;
delete process.env.OPENAI_EVAL_MODEL;
expect(getApiKeyRequirementModel()).toBe("gpt-4.1-mini");
process.env.OPENAI_EVAL_MODEL = "fallback-model";
expect(getApiKeyRequirementModel()).toBe("fallback-model");
process.env.OPENAI_API_KEY_EVAL_MODEL = "preferred-model";
expect(getApiKeyRequirementModel()).toBe("preferred-model");
} finally {
if (before.dedicated === undefined) {
delete process.env.OPENAI_API_KEY_EVAL_MODEL;
} else {
process.env.OPENAI_API_KEY_EVAL_MODEL = before.dedicated;
}
if (before.generic === undefined) {
delete process.env.OPENAI_EVAL_MODEL;
} else {
process.env.OPENAI_EVAL_MODEL = before.generic;
}
}
});
});
describe("assembleApiKeyRequirementUserMessage", () => {
it("packs frontmatter, file manifest and fenced SKILL.md", () => {
const message = assembleApiKeyRequirementUserMessage({
slug: "stripe-helper",
skillMd: "---\nname: stripe-helper\n---\n# Stripe helper\n",
requiresEnv: ["STRIPE_API_KEY"],
primaryEnv: "STRIPE_API_KEY",
envVars: [
{ name: "STRIPE_API_KEY", required: true, description: "Live secret key" },
{ name: "STRIPE_WEBHOOK_SECRET", required: false },
],
filePaths: ["SKILL.md", "scripts/charge.ts"],
});
expect(message).toContain("Skill slug: stripe-helper");
expect(message).toContain("STRIPE_API_KEY (required)");
expect(message).toContain("STRIPE_WEBHOOK_SECRET (optional)");
expect(message).toContain("Frontmatter — primaryEnv: STRIPE_API_KEY");
expect(message).toContain("- SKILL.md");
expect(message).toContain("- scripts/charge.ts");
expect(message).toContain("```markdown");
expect(message).toContain("# Stripe helper");
});
it("renders sensible placeholders when frontmatter / files are missing", () => {
const message = assembleApiKeyRequirementUserMessage({
slug: "local-only",
skillMd: "Local skill, no secrets.",
});
expect(message).toContain("Frontmatter — requires.env:\n(none)");
expect(message).toContain("Frontmatter — primaryEnv: (none)");
expect(message).toContain("Frontmatter — envVars:\n(none declared)");
expect(message).toContain("File manifest (paths only):\n(no files)");
});
it("truncates an oversize SKILL.md and marks the truncation", () => {
const huge = "x".repeat(20_000);
const message = assembleApiKeyRequirementUserMessage({
slug: "huge",
skillMd: huge,
});
expect(message).toContain("…[truncated]");
// ensure we did NOT emit the full 20k payload
expect(message.length).toBeLessThan(huge.length);
});
});
describe("parseApiKeyRequirementResponse", () => {
it("parses a clean JSON response", () => {
const parsed = parseApiKeyRequirementResponse(
JSON.stringify({
status: "required",
rationale: "Skill needs STRIPE_API_KEY to make live charges.",
envVars: ["STRIPE_API_KEY"],
}),
);
expect(parsed).toEqual({
status: "required",
rationale: "Skill needs STRIPE_API_KEY to make live charges.",
envVars: ["STRIPE_API_KEY"],
});
});
it("strips ```json fences before parsing", () => {
const parsed = parseApiKeyRequirementResponse(
"```json\n" +
JSON.stringify({
status: "not_required",
rationale: "Pure local utility.",
envVars: [],
}) +
"\n```",
);
expect(parsed).toMatchObject({
status: "not_required",
rationale: "Pure local utility.",
envVars: [],
});
});
it("returns null on invalid JSON", () => {
expect(parseApiKeyRequirementResponse("not-json")).toBeNull();
});
it("rejects responses missing required fields", () => {
expect(parseApiKeyRequirementResponse('{"status":"required"}')).toBeNull();
expect(
parseApiKeyRequirementResponse(
JSON.stringify({ rationale: "no status field", envVars: [] }),
),
).toBeNull();
expect(
parseApiKeyRequirementResponse(
JSON.stringify({ status: "required", rationale: " ", envVars: [] }),
),
).toBeNull();
});
it("rejects responses with a non-whitelisted status", () => {
expect(
parseApiKeyRequirementResponse(
JSON.stringify({
status: "definitely_yes",
rationale: "model improvised a status",
envVars: [],
}),
),
).toBeNull();
});
it("clips oversize envVars arrays and drops invalid names", () => {
const parsed = parseApiKeyRequirementResponse(
JSON.stringify({
status: "required",
rationale: "many envs",
envVars: [
"VALID_KEY_1",
"VALID_KEY_2",
"VALID_KEY_3",
"VALID_KEY_4",
"VALID_KEY_5",
"VALID_KEY_6",
"VALID_KEY_7",
"VALID_KEY_8",
"VALID_KEY_9", // beyond MAX_ENV_VAR_ITEMS=8
"lower_case_should_drop",
"1_LEADING_DIGIT",
"BAD-CHAR",
"VALID_KEY_1", // duplicate
"",
],
}),
);
expect(parsed?.envVars).toEqual([
"VALID_KEY_1",
"VALID_KEY_2",
"VALID_KEY_3",
"VALID_KEY_4",
"VALID_KEY_5",
"VALID_KEY_6",
"VALID_KEY_7",
"VALID_KEY_8",
]);
});
it("forces envVars empty when status is not_required or unknown", () => {
const notRequired = parseApiKeyRequirementResponse(
JSON.stringify({
status: "not_required",
rationale: "Local only.",
envVars: ["SOMETHING_LEAKED"],
}),
);
expect(notRequired?.envVars).toEqual([]);
const unknown = parseApiKeyRequirementResponse(
JSON.stringify({
status: "unknown",
rationale: "Cannot tell.",
envVars: ["MAYBE_KEY"],
}),
);
expect(unknown?.envVars).toEqual([]);
});
it("truncates an oversize rationale", () => {
const parsed = parseApiKeyRequirementResponse(
JSON.stringify({
status: "required",
rationale: "A".repeat(2000),
envVars: ["FOO"],
}),
);
expect(parsed?.rationale.length).toBeLessThanOrEqual(600);
expect(parsed?.rationale.endsWith("...")).toBe(true);
});
});
describe("toApiKeyRequiredBoolean", () => {
it("maps the tri-state correctly", () => {
expect(
toApiKeyRequiredBoolean({
status: "required",
rationale: "x",
envVars: ["X"],
}),
).toBe(true);
expect(
toApiKeyRequiredBoolean({
status: "not_required",
rationale: "x",
envVars: [],
}),
).toBe(false);
expect(
toApiKeyRequiredBoolean({
status: "unknown",
rationale: "x",
envVars: [],
}),
).toBeUndefined();
expect(toApiKeyRequiredBoolean(null)).toBeUndefined();
});
});
});

Some files were not shown because too many files have changed in this diff Show More