Compare commits

..
Author SHA1 Message Date
Vincent Koc cfc4ba9b6a fix(maintenance): add skill version privacy removal 2026-04-30 03:52:03 -07:00
Vincent Koc 9b27c1a1d3 fix(convex): page owner-publisher digest syncs
Fixes #1195.

Fixes #1182.
2026-04-30 03:32:43 -07:00
Vincent Koc 9e09581c05 fix(rate-limit): scope anonymous download fallback buckets 2026-04-30 03:20:29 -07:00
Vincent Koc 97c409d56b fix(github): catch suspicious skill rescan requests 2026-04-30 03:05:01 -07:00
Vincent Koc 6c93d2096e fix(security): flag disabled tls verification 2026-04-30 03:04:21 -07:00
Vincent Koc 65d02e57b0 fix(web): add frontend security headers 2026-04-30 03:04:20 -07:00
Vincent Koc ae83b2188c fix(api): require explicit license acceptance 2026-04-30 03:04:19 -07:00
Vincent Koc d97942b996 fix(security): lock down virustotal result lookup 2026-04-30 03:04:18 -07:00
Vincent Koc a3125daf78 fix(github): add third-party skill closeout label 2026-04-30 02:54:10 -07:00
Val Alexander 23eec67163 fix: make detail install panels full width
Make skill and plugin detail hero action panels span the full content width, moving scans/install above long-form detail content.\n\nVerified with local focused tests, lint, targeted formatting, diff check, build, and green PR CI build.
2026-04-30 04:06:52 -05:00
Patrick Erichsen c3c885ec10 Merge pull request #1940 from openclaw/pe/clawscan
chore: remove clawscan eval corpora
2026-04-30 01:42:24 -07:00
Patrick Erichsen 04492fe196 feat: add prompt evals against 2026-04-30 01:12:04 -07:00
Vincent Koc 292f15dbae fix(convex): preserve public skill type narrowing 2026-04-30 01:05:08 -07:00
Vincent Koc 6bf8d4b7b7 fix(packages): count package archive downloads 2026-04-30 01:00:32 -07:00
Vincent Koc b60514b3fe fix(github): run rescan guidance as app 2026-04-30 00:56:10 -07:00
Vincent Koc 45b9c0e51d fix(cli): apply source path before GitHub package fetch 2026-04-30 00:55:26 -07:00
Vincent Koc e3cf29a2bc fix(security): flag remote recipe execution 2026-04-30 00:52:53 -07:00
Vincent Koc 3deff6efd1 chore(github): soften rescan guidance label 2026-04-30 00:51:06 -07:00
Vincent Koc c4950b8034 fix(security): flag provider secrets and rclone paths 2026-04-30 00:50:15 -07:00
Vincent Koc b043065ee5 fix(skills): hide nonpublic duplicate references 2026-04-30 00:49:01 -07:00
Patrick Erichsen 94d358e25b chore: move security eval pipelines out of clawhub 2026-04-30 00:48:14 -07:00
Vincent Koc b8f04b5bc4 fix(security): flag env cgnat credentials 2026-04-30 00:44:55 -07:00
Vincent Koc 248a3f25e3 fix(security): flag hardcoded operator billing 2026-04-30 00:42:19 -07:00
Vincent Koc 8fb4d01e65 fix(github): classify issue auto-responses 2026-04-30 00:41:57 -07:00
Vincent Koc 26744ba4ef fix(security): flag autonomous credential egress 2026-04-30 00:39:42 -07:00
Vincent Koc f1481c4d4e Reapply "feat(security): merge clawscan ASI analysis"
This reverts commit fa9ab8d620.
2026-04-30 00:35:47 -07:00
Vincent Koc ec2308c96d fix(security): flag Python credential posts 2026-04-30 00:34:48 -07:00
Vincent Koc beb5c27d9e fix(security): flag plaintext cgnat endpoints 2026-04-30 00:32:12 -07:00
Vincent Koc 18ae25b4c2 fix(security): flag unsafe subprocess file writes 2026-04-30 00:30:50 -07:00
Vincent Koc 1208e86b5f fix(security): flag unsafe browser file renders 2026-04-30 00:29:27 -07:00
Vincent Koc cd34538f16 fix(readme): scope relative skill links 2026-04-30 00:29:11 -07:00
Vincent Koc 85db1c60ad fix(security): flag shell file upload exfiltration 2026-04-30 00:27:43 -07:00
Vincent Koc 8aa7a58a40 fix(security): detect dynamic module execution 2026-04-30 00:25:38 -07:00
Vincent Koc 67739a4a9f fix(ui): improve runtime requirement contrast 2026-04-30 00:24:37 -07:00
Vincent Koc fc74a2f6cd fix(security): flag secret argv exposure 2026-04-30 00:23:53 -07:00
Deepak JainandVincent Koc 52078abd85 docs: clarify optional skill environment variables (#1859)
* Document optional skill env vars

Fixes #1617

* fix: honor nested optional env declarations

* chore: format skill env docs

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-04-30 00:22:17 -07:00
Vincent Koc 933fb94bcf fix(security): flag browser credential automation 2026-04-30 00:20:10 -07:00
Vincent Koc 0db5ef6224 fix(search): stabilize relevance recall window 2026-04-30 00:19:06 -07:00
Vincent Koc 43d50b8947 fix(security): delete GitHub mirror on skill hide 2026-04-30 00:11:14 -07:00
Vincent Koc 3fea99b8a6 docs(search): explain discoverability ranking 2026-04-30 00:08:06 -07:00
Vincent Koc b4cfe33659 fix(security): flag platform source patch installs 2026-04-30 00:04:46 -07:00
Vincent Koc e60bff87e8 fix(cli): surface inspect moderation diagnostics 2026-04-30 00:03:51 -07:00
Vincent Koc b3c42ddba2 fix(security): detect credential exposure docs 2026-04-30 00:01:41 -07:00
Vincent Koc adbf4347e7 fix(security): scan code files for hardcoded secrets 2026-04-29 23:59:32 -07:00
Vincent Koc 6595e13a10 fix(search): use nonsuspicious digest indexes 2026-04-29 23:54:11 -07:00
Vincent Koc 2a7b0f0a6f fix(deploy): harden production smoke checks 2026-04-29 23:53:24 -07:00
Vincent Koc ed596ba24d fix(github): run Barnacle with app token 2026-04-29 23:53:00 -07:00
Vincent Koc 2d054fe9ed Merge branch 'main' of https://github.com/openclaw/clawhub
* 'main' of https://github.com/openclaw/clawhub:
  chore(security-dataset): remove eval runner
2026-04-29 23:50:15 -07:00
Vincent Koc fa9ab8d620 Revert "feat(security): merge clawscan ASI analysis"
This reverts commit 79eddc0223, reversing
changes made to 33334c5afa.
2026-04-29 23:49:30 -07:00
Vincent Koc 7f220c2108 chore(security-dataset): remove eval runner 2026-04-29 23:48:59 -07:00
Vincent Koc 9b5c9541f8 fix(search): add soul lexical fallback 2026-04-29 23:48:13 -07:00
Vincent Koc e324fcaae2 feat(api): support created-time skill listing 2026-04-29 23:46:57 -07:00
Patrick Erichsen 57be656406 Merge remote-tracking branch 'origin/main' into pe/clawscan 2026-04-29 23:45:10 -07:00
Vincent Koc 8cab60d64a feat(github): add barnacle auto-response workflows 2026-04-29 23:44:46 -07:00
Vincent Koc d4d69d42be feat(cli): list manual skill directories 2026-04-29 23:43:35 -07:00
Vincent Koc 1461d0f175 feat(cli): show moderation in inspect 2026-04-29 23:41:22 -07:00
Vincent Koc 827fd92c7d fix(ui): use download icon for public stats 2026-04-29 23:40:27 -07:00
Vincent Koc cf20e10338 fix(github-backups): scan digest rows for sync 2026-04-29 23:33:25 -07:00
Vincent Koc 79eddc0223 feat(security): merge clawscan ASI analysis 2026-04-29 23:32:32 -07:00
Vincent Koc 33334c5afa fix(stats): avoid scan fallback for public skill count 2026-04-29 23:29:17 -07:00
Vincent Koc 477aae7c95 fix(schema): allow R source files 2026-04-29 23:25:47 -07:00
Vincent Koc a535da6dfb feat(security): verify dependency registries 2026-04-29 23:15:35 -07:00
Vincent Koc 50ee17ce7d style(security-dataset): format eval CLI test 2026-04-29 23:11:04 -07:00
Vincent Koc 0079d3f09a test(security-dataset): cover eval CLI outputs 2026-04-29 23:10:41 -07:00
Deepak Jain bcfe66d7d5 fix(security): narrow crypto swap detection
Fixes #1524

Taken from #1857.
2026-04-29 23:04:49 -07:00
Vincent Koc f3a1d7fc32 feat(security-dataset): expand eval scanner metrics 2026-04-29 23:02:54 -07:00
Vincent Koc 201713c9ed chore(deps): hold undici on node20-compatible line 2026-04-29 22:54:52 -07:00
Vincent Koc 2a5638e05b Revert "chore(deps): update undici to v8"
This reverts commit 8d5e7b2d4d.
2026-04-29 22:54:47 -07:00
Patrick Erichsen 82a85ad21e Merge pull request #1935 from openclaw/pe/clawhub-unban-moderation-skill
feat: add clawhub unban command
2026-04-29 22:50:28 -07:00
Vincent Koc f3c060c360 fix(security-dataset): adapt oversized export batches 2026-04-29 22:49:17 -07:00
Vincent Koc 8d5e7b2d4d chore(deps): update undici to v8 2026-04-29 22:48:47 -07:00
Vincent Koc 2325c21108 fix(security-dataset): include export entry names in parser errors 2026-04-29 22:43:51 -07:00
Vincent Koc ad53229985 chore(repo): normalize workflow hygiene 2026-04-29 22:41:29 -07:00
Patrick Erichsen 886a38cb8b feat: add clawhub unban command 2026-04-29 22:40:46 -07:00
Vincent Koc 1a9d80a43d merge: sync testbox setup with latest main
* origin/main:
  fix(security-dataset): compress batched export output
2026-04-29 22:33:39 -07:00
Vincent Koc 0a9f969775 merge: sync testbox setup with main
* origin/main:
  fix(security-dataset): align export batch types
  fix(security-dataset): keep batch output smaller
  test(security-dataset): use node environment for export parser
  fix(security-dataset): batch export pages server-side

# Conflicts:
#	scripts/security-dataset/convexExport.test.ts
2026-04-29 22:33:24 -07:00
Vincent Koc 9917881331 fix(security-dataset): compress batched export output 2026-04-29 22:33:09 -07:00
Vincent Koc eec9702fa3 fix(security-dataset): align export batch types 2026-04-29 22:30:14 -07:00
Vincent Koc 4a09eafe42 fix(security-dataset): keep batch output smaller 2026-04-29 22:27:58 -07:00
Vincent Koc 076b938724 fix(test): isolate Convex export zip fixture 2026-04-29 22:27:02 -07:00
Vincent Koc 33b921af29 test(security-dataset): use node environment for export parser 2026-04-29 22:24:29 -07:00
Vincent Koc 8230c1e365 fix(security-dataset): batch export pages server-side 2026-04-29 22:22:17 -07:00
Vincent Koc 3bfdbfc004 chore(testbox): add Blacksmith runner setup 2026-04-29 22:21:18 -07:00
Patrick Erichsen b48b95b0c1 Merge remote-tracking branch 'origin/main' into pe/clawscan 2026-04-29 22:16:24 -07:00
Vincent Koc 9ebf7d7bde feat(security-dataset): ingest Convex exports locally 2026-04-29 22:14:21 -07:00
Vincent Koc 55dc372ecf fix(security-dataset): type Convex output parser fallback 2026-04-29 22:08:38 -07:00
Vincent Koc 667c69a28b feat(security-dataset): add snapshot time windows 2026-04-29 22:08:38 -07:00
Vincent Koc e8b2aa558c fix(ui): honor cleared security overrides 2026-04-29 22:06:31 -07:00
Vincent Koc 415c8e182e fix(security-dataset): parse matching Convex output 2026-04-29 22:03:04 -07:00
Patrick Erichsen 52831cbc2b fix: restore legacy clawscan details 2026-04-29 21:58:10 -07:00
Vincent Koc 05c6409c96 feat(security-dataset): expose dataset lineage query 2026-04-29 21:55:58 -07:00
Vincent Koc c8585875bd fix(security-dataset): add snapshot manifest lineage 2026-04-29 21:50:30 -07:00
Vincent Koc 4a9ae92d54 fix(security): tighten destructive delete gate 2026-04-29 21:47:04 -07:00
Vincent Koc 8ac5881b4f fix(security-dataset): retry invalid export pages 2026-04-29 21:45:16 -07:00
Vincent Koc ef340c047b fix(security): reduce execfile scanner noise 2026-04-29 21:44:14 -07:00
Vincent Koc 1e6f9bd44c fix(security-dataset): enforce sharded export limits 2026-04-29 21:43:00 -07:00
Vincent Koc 0150b384a7 fix(security-dataset): stream sharded exports 2026-04-29 21:37:34 -07:00
Vincent Koc 3989cd8126 docs(security): pin publish workflow examples 2026-04-29 21:35:32 -07:00
Vincent Koc b90a43adcb fix(security): flag unsafe moderation patterns 2026-04-29 21:33:32 -07:00
Vincent Koc 94102e28f5 fix(security-dataset): parse large Convex pages 2026-04-29 21:28:21 -07:00
Vincent Koc 463e9b3fa7 fix(security-dataset): handle large export pages 2026-04-29 21:26:16 -07:00
Vincent Koc 09820d0d1c fix(security-dataset): keep exports internal 2026-04-29 21:19:23 -07:00
Patrick Erichsen faead0e25c fix: remove unreachable scanner report branches 2026-04-29 21:09:37 -07:00
Patrick Erichsen 0a64b977cb test: update clawscan report expectations 2026-04-29 21:02:50 -07:00
Patrick Erichsen 1e81388560 style: format clawscan UI files 2026-04-29 21:02:50 -07:00
Patrick Erichsen 3ab5762dca feat: ui updates 2026-04-29 21:02:50 -07:00
Patrick Erichsen e2d187b3d5 feat: clawscan seed + frontend 2026-04-29 21:02:50 -07:00
Patrick Erichsen afdac4a6a3 feat: preserve SkillTester raw corpus snapshot 2026-04-29 21:02:50 -07:00
Patrick Erichsen 5dfcd896e9 feat: add SkillTester ClawHub corpus 2026-04-29 21:02:50 -07:00
Vincent Koc d91c4804ce fix(ci): harden CodeQL light coverage 2026-04-29 21:00:02 -07:00
Vincent Koc 59e28c7831 feat: add security dataset eval runner 2026-04-29 20:59:32 -07:00
Vincent Koc a0713e1833 feat: add security dataset snapshots 2026-04-29 20:57:06 -07:00
Vincent Koc ca19f31816 chore(deps): ignore incompatible auth core bumps 2026-04-29 20:56:05 -07:00
Vincent Koc 87da4ec65a chore(deps): update GitHub Actions pins 2026-04-29 20:50:40 -07:00
Vincent Koc d9b419b21b fix(deps): pin undici to ci-compatible line 2026-04-29 20:44:04 -07:00
Vincent Koc bb94325679 chore(deps): complete major dependency updates 2026-04-29 20:38:54 -07:00
Vincent Koc 88f8ca2d29 chore(deps): update dependency drift 2026-04-29 20:34:20 -07:00
Vincent Koc 54ed3c58a1 ci: add lightweight CodeQL scans 2026-04-29 20:23:59 -07:00
Vincent Koc ea35420eed chore(deps): enable dependency update automation 2026-04-29 20:23:44 -07:00
Vincent Koc 2520da134c fix(deps): remediate vulnerable packages 2026-04-29 20:22:20 -07:00
Vincent Koc 0b2de12e04 chore: add Patrick to secure code ownership 2026-04-29 20:14:44 -07:00
Vincent Koc a8326517ad chore: add secops code ownership 2026-04-29 20:11:07 -07:00
Val Alexander 7bef2a0b65 fix: remove card link hover underlines
Remove the inherited global hover underline from full-card link surfaces while preserving normal inline link behavior.

Validated with targeted formatter/lint checks and a local browser hover pass across home category, carousel, trending, skills, plugins, and users card/list surfaces.
2026-04-29 03:40:13 -05:00
Val Alexander 22bb94cee2 fix: restore ClawHub public UI
Restore the public header, hero, featured carousel, Trending Now, category grid, footer, and UI design-contract guardrails. Remove tweakcn/custom visual overlay settings and stale density preference plumbing, while preserving reviewed search/typeahead behavior and latest review fixes.
2026-04-29 02:52:34 -05:00
Patrick Erichsen b3c42b661b Merge pull request #1882 from openclaw/pe/plugin-management-tools
Add plugin curation to management
2026-04-28 22:31:44 -07:00
Vincent Koc f72179f37d chore(ci): update package publish artifact action
Update package publish artifact upload to the Node 24-ready artifact action and align the stale skills default-sort test with current filter behavior.
2026-04-28 22:31:33 -07:00
Patrick Erichsen 1d79f78426 feat: add plugin curation to management 2026-04-28 22:30:43 -07:00
Patrick Erichsen 6209fe3fff Merge pull request #1880 from openclaw/pe/featured-plugin-curation
feat: add featured plugin curation
2026-04-28 22:19:21 -07:00
Patrick Erichsen a7d1701f5a feat: add featured plugin curation 2026-04-28 22:18:35 -07:00
Patrick Erichsen 52da4954f6 Merge pull request #1871 from openclaw/pe/skills-plugins-typeahead
[codex] Add skills/plugins search typeahead
2026-04-28 21:24:26 -07:00
Patrick Erichsen 0ee5958f7a merge: sync with origin main 2026-04-28 21:18:34 -07:00
Patrick Erichsen 5d01b99adb Merge pull request #1878 from openclaw/pe/clawhub-rescan-guidance
feat: add ClawHub rescan guidance workflow
2026-04-28 20:09:30 -07:00
Patrick Erichsen 5dc834c27e feat: add ClawHub rescan guidance workflow 2026-04-28 20:07:52 -07:00
Patrick Erichsen 82b9a69dad Merge pull request #1875 from openclaw/pe/settings-stars
fix: move stars link into settings
2026-04-28 19:50:45 -07:00
Vincent Koc 064804e2d3 fix: make package publish retries idempotent 2026-04-28 19:29:39 -07:00
Patrick Erichsen 6c0163f9f2 feat: add skills plugins search typeahead 2026-04-28 18:33:20 -07:00
Patrick Erichsen 04a862d2b2 fix: move stars link into settings 2026-04-28 18:32:09 -07:00
Patrick Erichsen a7fc4bbae2 Merge pull request #1874 from openclaw/pe/oxfmt-pr-check
ci: check oxfmt on pull requests
2026-04-28 18:22:56 -07:00
Patrick Erichsen 4701c555f3 ci: check oxfmt on pull requests 2026-04-28 18:16:32 -07:00
Patrick Erichsen c1f167721b Merge pull request #1873 from openclaw/pe/fix-skill-upload
fix: add skill upload button to header
2026-04-28 17:37:14 -07:00
Patrick Erichsen 1a94744484 Update $name.tsx 2026-04-28 17:37:02 -07:00
Patrick Erichsen 9a5cfeee85 Update SkillHeader.tsx 2026-04-28 17:29:14 -07:00
Patrick Erichsen e69b7d4501 fix: add skill upload button to header 2026-04-28 17:25:30 -07:00
Patrick Erichsen ecf09b868a Merge pull request #1872 from openclaw/pe/clawhub-cli-0.12.1
chore(release): prepare clawhub cli 0.12.1
2026-04-28 16:53:37 -07:00
Patrick Erichsen 4d16472f5b chore(release): prepare clawhub cli 0.12.0 2026-04-28 16:52:53 -07:00
Patrick Erichsen 2e5ffdc565 Merge pull request #1861 from openclaw/pe/rescan
feat: add owner rescan security surfaces
2026-04-28 16:32:26 -07:00
Peter Steinberger 4e13e729fb ci: narrow ClawSweeper dispatch cancellation 2026-04-28 11:53:07 +01:00
Peter Steinberger d17e100cca ci: harden clawsweeper dispatch workflow 2026-04-28 11:35:24 +01:00
Peter Steinberger c732b38569 ci: debounce clawsweeper dispatch metadata 2026-04-28 11:31:51 +01:00
Patrick Erichsen 81ca04662c fix: prevent mobile install copy overlap 2026-04-28 01:20:09 -07:00
Patrick Erichsen ce69ab6a38 fix: polish mobile rescan security surfaces 2026-04-28 01:17:13 -07:00
Patrick Erichsen a28d94c345 feat: show in progress scans 2026-04-28 00:52:51 -07:00
Peter Steinberger 3701733797 fix: normalize vt engine stats before caching 2026-04-28 08:47:50 +01:00
Peter Steinberger b8ba595d06 docs: note vt code insight calibration 2026-04-28 08:40:09 +01:00
Patrick Erichsen ef2846b2e4 feat: add owner rescan security surfaces 2026-04-28 00:39:44 -07:00
Peter Steinberger 232e429dee fix: clear uncorroborated vt suspicious state 2026-04-28 08:39:39 +01:00
Deepak Jain 932155cb8f docs: clarify static scan suppression gate 2026-04-28 08:39:17 +01:00
Deepak Jain d855d09ab0 fix: calibrate vt code insight moderation
Refs #1830
2026-04-28 08:39:17 +01:00
Peter Steinberger 16e87c147d ci: harden ClawSweeper dispatcher credentials 2026-04-28 06:48:40 +01:00
Peter Steinberger cbe22e70b9 ci: fix ClawSweeper dispatcher payload 2026-04-28 06:44:28 +01:00
Peter Steinberger 837331c967 ci: add ClawSweeper event dispatcher 2026-04-28 06:43:39 +01:00
Peter Steinberger 6ce443496d fix: keep package list queries single-page 2026-04-28 06:28:27 +01:00
Peter Steinberger 8fd4f3b051 ci: fix production smoke coverage 2026-04-28 06:18:40 +01:00
Peter Steinberger a2153909da fix: avoid plugin catalog query limits 2026-04-28 06:13:57 +01:00
Deepak Jain 75e1b4633e fix: constrain plugin catalog queries (#1842)
Refs #1699.

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

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

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

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

Refs #1760

* fix: harden secret evidence redaction
2026-04-28 03:39:36 +01:00
Peter Steinberger 8752e4bb7e chore(release): prepare clawhub cli 0.11.0 2026-04-28 02:26:56 +01:00
Peter Steinberger bc06c472e4 fix(packages): authenticate repository lookups 2026-04-28 02:20:35 +01:00
Peter Steinberger 09fa7daa7b docs: clarify skill monetization support 2026-04-28 01:52:37 +01:00
Peter Steinberger e21ca80a7d docs: document public catalog reuse 2026-04-28 01:21:46 +01:00
Peter Steinberger 456f4db74d fix(search): widen lexical fallback coverage 2026-04-27 22:46:22 +01:00
Patrick Erichsen 7014a53fdf Merge pull request #1837 from openclaw/pe/convex-ai-files
[codex] Add Convex AI guidance and skills
2026-04-27 14:25:54 -07:00
Patrick Erichsen c6c4481ffd chore: keep only Convex agent skills 2026-04-27 14:20:04 -07:00
Patrick Erichsen 02b7d10af8 chore: refresh agent skill state 2026-04-27 14:12:01 -07:00
Patrick Erichsen c5a6d2700f chore: track repo agent skills 2026-04-27 14:10:42 -07:00
Val Alexander 205db67e99 feat(packages): expose package soft delete API 2026-04-27 16:07:19 -05:00
Patrick Erichsen f406c5bf16 ci: use pinned Convex CLI for AI file updates 2026-04-27 13:59:30 -07:00
Patrick Erichsen add0d13bef ci: run Convex AI update at midnight Pacific 2026-04-27 13:56:48 -07:00
Peter Steinberger 7c5b8b2a20 fix(search): boost exact slug matches 2026-04-27 21:56:24 +01:00
Patrick Erichsen d3ed4434b9 feat: seed local rescan fixtures 2026-04-27 13:53:14 -07:00
Patrick Erichsen 7f7d6676c3 ci: update Convex AI files weekly 2026-04-27 13:52:27 -07:00
Patrick Erichsen aff186bbcf chore: add Convex AI guidance files 2026-04-27 13:46:14 -07:00
Patrick Erichsen 1c430cc11d feat: add scanner-specific security pages 2026-04-27 13:44:37 -07:00
Peter Steinberger e2cb7dfe4e fix(search): fall back when embeddings fail 2026-04-27 21:34:42 +01:00
Patrick Erichsen fb3bcbafb1 feat: show owner flagged inventory on dashboard 2026-04-27 13:27:08 -07:00
Patrick Erichsen e6c3d6ff28 feat: add owner rescan requests 2026-04-27 13:25:59 -07:00
Peter Steinberger 57970579cf fix(search): widen vector candidate pool 2026-04-27 21:10:13 +01:00
Peter Steinberger 2ddc52c0b0 test: fix root test and typecheck gates 2026-04-27 20:51:00 +01:00
Peter Steinberger e63031c452 fix(skill-install): use openclaw-compatible slug 2026-04-27 20:42:22 +01:00
Peter Steinberger e3c772d90b fix(search): preserve expanded candidate scoring 2026-04-27 20:41:20 +01:00
Peter Steinberger 73e26e51c1 fix(moderation): narrow webhook flag 2026-04-27 20:40:00 +01:00
Peter Steinberger e7a1e9937b fix(moderation): keep skill status on latest version 2026-04-27 20:39:02 +01:00
Peter Steinberger d5776f8499 fix(skills): separate historical detail tags 2026-04-27 20:37:41 +01:00
Peter Steinberger 280352d959 docs: thank recent contributors 2026-04-27 20:36:56 +01:00
Deepak Jain bd375f6a93 fix: reduce env scan false positives
Allow declared env vars used with network API calls without weakening broad env scraping or exfiltration findings.\n\nCloses #1790
2026-04-27 20:36:40 +01:00
Deepak Jain 422f6d4e08 docs: surface package publish flow
Document code-plugin package publish required fields and a minimal manifest.\n\nCloses #1796
2026-04-27 20:34:56 +01:00
Peter Steinberger dd111cacee fix(api): restore public skills listing
Fixes #1722
Fixes #1739
2026-04-27 20:33:51 +01:00
Peter Steinberger ffa83db48a fix: stabilize package plugin search 2026-04-27 20:26:49 +01:00
Momo 96c7ab1aaa fix(skills): prevent backport publishes from clobbering the latest tag (#1832)
Merged via squash.

Prepared head SHA: bc2ef22165
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-27 20:33:57 +08:00
Momo 743fa3abba fix(skill-install): stabilize install surface layout
CSS-only stabilization for the skill install surface.

- neutralize Radix scroll-lock body compensation now that the app reserves scrollbar gutter globally
- make the install surface span the full hero width and keep the two install panels balanced
- reserve stable space for prompt feedback and prompt preview content to avoid toggle reflow

Verified locally:
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bun run build
2026-04-24 15:31:17 -05:00
Val Alexander 6c079e93c2 fix: satisfy skill install typecheck
Repair the skill install surface follow-up typecheck issue after #1800 merged.

- replace the unused local exhaustiveness sentinel in `skillDetailUtils` with a shared `assertNever` helper
- keep the package-manager switch exhaustive without tripping `noUnusedLocals`
2026-04-23 15:36:14 -05:00
Val Alexander 23a109c037 feat: add skill install prompt surface
Add a dedicated skill install surface that pairs OpenClaw prompt-driven install with visible CLI commands.

- add Install with OpenClaw and CLI Commands panels to the skill detail page
- add Copy Prompt modes for Install Only and Install & Setup plus package-manager switching for the ClawHub CLI command
- add regression coverage for the new surface and make the repo build path use the working Vite invocation
2026-04-23 15:29:43 -05:00
Patrick Erichsen f28c1745f9 Merge pull request #1794 from openclaw/fix/vercel-image-allow-svg
fix(security): allow SVGs through image optimizer so badges render
2026-04-22 22:52:27 -07:00
Patrick ErichsenandClaude Opus 4.7 4fe275eb50 fix(security): enable safe SVG handling so shields.io badges render
vercel.json currently allow-lists SVG-only hosts (img.shields.io,
shields.io, badgen.net, flat.badgen.net) while dangerouslyAllowSVG:
false rejects every SVG source. Those two settings are incompatible,
and every badge in every README on production is returning 400
INVALID_IMAGE_OPTIMIZE_REQUEST (e.g. the license badge on
/plugins/@opik/opik-openclaw).

Switch to the pattern Vercel documents for safely serving SVGs in
their NEXTJS_SAFE_SVG_IMAGES conformance rule:

- dangerouslyAllowSVG: true  — lets the optimizer accept SVG inputs
- contentDispositionType: attachment  — forces download instead of
  inline document rendering if someone navigates directly to the
  /_vercel/image URL (the only context where SVG scripts would run)
- contentSecurityPolicy: script-src 'none'; sandbox;  — blocks script
  execution in the response

Defense in depth: browsers already sandbox SVGs loaded through <img>
so scripts don't run there anyway; the CSP + attachment header cover
the edge case of someone opening the optimizer URL directly. Net
security is equivalent to rejecting SVGs, but badges actually render.

Docs: https://vercel.com/docs/conformance/rules/NEXTJS_SAFE_SVG_IMAGES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:50:43 -07:00
Patrick Erichsen c0d2ac7ac0 Merge pull request #1793 from openclaw/fix/image-proxy-xss
fix(security): proxy README images via Vercel Image Optimization
2026-04-22 22:42:32 -07:00
Patrick ErichsenandClaude Opus 4.7 d6d4028660 refactor(security): swap ProxiedImg component for rehype plugin
Replaces the React <img> wrapper with a tiny rehype plugin that rewrites
image srcs in the HAST. Same behavior (external http(s) URLs routed
through /_vercel/image; local/relative/data: URIs pass through), less
surface area:

- One shared plugin wired into both MarkdownPreview and SkillDetailTabs
  via rehypePlugins instead of a components override at each call site
- Dropped ProxiedImg.tsx + its 7 unit tests; the two integration tests
  in MarkdownPreview.test.tsx still assert the proxy URL shape for both
  <img> and ![](url) syntax
- Stopped reading <img width="..."> for the proxy's w= param. Vercel
  requires w to match a value in vercel.json sizes, so arbitrary README
  widths (e.g. width="200") would have been rejected. Always w=1024 now;
  the HTML width attribute still drives layout

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:32:21 -07:00
Patrick ErichsenandClaude Opus 4.7 82ae30d940 fix(security): proxy README images via Vercel Image Optimization
Closes the XSS / IP-leak surface from rendering third-party README
images directly on clawhub.ai. Routes external http(s) <img> sources
through Vercel's /_vercel/image endpoint, which enforces a host
allow-list, rejects SVG by default, and re-encodes rasters to webp.

Docs: https://vercel.com/docs/image-optimization

- vercel.json: add `images` config — host allow-list (raw.githubusercontent,
  shields.io, etc., based on NuGet's published README allow-list),
  dangerouslyAllowSVG=false, formats=[webp], 1d minimum cache TTL.
- src/components/ProxiedImg.tsx: small wrapper that rewrites external
  http(s) src URLs to /_vercel/image?url=...&w=...&q=75. Local paths,
  relative paths, and data: URIs pass through unchanged.
- MarkdownPreview + SkillDetailTabs: pass ProxiedImg as the `img`
  component override to react-markdown — covers both raw HTML <img>
  and markdown ![](url) syntax.
- package.json: drop unused `next` dep (vestigial from staging merge,
  zero imports anywhere; doesn't affect next-themes).

Tests: 1028/1028 (was 1017, added 11 — ProxiedImg unit tests +
markdown integration tests covering proxied vs passthrough paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:12:40 -07:00
Patrick Erichsen b53813a5a7 Merge pull request #1792 from openclaw/fix/lint-cleanup-staging-fallout
chore(lint): clean up 70 oxlint errors from staging merge #1573
2026-04-22 21:39:34 -07:00
Patrick ErichsenandClaude Opus 4.7 87469792d5 fix(typecheck): clear remaining tsc errors on main
8 typecheck errors that have been on main alongside the lint debt:

- convex/apiSurface.typecheck.ts: drop two stale @ts-expect-error
  directives. The `increment` references they guarded no longer
  exist (functions renamed to *Internal); runtime internal-only
  enforcement is preserved by `internalMutation`.
- src/components/MarkdownPreview.tsx: cast createHighlighter result
  to AnyHighlighter, narrow loadHighlighter return via the local
  promise variable, type baseRehype + memoized rehypePlugins as
  PluggableList (drops `as const` readonly mismatch with
  ReactMarkdown's prop type).
- src/lib/theme.test.tsx: rename remaining "hub" usages to "claw"
  (theme families collapsed to one in PR #1573 — the last "hub"
  references in the harness button + applyTheme call would never
  compile under the current ThemeName type).
- src/lib/packageApi.test.ts: add `?.` on the nullable result.

Full suite: lint 0, tests 1017/1017, typecheck 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:33:17 -07:00
Patrick ErichsenandClaude Opus 4.7 9e7407cd84 test: update stale assertions left over from staging merge
Two pre-existing test failures on main, both caused by UI/data
changes in PR #1573 that the tests weren't updated for:

- theme.test.tsx: expected stored theme "hub" to round-trip, but
  the staging merge collapsed all families into a single "claw"
  theme — unknown families now fall back to "claw". Test now
  asserts the legacy fallback behavior it claims to test.
- skill-detail-page.test.tsx: gated on the platform license
  summary text, which was removed from SkillMetadataSidebar in
  4d1a08b. Drop the obsolete assertion; the report-button
  findByRole on the next line provides the same render-wait.

Full suite: 1017/1017 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:23:29 -07:00
Patrick ErichsenandClaude Opus 4.7 70fd9436cf chore(lint): clean up oxlint errors from staging-merge fallout (#1573)
Fixes 70 oxlint errors that landed in the 2026-04-18 staging merge and
have kept main red ever since. Three rule categories:

- typescript-eslint(no-unnecessary-type-conversion): drop redundant
  String/Number/Boolean wraps + 'as T' casts on values already typed.
- typescript-eslint(consistent-return): unify mixed return paths,
  mostly in useEffect callbacks (early-return vs cleanup-fn) and CLI
  command handlers.
- typescript-eslint(no-unnecessary-type-parameters): drop generics
  used only once in a signature; replace with concrete types.
- Plus a handful of no-unused-vars, no-shadow, and one
  no-redundant-type-constituents (JSX.Element -> ReactNode).

No runtime behavior changes. Full lint clean (0 errors); test suite
shows the same 2 pre-existing failures as main, no new regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:16:23 -07:00
Vincent Koc 5e7584e032 Merge pull request #1791 from openclaw/fix/markdown-html-passthrough 2026-04-22 20:59:27 -07:00
Patrick ErichsenandClaude Opus 4.7 ea0824878d fix(markdown): render raw HTML + GFM in MarkdownPreview, add shiki highlighting
Plugin/soul READMEs that use raw HTML (e.g. centered logos via
<h1 align="center">, <picture>, <br/>) were rendering as escaped
text because @create-markdown/preview escapes all HTML. Swap the
renderer for react-markdown + remark-gfm + rehype-raw +
rehype-sanitize (GitHub's stack), with rehype-shiki-from-highlighter
for fenced code block syntax highlighting.

Sanitize runs before shiki so user HTML is scrubbed, and shiki's
trusted styled output flows through untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:33:54 -07:00
Val Alexander da74b2a382 Update .gitignore 2026-04-22 14:22:53 -05:00
Val Alexander 4787be4eb1 Refresh Open Graph image (#1754)
* Refresh OG image and bust cache version

- Replace the social preview artwork with a new branded SVG and updated PNG
- Add a versioned og image URL in the root head tags to ensure the new asset is served

* Refresh OG image design

- Redesign the social preview graphic for the new ClawHub branding
- Bump the OG image version so the updated asset is served

* fix: refine clawhub og image

* fix: center og logo layout

* fix: emphasize clawhub branding in og image

* Refresh OG image branding

- Update Open Graph artwork and logo asset
- Adjust root metadata to use the new social preview image

* fix: refine clawhub og image

* fix: tighten og image layout

* fix: remove og logo panel

* fix: reduce og logo scale

* fix: align og image to new comp
2026-04-20 21:59:08 -05:00
Gustavo Madeira Santana 89246f1927 chore(ui): remove gap before hero cycled words 2026-04-19 13:25:56 -04:00
Val Alexander f4ddccbead enchance: mobile skills ux (#1737) 2026-04-18 20:10:03 -05:00
Val Alexander 3cafcbf873 Mobile search icon + system theme on first load
- Initialize root theme data from stored selection before paint
- Hide the search label on mobile and tighten button padding
2026-04-18 18:40:56 -05:00
Val Alexander 13064a7897 Merge pull request #1731 from openclaw/okcode/fix-mobile-search-button
Fix mobile header branding and add Home link
2026-04-18 17:52:29 -05:00
Val Alexander 194c22f4dd Add branded mobile nav header
- Show the logo mark in the mobile drawer title
- Tighten mobile suggestion spacing on small screens
- Add test coverage for the branded mobile nav header
2026-04-18 17:50:21 -05:00
Val Alexander a693b945fa Add Home link to mobile header navigation
- Insert a Home entry at the top of the mobile menu
- Update header tests to cover the new menu order
2026-04-18 17:40:22 -05:00
Val Alexander 9bef672541 Merge branch 'okcode/polished-card-icons-logo' 2026-04-18 17:28:51 -05:00
Val Alexander 9551cac37b Merge pull request #1729 from openclaw/okcode/fix-settings-update
Stabilize preferences sync and keep diff editor mounted
2026-04-18 17:25:52 -05:00
Val Alexander eb4138fbb3 fix: harden preferences storage sync 2026-04-18 17:24:31 -05:00
Val Alexanderandgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> 5fbead624b Update src/lib/preferences.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-18 17:17:18 -05:00
Val Alexander 35094177e6 Keep diff editor mounted when switching view mode
- Remove the diff editor remount on inline vs side-by-side toggles
- Add a regression test to verify the editor stays mounted
2026-04-18 17:04:30 -05:00
Val Alexander faa5c9f2b5 Polish icons and brand mark styling
- Simplify home and settings labels by removing redundant icons
- Swap automation icons to refresh glyphs in sidebars and toolbar
- Add subtle border and shadow treatment to the brand mark
2026-04-18 17:03:35 -05:00
Val Alexander c3314c2d01 Stabilize preference snapshots and storage sync
- Cache localStorage reads to avoid redundant snapshot churn
- Sync updates across tabs and add coverage for preference re-renders
2026-04-18 17:03:15 -05:00
Val Alexander 7dfa19157c Merge pull request #1573 from openclaw/staging
chore: merge staging into main
2026-04-18 16:46:00 -05:00
Val AlexanderandClaude Opus 4.6 44acf86ac1 merge: resolve AGENTS.md conflict — keep both convex-ai and stat migration rules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:45:37 -05:00
Val AlexanderandClaude Opus 4.6 a0ebc1b50a style: spread footer columns evenly across full width
Switch footer grid from auto-sized centered columns to equal 1fr
columns that span the full screen width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:44:28 -05:00
Val AlexanderandClaude Opus 4.6 88dbb69a23 style: adopt darker home-v2 palette globally and unify radius to 8px
Shift all theme variants (claw dark/light, hub dark/light) to the
bolder home-v2 backgrounds (#060608 dark, #faf6f1 light cream).
Harmonize surface, nav-bg, input-bg, and overlay-bg to match.
Set every radius token (--r-lg/md/sm/xs/pill) and home-v2 hardcoded
radii to a single consistent 8px value.
Remove home-v2–specific overrides for app-shell background, navbar
background, footer transparency, and navbar-inner max-width that
previously caused visual divergence between the home page and the
rest of the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:30:53 -05:00
Val Alexander df9acd27e4 update: styles 2026-04-18 16:28:00 -05:00
Val AlexanderandClaude Opus 4.6 dbd5d4042c fix: restore header logo and compact/center footer
Uncomment the brand logo image in the header navbar and reduce footer
vertical padding, gaps, and margins to ~55% of original height while
centering the grid columns and link text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:17:17 -05:00
Val AlexanderClaude Opus 4.6copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ebe82b7e18 Improve about page rejection categories (#1728)
* improve about page rejection categories: add icons, fix grid, polish cards

- Add lucide-react icons to each rejection category card for visual scanning
- Fix unbalanced grid layout by removing featured card sizing, using clean 2/3-col grid
- Fix broken hover transitions (var(--transition-fast) was undefined outside reduced-motion)
- Add lift-on-hover effect and icon glow matching home page card patterns
- Render backtick-wrapped text as styled inline code elements
- Improve description text contrast from ~3.5:1 to ~4.8:1 (WCAG AA)

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

* fix: use h3 for card titles to fix heading hierarchy (WCAG 1.3.1)

Change rejection category card titles from <h2> to <h3> since the parent
section already uses <h2> for "Immediate rejection categories". Updates
the matching CSS selector from .about-rule-card h2 to h3.

Also adds tests for renderWithInlineCode helper covering plain text,
single/multiple code spans, empty input, and code-only strings.

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

* fix: remove white backgrounds from all logo assets

- Remove white backgrounds from clawd-logo.png, clawd-mark.png,
  logo192.png, logo512.png — now transparent PNGs
- Convert white strokes to dark (#1a0808) in both PNGs and logo.svg
  so segments separate cleanly on any background
- Defringe antialiased edges to eliminate white halos
- Regenerate favicon.ico from transparent source
- Update manifest.json background_color from #ffffff to #0a0a0a

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

* consolidate logo assets: delete SVGs, use only PNGs with transparent bg

- Delete public/logo.svg, public/og.svg, src/logo.svg (dead/unused SVGs)
- Remove logo.svg favicon link from __root.tsx (favicon.ico remains)
- Remove white backgrounds from clawd-logo.png and clawd-mark.png
- Convert white strokes to dark (#1a0808), defringe antialiased edges
- Regenerate logo192.png, logo512.png, favicon.ico from clean sources
- Only canonical logo files are now clawd-logo.png and clawd-mark.png

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

* style: widen navbar search bar and polish hero section

Let the search bar span the full width between brand and theme toggle
by removing the oversized right-column minimum and theme-toggle min-width.
Widen the hero search container, subtitle, and tighten vertical padding
for a sleeker feel.

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

* fix: use :is(h2, h3) selector for about-rule-card headings

The /souls page reuses about-rule-card with <h2> elements. Using
:is(h2, h3) ensures both heading levels get styled consistently.

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

* feat: slot machine Easter egg on hero label triple-click

Triple-clicking "BUILT BY THE COMMUNITY" triggers a casino-style slot
machine across all 3 headline words. Reels spin and stop sequentially
with a 1/13 jackpot chance. Winning fires a confetti celebration with
golden text glow. Auto-resets after the animation completes.

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

* feat: add cooldown, longer celebration, and Hack x3 lobster jackpot

- 18s cooldown after a win, 3s after a loss to prevent spam
- Win celebration extended to 10s for screenshot opportunities
- Hack x3 jackpot triggers aquatic theme: cyan/teal text glow,
  ocean-colored confetti with bubble and claw particles, and the
  lobster logo fades in behind the headline

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

* feat: tune slot machine odds to 1/25 any jackpot, 1/100 Hack jackpot

Replace pure random picks with controlled probability: 4% chance of
any jackpot per spin, with 25% of jackpots being Hack (= 1% overall).
Non-jackpot spins re-roll accidental triple matches to keep odds exact.

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

* fix: clean up slot timers on unmount, fix about-grid specificity

Add useEffect cleanup to clear slot machine timers/intervals when
the home route unmounts mid-animation. Fix about-grid media query
specificity by including .about-panel-categories .about-grid to
override the higher-specificity base rule.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-18 16:07:48 -05:00
Val AlexanderandClaude Opus 4.6 4c566268a9 fix: hide logo, clean up rejection categories layout (#1727)
* fix: hide logo, use ClawHub as home link, and clean up rejection categories grid

Comment out the brand logo image for now, rename "Immediate rejection
categories" to "Rejection Categories", remove the featured card variant,
and switch to an auto-fill grid so cards spread evenly at full width.
Add overflow: visible on the categories panel to prevent hover shadow
clipping.

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

* fix: impose max page width on home page using --page-max (1536px)

Constrain .home-v2-main to max-width: var(--page-max) and center it
with margin-inline: auto. Extend the home page background color to the
full viewport via .app-shell:has(.home-v2-main) for both light and dark
themes so the background bleeds edge-to-edge beyond the content column.

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

* fix: remove extra footer padding and ensure full-width nav/footer for boxed layout

Zero out the outer .site-footer padding and set background to transparent
on home-v2 pages so the app-shell background bleeds through edge-to-edge.
Remove the redundant light-mode footer background override (app-shell
background already covers it). Nav and footer now visually span full
viewport width while .home-v2-main content stays boxed at --page-max.

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

* fix: reduce carousel card hover effect and increase track padding

The carousel cards were getting clipped by the parent overflow:hidden
container. Reduce the hover transform from translateY(-4px) scale(1.01)
to translateY(-2px) and shrink box-shadow spread across all theme
variants. Increase carousel track top padding from 4px to 12px to
accommodate the upward shift without cutoff.

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

* fix: address review feedback — mobile brand, category grid, hover drama

- Keep brand name visible on mobile (remove display:none for
  .brand-name-responsive at ≤639px) so the home link is always
  discoverable. Add TODO comment on the commented-out logo block.
- Add .about-panel-categories .about-grid to the ≤640px media query
  so the category grid correctly collapses to single-column on mobile.
- Bump carousel card hover to translateY(-3px) with 0 6px 24px shadow
  for a slightly more dramatic lift — still within the 12px top / 48px
  bottom track padding so nothing clips.

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

* chore: remove unused footer divider element

The site-footer-divider was already hidden via CSS (display: none) on
home-v2 pages. Remove the element entirely since it serves no purpose.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:37:43 -05:00
Val Alexander e54fc1939a fix: normalize nav and footer layout 2026-04-18 13:41:36 -05:00
Momoandmomothemage 530e39eedc refactor: extract readCanonicalStat and add structural guards for stat field migration (#1709)
Merged via squash.

Prepared head SHA: e92817f66f
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-17 17:44:32 +08:00
copilot-swe-agent[bot]andBunsDev 8b87c31a99 Merge remote-tracking branch 'origin/main' into staging
# Conflicts:
#	src/routes/management.tsx
#	src/routes/settings.tsx

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
2026-04-17 08:49:21 +00:00
Momo f7bc8b6349 fix(stats): fix skill stat field sync direction and reconcile logic (#1704)
Merged via squash.

Prepared head SHA: e814278382
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Co-authored-by: momothemage <35096042+momothemage@users.noreply.github.com>
Reviewed-by: @momothemage
2026-04-16 20:04:13 +08:00
5b8f09167a fix(api): align inspect security snapshot with static scan moderation
Include static scan results in the skill version security snapshot so inspect/API responses reflect the same moderation-relevant signal already used elsewhere. Also add regression coverage for suspicious, malicious, and static-only scan combinations.

Co-authored-by: davida-ps <232346510+davida-ps@users.noreply.github.com>
Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com>
2026-04-16 18:39:39 +10:00
hugh 17fbd13bc9 fix(cli): use explorer on Windows to preserve auth URL params
On Windows, opening auth URLs via `cmd /c start` can truncate query parameters because `&` is treated as a command separator. Use `explorer` instead so the browser opener gets the full URL without shell parsing, and cover the Windows spawn args in the CLI UI test.

Co-authored-by: hugh <1012760428@qq.com>
2026-04-16 14:00:03 +10:00
aab7dc9ba4 fix(upload): fall back to octet-stream for empty Content-Type
Handle browser uploads that provide an empty MIME type by falling back to `application/octet-stream` before sending the storage request.

Co-authored-by: Arthur Katcher <192321283+arthurkatcher@users.noreply.github.com>
Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com>
2026-04-16 12:54:51 +10:00
ImLukeF dde8796790 feat: tag skills needing sensitive credentials 2026-04-14 20:09:24 +10:00
Val Alexander acc6d292de Home v2 styles: layout, theme & navbar tweaks
Add and refine styles for the Home V2 UI: introduce navbar search/home styles, motto and headline variants, section copy/eyebrow rules, discovery and categories layouts, and responsive grid stacking. Adjust hv2 color variables (text-secondary/tertiary) and move category border to the grid element; update spacing/alignment for carousel and section headers. Add light/dark theme overrides to improve navbar, tabs and search contrast and hover states. Misc minor typographic and spacing refinements for a more cohesive Home V2 appearance.
2026-04-14 00:27:04 -05:00
ImLukeF 2236ed7be1 feat: add org profile editing 2026-04-14 14:10:52 +10:00
ImLukeF f6fb7ccfc0 Revert "Reapply "feat: allow moderators to transfer skill publishers (#1663)""
This reverts commit b73758c7c8.
2026-04-14 13:39:49 +10:00
ImLukeF b73758c7c8 Reapply "feat: allow moderators to transfer skill publishers (#1663)"
This reverts commit fbc07c5617.
2026-04-14 13:38:30 +10:00
ImLukeF fbc07c5617 Revert "feat: allow moderators to transfer skill publishers (#1663)"
This reverts commit 80e5aec577.
2026-04-14 13:37:08 +10:00
Luke 80e5aec577 feat: allow moderators to transfer skill publishers (#1663) 2026-04-14 13:36:28 +10:00
Val AlexanderandNova f869b31ad6 fix: remove leftover theme-family UI remnants
- drop mobile theme-family section in header
- remove unused theme-family settings bindings

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 22:15:06 -05:00
Val AlexanderandNova 9a853f2fcc chore: update lockfile and favicon
- refresh bun.lock after dependency reinstall
- include favicon update

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 22:13:03 -05:00
Val AlexanderandNova b4a7540157 feat: homepage redesign + unified theme + UI polish
- Redesign homepage with hero, search, featured carousel, categories, proof bar, trending
- Add cream/peach/tan light mode palette with inset-shadow pattern (dark + light)
- Remove Hub theme — single Claw theme only (light/dark mode toggle remains)
- Semi-rounded radius system (Claw × Hub midpoint: 4/7/10px)
- Consistent button radius site-wide (--r-btn: 4px), zero makeshift buttons
- Add VITE_FEATURE_SOULS env flag (default: false) to gate Souls pages
- Hide Souls from nav, footer, and homepage categories
- Remove theme family toggle from Header + Settings
- Widen page max to screen-2xl (1536px)
- Slow featured carousel 15% (40s → 46s)

Co-authored-by: Nova <nova@openknot.ai>
2026-04-13 21:59:23 -05:00
Val Alexander 0ea1127a2b fix: refine header and about responsiveness (#1661) 2026-04-13 13:03:40 -05:00
Val Alexander aeab23a6d6 Fix dark-mode styling for skills filter chips (#1660)
- Add readable dark-surface and active-state colors to filter chips
- Cover the toolbar styling with a jsdom test
2026-04-13 13:01:37 -05:00
350 changed files with 39869 additions and 6816 deletions
+347
View File
@@ -0,0 +1,347 @@
---
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,288 @@
---
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.
---
# Convex Create Component
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
- Packaging Convex functionality for reuse across multiple apps
## When Not to Use
- One-off business logic that belongs in the main app
- Thin utilities that do not need Convex tables or functions
- App-level orchestration that should stay in `convex/`
- Cases where a normal TypeScript library is enough
## 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.
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.
## Choose the Shape
Ask the user, then pick one path:
| Goal | Shape | Reference |
| ------------------------------------------------- | ---------------- | ----------------------------------- |
| Component for this app only | Local | `references/local-components.md` |
| Publish or share across apps | Packaged | `references/packaged-components.md` |
| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` |
| Not sure | Default to local | `references/local-components.md` |
Read exactly one reference file before proceeding.
## Default Approach
Unless the user explicitly wants an npm package, default to a local component:
- Put it under `convex/components/<componentName>/`
- Define it with `defineComponent(...)` in its own `convex.config.ts`
- Install it from the app's `convex/convex.config.ts` with `app.use(...)`
- Let `npx convex dev` generate the component's own `_generated/` files
## Component Skeleton
A minimal local component with a table and two functions, plus the app wiring.
```ts
// convex/components/notifications/convex.config.ts
import { defineComponent } from "convex/server";
export default defineComponent("notifications");
```
```ts
// convex/components/notifications/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
notifications: defineTable({
userId: v.string(),
message: v.string(),
read: v.boolean(),
}).index("by_user", ["userId"]),
});
```
```ts
// convex/components/notifications/lib.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
export const send = mutation({
args: { userId: v.string(), message: v.string() },
returns: v.id("notifications"),
handler: async (ctx, args) => {
return await ctx.db.insert("notifications", {
userId: args.userId,
message: args.message,
read: false,
});
},
});
export const listUnread = query({
args: { userId: v.string() },
returns: v.array(
v.object({
_id: v.id("notifications"),
_creationTime: v.number(),
userId: v.string(),
message: v.string(),
read: v.boolean(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.filter((q) => q.eq(q.field("read"), false))
.collect();
},
});
```
```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import notifications from "./components/notifications/convex.config.js";
const app = defineApp();
app.use(notifications);
export default app;
```
```ts
// convex/notifications.ts (app-side wrapper)
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});
export const myUnread = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.runQuery(components.notifications.lib.listUnread, {
userId,
});
},
});
```
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.
## Patterns
### Authentication and environment access
```ts
// Bad: component code cannot rely on app auth or env
const identity = await ctx.auth.getUserIdentity();
const apiKey = process.env.OPENAI_API_KEY;
```
```ts
// Good: the app resolves auth and env, then passes explicit values
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runAction(components.translator.translate, {
userId,
apiKey: process.env.OPENAI_API_KEY,
text: args.text,
});
```
### Client-facing API
```ts
// Bad: assuming a component function is directly callable by clients
export const send = components.notifications.send;
```
```ts
// Good: re-export through an app mutation or query
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});
```
### IDs across the boundary
```ts
// Bad: parent app table IDs are not valid component validators
args: {
userId: v.id("users");
}
```
```ts
// Good: treat parent-owned IDs as strings at the boundary
args: {
userId: v.string();
}
```
### 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`.
## Validation
Try validation in this order:
1. `npx convex codegen --component-dir convex/components/<name>`
2. `npx convex codegen`
3. `npx convex dev`
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.
## Reference Files
Read exactly one of these after the user confirms the goal:
- `references/local-components.md`
- `references/packaged-components.md`
- `references/hybrid-components.md`
Official docs: [Authoring Components](https://docs.convex.dev/components/authoring)
## Checklist
- [ ] Asked the user what they want to build and confirmed the shape
- [ ] 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 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()`
- [ ] Public functions have `args` and `returns` validators
- [ ] Ran `npx convex dev` and fixed codegen or type issues
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Create Component"
short_description: "Design and build reusable Convex components with clear boundaries."
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."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-2.25-1.313M21 7.5v2.25m0-2.25-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3 2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75 2.25-1.313M12 21.75V19.5m0 2.25-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1,134 @@
# Advanced Component Patterns
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.
```ts
// App side: create a handle and pass it to the component
import { createFunctionHandle } from "convex/server";
export const startJob = mutation({
handler: async (ctx) => {
const handle = await createFunctionHandle(internal.myModule.processItem);
await ctx.runMutation(components.workpool.enqueue, {
callback: handle,
});
},
});
```
```ts
// Component side: accept and invoke the handle
import { v } from "convex/values";
import type { FunctionHandle } from "convex/server";
import { mutation } from "./_generated/server.js";
export const enqueue = mutation({
args: { callback: v.string() },
handler: async (ctx, args) => {
const handle = args.callback as FunctionHandle<"mutation">;
await ctx.scheduler.runAfter(0, handle, {});
},
});
```
## Deriving validators from schema
Instead of manually repeating field types in return validators, extend the schema validator:
```ts
import { v } from "convex/values";
import schema from "./schema.js";
const notificationDoc = schema.tables.notifications.validator.extend({
_id: v.id("notifications"),
_creationTime: v.number(),
});
export const getLatest = query({
args: {},
returns: v.nullable(notificationDoc),
handler: async (ctx) => {
return await ctx.db.query("notifications").order("desc").first();
},
});
```
## Static configuration with a globals table
A common pattern for component configuration is a single-document "globals" table:
```ts
// schema.ts
export default defineSchema({
globals: defineTable({
maxRetries: v.number(),
webhookUrl: v.optional(v.string()),
}),
// ... other tables
});
```
```ts
// lib.ts
export const configure = mutation({
args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.query("globals").first();
if (existing) {
await ctx.db.patch(existing._id, args);
} else {
await ctx.db.insert("globals", args);
}
return null;
},
});
```
## 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.
```ts
// src/client/index.ts
import type { GenericMutationCtx, GenericDataModel } from "convex/server";
import type { ComponentApi } from "../component/_generated/component.js";
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
export class Notifications {
constructor(
private component: ComponentApi,
private options?: { defaultChannel?: string },
) {}
async send(ctx: MutationCtx, args: { userId: string; message: string }) {
return await ctx.runMutation(this.component.lib.send, {
...args,
channel: this.options?.defaultChannel ?? "default",
});
}
}
```
```ts
// App usage
import { Notifications } from "@convex-dev/notifications";
import { components } from "./_generated/api";
const notifications = new Notifications(components.notifications, {
defaultChannel: "alerts",
});
export const send = mutation({
args: { message: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
await notifications.send(ctx, { userId, message: args.message });
},
});
```
@@ -0,0 +1,37 @@
# Hybrid Convex Components
Read this file only when the user explicitly wants a hybrid setup.
## What This Means
A hybrid component combines a local Convex component with shared library code.
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
## Default Advice
Treat hybrid as an advanced option, not the default.
Before choosing it, ask:
- Why is a plain local component not enough?
- Why is a packaged component not enough?
- What exactly needs to stay overridable or shared?
If the answer is vague, fall back to local or packaged.
## Risks
- More moving parts
- Harder upgrades and backwards compatibility
- Easier to blur the component boundary
## Checklist
- [ ] User explicitly needs hybrid behavior
- [ ] Local-only and packaged-only options were considered first
- [ ] The extension points are clearly defined before coding
@@ -0,0 +1,38 @@
# 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.
## When to Choose This
- The user wants the simplest path
- The component only needs to work in this repo
- The goal is extracting app logic into a cleaner boundary
## Default Layout
Use this structure unless the repo already has a clear alternative pattern:
```text
convex/
convex.config.ts
components/
<name>/
convex.config.ts
schema.ts
<feature>.ts
```
## Workflow Notes
- Define the component with `defineComponent("<name>")`
- Install it from the app with `defineApp()` and `app.use(...)`
- Keep auth, env access, public API wrappers, and HTTP route mounting in the app
- Let the component own isolated tables and reusable backend workflows
- Add app wrappers if clients need to call into the component
## Checklist
- [ ] Component is inside `convex/components/<name>/`
- [ ] App installs it with `app.use(...)`
- [ ] Component owns only its own tables
- [ ] App wrappers handle client-facing calls when needed
@@ -0,0 +1,51 @@
# Packaged Convex Components
Read this file when the user wants a reusable npm package or a component shared across multiple apps.
## When to Choose This
- The user wants to publish the component
- The user wants a stable reusable package boundary
- The component will be shared across multiple apps or teams
## 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
- 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.
Recommended order:
1. `npx convex codegen --component-dir ./path/to/component`
2. Run the package build command
3. Run `npx convex dev --typecheck-components` in the example app
Do not assume normal app codegen is enough for packaged component workflows.
## Package Exports
If publishing to npm, make sure the package exposes the entry points apps need:
- package root for client helpers, types, or classes
- `./convex.config.js` for installing the component
- `./_generated/component.js` for the app-facing `ComponentApi` type
- `./test` for testing helpers when applicable
## Testing
- Use `convex-test` for component logic
- Register the component schema and modules with the test instance
- Test app-side wrapper code from an example app that installs the package
- Export a small helper from `./test` if consumers need easy test registration
## Checklist
- [ ] Packaging is actually required
- [ ] Build order avoids bundle and codegen races
- [ ] Package exports include install and typing entry points
- [ ] Example app exercises the packaged component
- [ ] Core behavior is covered by tests
@@ -0,0 +1,149 @@
---
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.
---
# Convex Migration Helper
Safely migrate Convex schemas and data when making breaking changes.
## When to Use
- Adding new required fields to existing tables
- Changing field types or structure
- Splitting or merging tables
- Renaming or deleting fields
- Migrating from nested to relational data
## When Not to Use
- Greenfield schema with no existing data in production or dev
- Adding optional fields that do not need backfilling
- Adding new tables with no existing data to migrate
- Adding or removing indexes with no correctness concern
- Questions about Convex schema design without a migration need
## Key Concepts
### 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:
- 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**.
### 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.
### 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.
### 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.
## Safe Changes (No Migration Needed)
### Adding Optional Field
```typescript
// Before
users: defineTable({
name: v.string(),
});
// After - safe, new field is optional
users: defineTable({
name: v.string(),
bio: v.optional(v.string()),
});
```
### Adding New Table
```typescript
posts: defineTable({
userId: v.id("users"),
title: v.string(),
}).index("by_user", ["userId"]);
```
### Adding Index
```typescript
users: defineTable({
name: v.string(),
email: v.string(),
}).index("by_email", ["email"]);
```
## Breaking Changes: The Deployment Workflow
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)
2. Update code to handle both formats when reading
3. Update code to write the new format for new documents
4. Deploy
**Between deploys - Migrate data:**
5. Run migration to backfill existing documents
6. Verify all documents are migrated
**Deploy 2 - Narrow the schema:**
7. Update schema to require the new format only
8. Remove code that handles the old format
9. Deploy
## 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.
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:
- Adding a required field
- Deleting a field
- Changing a field type
- Splitting nested data into a separate table
- Cleaning up orphaned documents
- Zero-downtime strategies (dual write, dual read)
- Small table shortcut (single internalMutation without the component)
- Verifying a migration is complete
## 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.
## Migration Checklist
- [ ] Identify the breaking change and plan the multi-deploy workflow
- [ ] Update schema to allow both old and new formats
- [ ] Update code to handle both formats when reading
- [ ] Update code to write the new format for new documents
- [ ] Deploy widened schema and updated code
- [ ] Define migration using the `@convex-dev/migrations` component
- [ ] Test with `dryRun: true`
- [ ] Run migration and monitor status
- [ ] Verify all documents are migrated
- [ ] Update schema to require new format only
- [ ] Clean up code that handled old format
- [ ] Deploy final schema and code
- [ ] Remove migration code once confirmed stable
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Migration Helper"
short_description: "Plan and run safe Convex schema and data migrations."
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."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1,231 @@
# Migration Patterns Reference
Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations.
## Adding a Required Field
```typescript
// Deploy 1: Schema allows both states
users: defineTable({
name: v.string(),
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
});
// Migration: backfill the field
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});
// Deploy 2: After migration completes, make it required
users: defineTable({
name: v.string(),
role: v.union(v.literal("user"), v.literal("admin")),
});
```
## Deleting a Field
Mark the field optional first, migrate data to remove it, then remove from schema:
```typescript
// Deploy 1: Make optional
// isPro: v.boolean() --> isPro: v.optional(v.boolean())
// Migration
export const removeIsPro = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.isPro !== undefined) {
await ctx.db.patch(team._id, { isPro: undefined });
}
},
});
// Deploy 2: Remove isPro from schema entirely
```
## Changing a Field Type
Prefer creating a new field. You can combine adding and deleting in one migration:
```typescript
// Deploy 1: Add new field, keep old field optional
// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...)
// Migration: convert old field to new field
export const convertToEnum = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.plan === undefined) {
await ctx.db.patch(team._id, {
plan: team.isPro ? "pro" : "basic",
isPro: undefined,
});
}
},
});
// Deploy 2: Remove isPro from schema, make plan required
```
## Splitting Nested Data Into a Separate Table
```typescript
export const extractPreferences = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.preferences === undefined) return;
const existing = await ctx.db
.query("userPreferences")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.first();
if (!existing) {
await ctx.db.insert("userPreferences", {
userId: user._id,
...user.preferences,
});
}
await ctx.db.patch(user._id, { preferences: undefined });
},
});
```
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
```typescript
export const deleteOrphanedEmbeddings = migrations.define({
table: "embeddings",
migrateOne: async (ctx, doc) => {
const chunk = await ctx.db
.query("chunks")
.withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id))
.first();
if (!chunk) {
await ctx.db.delete(doc._id);
}
},
});
```
## Zero-Downtime 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.
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.
```typescript
// Bad: only writing to new structure before migration is done
export const createTeam = mutation({
args: { name: v.string(), isPro: v.boolean() },
handler: async (ctx, args) => {
await ctx.db.insert("teams", {
name: args.name,
plan: args.isPro ? "pro" : "basic",
});
},
});
// Good: writing to both structures during migration
export const createTeam = mutation({
args: { name: v.string(), isPro: v.boolean() },
handler: async (ctx, args) => {
const plan = args.isPro ? "pro" : "basic";
await ctx.db.insert("teams", {
name: args.name,
isPro: args.isPro,
plan,
});
},
});
```
### Dual Read
Read both formats. Write only the new format.
1. Deploy code that reads both formats (preferring new), writes only 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.
```typescript
// Good: reading both formats, preferring new
function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
if (team.plan !== undefined) return team.plan;
return team.isPro ? "pro" : "basic";
}
```
## Small Table Shortcut
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";
export const backfillSmallTable = internalMutation({
handler: async (ctx) => {
const docs = await ctx.db.query("smallConfig").collect();
for (const doc of docs) {
if (doc.newField === undefined) {
await ctx.db.patch(doc._id, { newField: "default" });
}
}
},
});
```
```bash
npx convex run migrations:backfillSmallTable
```
Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component.
## Verifying a Migration
Query to check remaining unmigrated documents:
```typescript
import { query } from "./_generated/server";
export const verifyMigration = query({
handler: async (ctx) => {
const remaining = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("role"), undefined))
.take(10);
return {
complete: remaining.length === 0,
sampleRemaining: remaining.map((u) => u._id),
};
},
});
```
Or use the component's built-in status monitoring:
```bash
npx convex run --component migrations lib:getStatus --watch
```
@@ -0,0 +1,169 @@
# Migrations Component Reference
Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations.
## Installation
```bash
npm install @convex-dev/migrations
```
## Setup
```typescript
// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config.js";
const app = defineApp();
app.use(migrations);
export default app;
```
```typescript
// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api.js";
import { DataModel } from "./_generated/dataModel.js";
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.
## Define a Migration
The `migrateOne` function processes a single document. The component handles batching and pagination automatically.
```typescript
// convex/migrations.ts
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});
```
Shorthand: if you return an object, it is applied as a patch automatically.
```typescript
export const clearDeprecatedField = migrations.define({
table: "users",
migrateOne: () => ({ legacyField: undefined }),
});
```
## Run a Migration
From the CLI:
```bash
# Define a one-off runner in convex/migrations.ts:
# export const runIt = migrations.runner(internal.migrations.addDefaultRole);
npx convex run migrations:runIt
# Or use the general-purpose runner
npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}'
```
Programmatically from another Convex function:
```typescript
await migrations.runOne(ctx, internal.migrations.addDefaultRole);
```
## Run Multiple Migrations in Order
```typescript
export const runAll = migrations.runner([
internal.migrations.addDefaultRole,
internal.migrations.clearDeprecatedField,
internal.migrations.normalizeEmails,
]);
```
```bash
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.
## Dry Run
Test a migration before committing changes:
```bash
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.
## Check Migration Status
```bash
npx convex run --component migrations lib:getStatus --watch
```
## Cancel a Running Migration
```bash
npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}'
```
Or programmatically:
```typescript
await migrations.cancel(ctx, internal.migrations.addDefaultRole);
```
## Run Migrations on Deploy
Chain migration execution after deploying:
```bash
npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod
```
## Configuration Options
### 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:
```typescript
export const migrateHeavyTable = migrations.define({
table: "largeDocuments",
batchSize: 10,
migrateOne: async (ctx, doc) => {
// migration logic
},
});
```
### Migrate a Subset Using an Index
Process only matching documents instead of the full table:
```typescript
export const fixEmptyNames = migrations.define({
table: "users",
customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")),
migrateOne: () => ({ name: "<unknown>" }),
});
```
### 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:
```typescript
export const clearField = migrations.define({
table: "myTable",
parallelize: true,
migrateOne: () => ({ optionalField: undefined }),
});
```
@@ -0,0 +1,143 @@
---
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.
---
# Convex Performance Audit
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
- OCC conflict errors or excessive mutation retries
- High subscription count or slow UI updates
- Functions approaching execution or transaction limits
- The same performance pattern needs fixing across sibling functions
## When Not to Use
- 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
## 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
## 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.
## Signal Routing
After gathering signals, identify the problem class and read the matching reference file.
| Signal | Reference |
| -------------------------------------------------------------- | ----------------------------------------- |
| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` |
| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` |
| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` |
| 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.
## Escalate Larger Fixes
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
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.
Write down:
- entrypoint functions
- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation`
- tables read
- tables written
- whether the path is high-read, high-write, or both
### 2. Trace the full read and write set
For each function in the path:
1. Trace every `ctx.db.get()` and `ctx.db.query()`
2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()`
3. Note foreign-key lookups, JS-side filtering, and full-document reads
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.
### 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.
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.
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
Do not leave one path fixed and another path on the old pattern unless there is a clear product reason.
### 5. Verify before finishing
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
## 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
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
- [ ] Gathered signals from insights, dashboard, or code audit
- [ ] Identified the problem class and read the matching reference
- [ ] Scoped one concrete user flow or function path
- [ ] Traced every read and write in that path
- [ ] Identified sibling functions touching the same tables
- [ ] Applied fixes from the reference, following the recommended fix order
- [ ] Fixed sibling functions consistently
- [ ] Verified behavior and confirmed no regressions
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Performance Audit"
short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
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."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

@@ -0,0 +1,232 @@
# Function Budget
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.
## 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.
| Resource | Limit |
| --------------------------------- | ----------------------------------------------------- |
| Query/mutation execution time | 1 second (user code only, excludes DB operations) |
| Action execution time | 10 minutes |
| Data read per transaction | 16 MiB |
| Data written per transaction | 16 MiB |
| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) |
| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) |
| Documents written per transaction | 16,000 |
| Individual document size | 1 MiB |
| Function return value size | 16 MiB |
## Symptoms
- "Function execution took too long" errors
- "Transaction too large" or read/write set size errors
- Slow queries that read many documents
- Client receiving large payloads that slow down page load
- `npx convex insights --details` showing high bytes read
## Common Causes
### 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.
### 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.
### Mutation doing too much work
A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction.
### Returning too much data to the client
A query returning full documents when the client only needs a few fields.
## Fix Order
### 1. Bound your reads
Never `.collect()` without a limit on a table that can grow unbounded.
```ts
// Bad: unbounded read, breaks as the table grows
const messages = await ctx.db.query("messages").collect();
```
```ts
// Good: paginate or limit
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);
```
### 2. Read smaller shapes
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.
### 3. Break large mutations into batches
If a mutation needs to update hundreds of documents, split it into a self-scheduling chain.
```ts
// Bad: one mutation updating every row
export const backfillAll = internalMutation({
handler: async (ctx) => {
const docs = await ctx.db.query("items").collect();
for (const doc of docs) {
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
}
},
});
```
```ts
// Good: cursor-based batch processing
export const backfillBatch = internalMutation({
args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) },
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 100;
const result = await ctx.db
.query("items")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
for (const doc of result.page) {
if (doc.newField === undefined) {
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
}
}
if (!result.isDone) {
await ctx.scheduler.runAfter(0, internal.items.backfillBatch, {
cursor: result.continueCursor,
batchSize,
});
}
},
});
```
### 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.
Actions run outside the transaction and can call mutations to write results back.
```ts
// Bad: heavy computation inside a mutation
export const processUpload = mutation({
handler: async (ctx, args) => {
const result = expensiveComputation(args.data);
await ctx.db.insert("results", result);
},
});
```
```ts
// Good: action for heavy work, mutation for the write
export const processUpload = action({
handler: async (ctx, args) => {
const result = expensiveComputation(args.data);
await ctx.runMutation(internal.results.store, { result });
},
});
```
### 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.
```ts
// Bad: returns full documents including large content fields
export const list = query({
handler: async (ctx) => {
return await ctx.db.query("articles").take(20);
},
});
```
```ts
// Good: project to only the fields the client needs
export const list = query({
handler: async (ctx) => {
const articles = await ctx.db.query("articles").take(20);
return articles.map((a) => ({
_id: a._id,
title: a.title,
author: a.author,
createdAt: a._creationTime,
}));
},
});
```
### 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.
```ts
// Bad: unnecessary overhead from ctx.runQuery inside a mutation
export const createProject = mutation({
handler: async (ctx, args) => {
const user = await ctx.runQuery(api.users.getCurrentUser);
await ctx.db.insert("projects", { ...args, ownerId: user._id });
},
});
```
```ts
// Good: plain helper function, no extra overhead
export const createProject = mutation({
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
await ctx.db.insert("projects", { ...args, ownerId: user._id });
},
});
```
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).
```ts
// Bad: runAction overhead for no reason
export const processItems = action({
handler: async (ctx, args) => {
for (const item of args.items) {
await ctx.runAction(internal.items.processOne, { item });
}
},
});
```
```ts
// Good: plain function call
export const processItems = action({
handler: async (ctx, args) => {
for (const item of args.items) {
await processOneItem(ctx, { item });
}
},
});
```
## Verification
1. No function execution or transaction size errors
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
6. Sibling functions with similar patterns were checked
@@ -0,0 +1,369 @@
# 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.
## Contents
- Core Principle
- Consistency Rule
- 1. Push Filters To Storage (indexes, migration rule, redundant indexes)
- 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)
- Verification
## Core Principle
Every byte read or written multiplies with concurrency.
Think:
`cost x calls_per_second x 86400`
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.
Do this especially for:
- multiple list queries over the same table
- multiple writers to the same table
- public browse and search queries over the same records
- helper functions reused by more than one endpoint
## 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.
Prefer:
- `withIndex(...)`
- `.withSearchIndex(...)` for text search
- narrower tables
- summary tables
before accepting a scan-plus-filter pattern.
```ts
// Bad: scans then filters in JavaScript
export const listOpen = query({
args: {},
handler: async (ctx) => {
const tasks = await ctx.db.query("tasks").collect();
return tasks.filter((task) => task.status === "open");
},
});
```
```ts
// Also bad: Convex .filter() does not push to storage either
export const listOpen = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("tasks")
.filter((q) => q.eq(q.field("status"), "open"))
.collect();
},
});
```
```ts
// Good: use an index so storage does the filtering
export const listOpen = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("tasks")
.withIndex("by_status", (q) => q.eq("status", "open"))
.collect();
},
});
```
### Migration rule for indexes
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`.
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`.
```ts
// Bad: optional booleans can miss older rows where the field is undefined
const projects = await ctx.db
.query("projects")
.withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false))
.order("desc")
.take(20);
```
```ts
// Good: switch hot-path reads only after the rollout is migration-safe
// See the migration helper skill for dual-read / backfill / cutover patterns.
```
### 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.
```ts
// Bad: two indexes where one would do
defineTable({ team: v.id("teams"), user: v.id("users") })
.index("by_team", ["team"])
.index("by_team_and_user", ["team", "user"]);
```
```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"],
);
```
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.
### When to denormalize
Denormalize when all of these are true:
- the path is hot
- the joined document is much larger than the field you need
- many readers are paying that join cost repeatedly
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.
### Fallback rule
Denormalized data is an optimization. Live data is the correctness path.
Rules:
- If the denormalized field is missing or null, fall back to the live read
- Do not show placeholders instead of falling back
- In lookup maps, only include fully populated entries
```ts
// Bad: missing denormalized data becomes a placeholder and blocks correctness
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;
```
Bad lookup map pattern:
```ts
const ownersById = {
[project.ownerId]: { ownerName: null },
};
```
That blocks fallback because the map says "I have data" when it does not.
Good lookup map pattern:
```ts
const ownersById =
project.ownerName !== undefined && project.ownerName !== null
? { [project.ownerId]: { ownerName: project.ownerName } }
: {};
```
### No denormalized copy yet
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.
Rollout order:
1. Update schema
2. Update write path
3. Backfill
4. Switch read path
## 3. Minimize Row Size
Hot list pages should read the smallest document shape that still answers the UI.
Prefer summary or digest tables over full source tables when:
- the list page only needs a subset of fields
- 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.
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
```ts
// Bad: list page reads source docs, then joins owner data per row
const projects = await ctx.db
.query("projects")
.withIndex("by_public", (q) => q.eq("isPublic", true))
.collect();
```
```ts
// Good: list page reads the smaller digest shape first
const projects = await ctx.db
.query("projectDigests")
.withIndex("by_public_and_updated", (q) => q.eq("isPublic", true))
.order("desc")
.take(20);
```
## 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.
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.
```ts
// Bad: every presence heartbeat invalidates subscribers to the whole profile
await ctx.db.patch(user._id, {
name: args.name,
avatarUrl: args.avatarUrl,
lastSeen: Date.now(),
});
```
```ts
// Good: keep profile reads stable, move heartbeat updates to a separate document
await ctx.db.patch(user._id, {
name: args.name,
avatarUrl: args.avatarUrl,
});
await ctx.db.patch(presence._id, {
lastSeen: Date.now(),
});
```
## 5. Match Consistency To Read Patterns
Choose read strategy based on traffic shape.
### High-read, low-write
Examples:
- public browse pages
- search results
- landing pages
- directory listings
Prefer:
- point-in-time reads where appropriate
- explicit refresh
- 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.
### High-read, high-write
Examples:
- collaborative editors
- live dashboards
- presence-heavy views
Reactive queries may be worth the ongoing cost.
## Convex-Specific Notes
### Reactive queries
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.
### 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.
Use them for:
- aggregate snapshots
- reports
- low-churn listings
- pages where explicit refresh is fine
### Triggers and fan-out
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:
- diff before patching
- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate
### Aggregates
Reactive global counts invalidate frequently on busy tables.
Prefer:
- one-shot aggregate fetches
- periodic recomputation
- precomputed summary rows
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.
Deploy code that can handle both states before running the backfill.
During the gap:
- writes should populate the new shape
- reads should fall back safely
## Verification
Before closing the audit, confirm:
1. Same results as before, no dropped records
2. The removed table or lookup is no longer in the hot-path read set
3. Tests or validation cover fallback behavior
4. Migration safety is preserved while fields or indexes are unbackfilled
5. Sibling functions were fixed consistently
@@ -0,0 +1,114 @@
# 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.
## 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.
## Symptoms
- OCC conflict errors in deployment logs or health page
- Mutations retrying multiple times before succeeding
- User-visible latency spikes on write-heavy pages
- `npx convex insights --details` showing high conflict rates
## Common Causes
### 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.
### 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.
### 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.
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.
## Fix Order
### 1. Reduce read set size
Narrower reads mean fewer false conflicts.
```ts
// Bad: broad scan creates a wide conflict surface
const allTasks = await ctx.db.query("tasks").collect();
const mine = allTasks.filter((t) => t.ownerId === userId);
```
```ts
// Good: indexed query touches only relevant documents
const mine = await ctx.db
.query("tasks")
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
```
### 2. Split hot documents
When many writers target the same document, split the contention point.
```ts
// Bad: every vote increments the same counter document
const counter = await ctx.db.get(pollCounterId);
await ctx.db.patch(pollCounterId, { count: counter!.count + 1 });
```
```ts
// Good: shard the counter across multiple documents, aggregate on read
const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
const shardId = shardIds[shardIndex];
const shard = await ctx.db.get(shardId);
await ctx.db.patch(shardId, { count: shard!.count + 1 });
```
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.
```ts
// Bad: canonical write and derived work happen in the same transaction
await ctx.db.patch(userId, { name: args.name });
await ctx.db.insert("userUpdateAnalytics", {
userId,
kind: "name_changed",
name: args.name,
});
```
```ts
// Good: keep the primary write small, defer the analytics work
await ctx.db.patch(userId, { name: args.name });
await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, {
userId,
name: args.name,
});
```
### 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.
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.
## Verification
1. OCC conflict rate has dropped in insights or dashboard
2. Mutation latency is lower and more consistent
3. No data correctness regressions from splitting or scheduling changes
4. Sibling writers to the same hot documents were fixed consistently
@@ -0,0 +1,252 @@
# 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.
## 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:
`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.
## Symptoms
- Dashboard shows high active subscription count
- UI feels sluggish or laggy despite fast individual queries
- React profiling shows frequent re-renders from Convex state
- Pages with many components each running their own `useQuery`
- Paginated lists where every loaded page stays subscribed
## Common Causes
### 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.
### 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.
### 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.
### 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.
### 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.
## 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.
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
Possible implementations depend on environment:
- a server-rendered fetch
- a framework helper like `fetchQuery`
- a point-in-time client read such as `ConvexHttpClient.query()`
```ts
// Reactive by default when fresh live data matters
function TeamPresence() {
const presence = useQuery(api.teams.livePresence, { teamId });
return <PresenceList users={presence} />;
}
```
```ts
// Point-in-time read when explicit refresh is acceptable
import { ConvexHttpClient } from "convex/browser";
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
function SnapshotView() {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => {
client.query(api.items.snapshot).then(setItems);
}, []);
return <ItemGrid items={items} />;
}
```
Good candidates for point-in-time reads:
- aggregate snapshots
- reports
- low-churn listings
- flows where explicit refresh is already acceptable
Keep reactive for:
- collaborative editing
- live dashboards
- presence-heavy views
- any surface where users expect fresh changes to appear automatically
### 2. Batch related data into fewer queries
Instead of N components each fetching their own related data, fetch it in a single query.
```ts
// Bad: each card fetches its own author
function ProjectCard({ project }: { project: Project }) {
const author = useQuery(api.users.get, { id: project.authorId });
return <Card title={project.name} author={author?.name} />;
}
```
```ts
// Good: parent query returns projects with author names included
function ProjectList() {
const projects = useQuery(api.projects.listWithAuthors);
return projects?.map((p) => (
<Card key={p._id} title={p.name} author={p.authorName} />
));
}
```
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.
### 3. Use skip to avoid unnecessary subscriptions
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
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",
);
```
### 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.
```ts
// Bad: lastSeen lives on the user doc, every heartbeat invalidates
// every query that reads this user
const users = defineTable({
name: v.string(),
email: v.string(),
lastSeen: v.number(),
});
```
```ts
// Good: lastSeen lives in a separate heartbeat doc
const users = defineTable({
name: v.string(),
email: v.string(),
heartbeatId: v.id("heartbeats"),
});
const heartbeats = defineTable({
lastSeen: v.number(),
});
```
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.
### 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.
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.
### 6. Narrow query read sets
Queries that return less data and touch fewer documents invalidate less often.
```ts
// Bad: returns all fields, invalidates on any field change
export const list = query({
handler: async (ctx) => {
return await ctx.db.query("projects").collect();
},
});
```
```ts
// Good: use a digest table with only the fields the list needs
export const listDigests = query({
handler: async (ctx) => {
return await ctx.db.query("projectDigests").collect();
},
});
```
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.
```ts
// Bad: Date.now() defeats query caching and causes frequent re-evaluation
const releasedPosts = await ctx.db
.query("posts")
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))
.take(100);
```
```ts
// Good: use a boolean field updated by a scheduled function
const releasedPosts = await ctx.db
.query("posts")
.withIndex("by_is_released", (q) => q.eq("isReleased", true))
.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.
### 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
- 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.
Treat this as a UX problem first when:
- the underlying query is already reasonably cheap
- the complaint is flicker, loading flashes, or re-render churn
- live updates are still desirable once fresh data arrives
## Verification
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
5. Sibling pages with similar patterns were updated consistently
+347
View File
@@ -0,0 +1,347 @@
---
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.
---
# Convex Quickstart
Set up a working Convex project as fast as possible.
## When to Use
- Starting a brand new project with Convex
- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app
- Scaffolding a Convex app for prototyping
## 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
## Workflow
1. Determine the starting point: new project or existing app
2. If new project, pick a template and scaffold with `npm create convex@latest`
3. If existing app, install `convex` and wire up the provider
4. Run `npx convex dev` to connect a deployment and start the dev loop
5. Verify the setup works
## 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.
### Pick a template
| Template | Stack |
| -------------------------- | ----------------------------------------- |
| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui |
| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui |
| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui |
| `nextjs-clerk` | Next.js + Clerk auth |
| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui |
| `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.
You can also use any GitHub repo as a template:
```bash
npm create convex@latest my-app -- -t owner/repo
npm create convex@latest my-app -- -t owner/repo#branch
```
### Scaffold the project
Always pass the project name and template flag to avoid interactive prompts:
```bash
npm create convex@latest my-app -- -t react-vite-shadcn
cd my-app
npm install
```
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):
```bash
npm create convex@latest . -- -t react-vite-shadcn
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.
**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:
- 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/`.
**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
The user should also run the frontend dev server in a separate terminal:
```bash
npm run dev
```
Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`.
### What you get
After scaffolding, the project structure looks like:
```
my-app/
convex/ # Backend functions and schema
_generated/ # Auto-generated types (check this into git)
schema.ts # Database schema (if template includes one)
src/ # Frontend code (or app/ for Next.js)
package.json
.env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL
```
The template already has:
- `ConvexProvider` wired into the app root
- Correct env var names for the framework
- Tailwind and shadcn/ui ready (for shadcn templates)
- Auth provider configured (for auth templates)
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.
### Install
```bash
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.
### Wire up the provider
The Convex client must wrap the app at the root. The setup varies by framework.
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,
);
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
// Good: created once at module scope
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
function App() {
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
```
#### React (Vite)
```tsx
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import App from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</StrictMode>,
);
```
#### Next.js (App Router)
```tsx
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
```
```tsx
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
```
#### Other frameworks
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)
- [React Native](https://docs.convex.dev/quickstart/react-native)
- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start)
- [Remix](https://docs.convex.dev/quickstart/remix)
- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs)
### Environment variables
The env var name depends on the framework:
| Framework | Variable |
| ------------ | ------------------------ |
| Vite | `VITE_CONVEX_URL` |
| Next.js | `NEXT_PUBLIC_CONVEX_URL` |
| Remix | `CONVEX_URL` |
| React Native | `EXPO_PUBLIC_CONVEX_URL` |
`npx convex dev` writes the correct variable to `.env.local` automatically.
## 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.
Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline:
```bash
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.
## Verify the Setup
After setup, confirm everything is working:
1. The user confirms `npx convex dev` is running without errors
2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts`
3. `.env.local` contains the deployment URL
## Writing Your First Function
Once the project is set up, create a schema and a query to verify the full loop works.
`convex/schema.ts`:
```ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
completed: v.boolean(),
}),
});
```
`convex/tasks.ts`:
```ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
export const create = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", { text: args.text, completed: false });
},
});
```
Use in a React component (adjust the import path based on your file location relative to `convex/`):
```tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function Tasks() {
const tasks = useQuery(api.tasks.list);
const create = useMutation(api.tasks.create);
return (
<div>
<button onClick={() => create({ text: "New task" })}>Add</button>
{tasks?.map((t) => (
<div key={t._id}>{t.text}</div>
))}
</div>
);
}
```
## Development vs Production
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:
```bash
npx convex deploy
```
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)
- 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)
- 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 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
- [ ] `.env.local` has the deployment URL
- [ ] Verified a basic query/mutation round-trip works
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Quickstart"
short_description: "Start a new Convex app or add Convex to an existing frontend."
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."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+150
View File
@@ -0,0 +1,150 @@
---
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.
---
# Convex Authentication Setup
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)
## 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
## First Step: Choose the Auth Provider
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
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
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
- 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`
The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks.
Use those sources for:
- package installation
- client provider wiring
- environment variables
- `convex/auth.config.ts` setup
- login and logout UI patterns
- framework-specific setup for React, Vite, or Next.js
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
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
The most common auth task is checking identity in Convex functions.
```ts
// Bad: trusting a client-provided userId
export const getMyProfile = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
```
```ts
// Good: verifying identity server-side
export const getMyProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
},
});
```
## Workflow
1. Determine the provider, either by asking the user or inferring from the repo
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
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
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
### Provider References
- `references/convex-auth.md`
- `references/clerk.md`
- `references/workos-authkit.md`
- `references/auth0.md`
## Checklist
- [ ] Chosen the correct auth provider before writing setup code
- [ ] 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
- [ ] 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
- [ ] Added authentication checks in protected backend functions
- [ ] Added authorization checks where the app actually needs them
- [ ] Clear error messages ("Not authenticated", "Unauthorized")
- [ ] Client auth provider configured for the chosen provider
- [ ] If requested, production auth setup is covered too
@@ -0,0 +1,10 @@
interface:
display_name: "Convex Setup Auth"
short_description: "Set up Convex auth, user identity mapping, and access control."
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."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

@@ -0,0 +1,116 @@
# Auth0
Official docs:
- https://docs.convex.dev/auth/auth0
- https://auth0.github.io/auth0-cli/
- https://auth0.github.io/auth0-cli/auth0_apps_create.html
Use this when the app already uses Auth0 or the user wants Auth0 specifically.
## Workflow
1. Confirm the user wants Auth0
2. Determine the app framework and whether Auth0 is already partly set up
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
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
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
## 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
- 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
## Key Setup Areas
- install the Auth0 SDK for the app's framework
- configure `convex/auth.config.ts` with the Auth0 domain and client ID
- set environment variables for local and production environments
- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
- use Convex auth state when gating Convex-backed UI
## Files and Env Vars To Expect
- `convex/auth.config.ts`
- frontend app entry or provider wrapper
- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/`
- Auth0 environment variables commonly include:
- `AUTH0_DOMAIN`
- `AUTH0_CLIENT_ID`
- `VITE_AUTH0_DOMAIN`
- `VITE_AUTH0_CLIENT_ID`
## Concrete Steps
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
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
9. Set frontend and backend environment variables
10. Wrap the app in `Auth0Provider`
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
## 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
## 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.
## Validation
- Verify the user can complete the Auth0 login flow
- 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
## Checklist
- [ ] Confirm the user wants Auth0
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] 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
- [ ] If requested, configure the production deployment too
@@ -0,0 +1,113 @@
# Clerk
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.
## Workflow
1. Confirm the user wants Clerk
2. Make sure the user has a Clerk account and a Clerk application
3. Determine the app framework:
- React
- Next.js
- TanStack Start
4. Ask whether the user wants local-only setup or production-ready setup now
5. Gather the Clerk keys and the Clerk Frontend API URL
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
## 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`
## Key Setup Areas
- install the Clerk SDK for the framework in use
- 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`
## Files and Env Vars To Expect
- `convex/auth.config.ts`
- React or Vite client entry such as `src/main.tsx`
- Next.js client wrapper for Convex if using App Router
- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up`
- Clerk app creation page: `https://dashboard.clerk.com/apps/new`
- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex`
- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys`
- Clerk environment variables:
- `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs
- `CLERK_FRONTEND_API_URL` in the Clerk docs
- `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps
- `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.
## 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
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
7. Install the Clerk package for the app's framework
8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens
9. Set the publishable key in the frontend environment
10. Set the issuer domain or Frontend API URL so Convex can validate the JWT
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk`
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
## 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.
## 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.
## 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
- 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
## Checklist
- [ ] Confirm the user wants Clerk
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Follow the correct framework section in the official guide
- [ ] Set Clerk environment variables
- [ ] Configure `convex/auth.config.ts`
- [ ] Verify Convex authenticated state after login
- [ ] If requested, configure the production deployment too
@@ -0,0 +1,143 @@
# Convex Auth
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.
## Workflow
1. Confirm the user wants Convex Auth specifically
2. Determine which sign-in methods the app needs:
- magic links or OTPs
- OAuth providers
- passwords and password reset
3. Ask whether the user wants local-only setup or production-ready setup now
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
6. Install the auth packages:
- `npm install @convex-dev/auth @auth/core@0.37.0`
7. Run the initialization command:
- `npx @convex-dev/auth`
8. Confirm the initializer created:
- `convex/auth.config.ts`
- `convex/auth.ts`
- `convex/http.ts`
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
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
## 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
## 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
## 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
4. Run `npx @convex-dev/auth`
5. Confirm the generated auth setup is present before continuing:
- `convex/auth.config.ts`
- `convex/auth.ts`
- `convex/http.ts`
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
10. Verify login locally
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
- 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
- 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
## 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.
## 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.
## Human Handoff
If `npx convex dev` or deployment setup requires human input:
- stop and explain exactly what the user needs to do
- say why that step is required
- resume the auth setup immediately after the user confirms it is done
## 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
- 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
## 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
- [ ] 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
- [ ] 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
- [ ] 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
@@ -0,0 +1,114 @@
# WorkOS AuthKit
Official docs:
- https://docs.convex.dev/auth/authkit/
- 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.
## Workflow
1. Confirm the user wants WorkOS AuthKit
2. Determine whether they want:
- a Convex-managed WorkOS team
- an existing WorkOS team
3. Ask whether the user wants local-only setup or production-ready setup now
4. Read the official Convex and WorkOS AuthKit guide
5. Create or update `convex.json` for the app's framework and real local port
6. Follow the correct branch of the setup flow based on that choice
7. Configure the required WorkOS environment variables
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
## 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
## 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/auth.config.ts` wiring for WorkOS-issued JWTs
- client provider setup and token flow into Convex
- login callback and redirect configuration
## Files and Env Vars To Expect
- `convex.json`
- `convex/auth.config.ts`
- frontend auth provider wiring
- callback or redirect route setup where the framework requires it
- WorkOS environment variables commonly include:
- `WORKOS_CLIENT_ID`
- `WORKOS_API_KEY`
- `WORKOS_COOKIE_PASSWORD`
- `VITE_WORKOS_CLIENT_ID`
- `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.
## 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`
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
## 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
- 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."
## 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.
## Validation
- Verify the user can complete the login flow and return to the app
- Verify the callback URL matches the real frontend port in local dev
- 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
## Checklist
- [ ] Confirm the user wants WorkOS AuthKit
- [ ] Ask whether the user wants local-only setup or production-ready setup
- [ ] Choose Convex-managed or existing WorkOS team
- [ ] Create or update `convex.json`
- [ ] Configure WorkOS environment variables
- [ ] Configure `convex/auth.config.ts`
- [ ] Verify authenticated requests reach Convex after login
- [ ] If requested, configure the production deployment too
+47
View File
@@ -0,0 +1,47 @@
---
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.
---
# Convex
Use this as the routing skill for Convex work in this repo.
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.
Preferred:
```bash
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:
- [Convex AI docs](https://docs.convex.dev/ai)
Simple fallback:
- [convex_rules.txt](https://convex.link/convex_rules.txt)
Prefer `npx convex ai-files install` over copying rules by hand when possible.
## Route to the Right Skill
After that, use the most specific Convex skill for the task:
- New project or adding Convex to an app: `convex-quickstart`
- Authentication setup: `convex-setup-auth`
- Building a reusable Convex component: `convex-create-component`
- 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.
## When Not to Use
- The user has already named a more specific Convex workflow
- Another Convex skill obviously fits the request better
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+12
View File
@@ -0,0 +1,12 @@
* text=auto eol=lf
*.avif binary
*.gif binary
*.ico binary
*.jpg binary
*.jpeg binary
*.png binary
*.webp binary
*.woff binary
*.woff2 binary
+113
View File
@@ -0,0 +1,113 @@
# Protect the ownership rules themselves.
/.github/CODEOWNERS @openclaw/openclaw-secops
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
# 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/codeql/ @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
/scripts/clawhub-cli-npm-release-check.mjs @openclaw/openclaw-secops
/scripts/github/clawhub-rescan-auto-response.mjs @openclaw/openclaw-secops
# Backend auth, API, publish, upload, moderation, and scan enforcement.
/convex/schema.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/auth.config.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/auth.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/commentModeration.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/http.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/httpApi.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/httpApiV1/ @openclaw/openclaw-secops @Patrick-Erichsen
/convex/packagePublishTokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/packages.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
/convex/uploads.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/vt.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/webhooks.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/access.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/apiTokenAuth.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/commentScamPrompt.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/githubActionsOidc.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/httpHeaders.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/httpRateLimit.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/manualOverrides.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/moderation.ts @openclaw/openclaw-secops @Patrick-Erichsen
/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/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
/convex/lib/reporting.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/securityPrompt.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/skillCapabilityTags.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/skillPublish.ts @openclaw/openclaw-secops @Patrick-Erichsen
/convex/lib/skillSafety.ts @openclaw/openclaw-secops @Patrick-Erichsen
/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
# CLI auth, admin, publishing, ownership, and package-contract surfaces.
/packages/clawhub/src/browserAuth.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/http.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/adminHelp.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/authToken.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/clawdbotConfig.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/auth.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/delete.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/github.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/cli/commands/moderation.ts @openclaw/openclaw-secops @Patrick-Erichsen
/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
/packages/clawhub/src/schema/openclawContract.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/schema/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
/packages/clawhub/src/schema/routes.ts @openclaw/openclaw-secops @Patrick-Erichsen
/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/packages.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
# 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
/public/api/v1/openapi.json @openclaw/openclaw-secops @Patrick-Erichsen
+10
View File
@@ -0,0 +1,10 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
@@ -0,0 +1,16 @@
name: clawhub-codeql-actions-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
paths:
- .github/workflows
@@ -0,0 +1,76 @@
name: clawhub-codeql-backend-api-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- convex/auth.config.ts
- convex/auth.ts
- convex/commentModeration.ts
- convex/http.ts
- convex/httpApi.ts
- convex/httpApiV1
- convex/packagePublishTokens.ts
- convex/packages.ts
- convex/publishers.ts
- convex/rateLimits.ts
- convex/rescanRequests.ts
- convex/skills.ts
- convex/skillTransfers.ts
- convex/tokens.ts
- convex/uploads.ts
- convex/vt.ts
- convex/webhooks.ts
- convex/lib/access.ts
- convex/lib/apiTokenAuth.ts
- convex/lib/commentScamPrompt.ts
- convex/lib/githubActionsOidc.ts
- convex/lib/httpHeaders.ts
- convex/lib/httpRateLimit.ts
- convex/lib/httpUtils.ts
- convex/lib/manualOverrides.ts
- convex/lib/moderation.ts
- convex/lib/moderationEngine.ts
- convex/lib/moderationReasonCodes.ts
- convex/lib/packageRegistry.ts
- convex/lib/packageSecurity.ts
- convex/lib/publishers.ts
- convex/lib/publishLimits.ts
- convex/lib/reporting.ts
- convex/lib/securityPrompt.ts
- convex/lib/skillPublish.ts
- convex/lib/skillSafety.ts
- 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"
- "**/coverage"
- "**/dist"
- "**/dist/**"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"
- "convex/_generated/**"
@@ -0,0 +1,59 @@
name: clawhub-codeql-cli-package-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- packages/clawhub/src/browserAuth.ts
- packages/clawhub/src/http.ts
- packages/clawhub/src/cli/adminHelp.ts
- packages/clawhub/src/cli/authToken.ts
- packages/clawhub/src/cli/clawdbotConfig.ts
- packages/clawhub/src/cli/commands/auth.ts
- packages/clawhub/src/cli/commands/delete.ts
- packages/clawhub/src/cli/commands/github.ts
- packages/clawhub/src/cli/commands/moderation.ts
- 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
- packages/clawhub/src/schema/openclawContract.ts
- packages/clawhub/src/schema/packages.ts
- packages/clawhub/src/schema/routes.ts
- packages/clawhub/src/schema/schemas.ts
- packages/clawhub/src/schema/textFiles.ts
- packages/schema/src/openclawContract.ts
- packages/schema/src/packages.ts
- packages/schema/src/routes.ts
- packages/schema/src/schemas.ts
- packages/schema/src/textFiles.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/dist"
- "**/dist/**"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"
@@ -0,0 +1,57 @@
name: clawhub-codeql-frontend-publish-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/components/DetailSecuritySummary.tsx
- src/components/MarkdownPreview.tsx
- src/components/PackageSourceChooser.tsx
- src/components/SecurityScannerPage.tsx
- src/components/SkillSecurityScanResults.tsx
- src/lib/authErrorMessage.ts
- src/lib/packageApi.ts
- src/lib/packageUpload.ts
- src/lib/pluginPublishPrefill.ts
- src/lib/rehypeProxyImages.ts
- src/lib/roles.ts
- src/lib/uploadFiles.ts
- src/lib/uploadUtils.ts
- src/lib/useAuthError.ts
- src/lib/useAuthStatus.ts
- src/routes/admin.tsx
- src/routes/cli/auth.tsx
- src/routes/packages/new.tsx
- src/routes/publish-plugin.tsx
- src/routes/publish-skill.tsx
- src/routes/upload.tsx
- src/routes/upload
- src/routes/$owner/$slug/security
- src/routes/plugins/$name/security
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/dist"
- "**/dist/**"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"
@@ -0,0 +1,39 @@
name: clawhub-codeql-repository-automation-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- scripts/check-staged-secrets.mjs
- scripts/clawhub-cli-npm-release-check.mjs
- scripts/github
- scripts/verify-convex-contract.ts
- scripts/copy-og-assets.ts
- scripts/check-peer-deps.ts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/dist"
- "**/dist/**"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"
+41
View File
@@ -0,0 +1,41 @@
version: 2
updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/Los_Angeles"
open-pull-requests-limit: 10
ignore:
- dependency-name: "@auth/core"
update-types:
- "version-update:semver-minor"
- "version-update:semver-major"
- dependency-name: "undici"
update-types:
- "version-update:semver-major"
groups:
production-minor-and-patch:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development-minor-and-patch:
dependency-type: "development"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/Los_Angeles"
groups:
github-actions:
patterns:
- "*"
+59
View File
@@ -0,0 +1,59 @@
name: Auto response
on:
issues:
types: [opened, edited, labeled]
issue_comment:
types: [created]
pull_request_target: # zizmor: ignore[dangerous-triggers] trusted base checkout only; no untrusted PR code execution
types: [opened, edited, synchronize, reopened, labeled]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
permissions: {}
jobs:
auto-response:
permissions:
contents: read
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
persist-credentials: false
- 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: Run Barnacle auto-response
uses: actions/github-script@v9
with:
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
script: |
const { pathToFileURL } = require("node:url");
const moduleUrl = pathToFileURL(
`${process.env.GITHUB_WORKSPACE}/scripts/github/barnacle-auto-response.mjs`,
);
const { runBarnacleAutoResponse } = await import(moduleUrl.href);
await runBarnacleAutoResponse({ github, context, core });
+80
View File
@@ -0,0 +1,80 @@
name: Blacksmith Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
permissions:
contents: read
env:
BUN_VERSION: "1.3.10"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
check:
name: "check"
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
with:
testbox_id: ${{ inputs.testbox_id }}
- uses: actions/checkout@v6
with:
fetch-depth: 50
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Restore Bun install cache
id: bun-cache
uses: actions/cache/restore@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-${{ env.BUN_VERSION }}-
- name: Install
run: bun install --frozen-lockfile
- name: Save Bun install cache
if: steps.bun-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ~/.bun/install/cache
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
- name: Prepare Testbox shell
shell: bash
run: |
set -euo pipefail
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
bun_bin="$(command -v bun)"
sudo ln -sf "$bun_bin" /usr/local/bin/bun
if command -v bunx >/dev/null 2>&1; then
sudo ln -sf "$(command -v bunx)" /usr/local/bin/bunx
fi
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+26 -1
View File
@@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -12,8 +15,10 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
@@ -21,6 +26,26 @@ jobs:
run: bun install --frozen-lockfile
- name: Peer deps
run: bun run check:peers
- name: Audit dependencies
run: bun audit
- 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'
)
if (( ${#changed_files[@]} == 0 )); then
echo "No changed files supported by oxfmt."
exit 0
fi
bun run format:check -- "${changed_files[@]}"
- name: Lint
run: bun run lint
@@ -52,7 +52,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Setup Bun
uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ env.BUN_VERSION }}
@@ -233,6 +233,7 @@ jobs:
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
# shellcheck disable=SC2016
printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "ClawHub CLI NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });'
- name: Download prepared npm tarball
@@ -0,0 +1,57 @@
name: ClawHub Rescan Guidance
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to check"
required: true
type: string
permissions:
contents: read
issues: write
concurrency:
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
cancel-in-progress: false
jobs:
rescan-guidance:
runs-on: ubuntu-latest
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
env:
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
persist-credentials: false
- 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: Comment when rescan guidance label is present
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
run: |
node scripts/github/clawhub-rescan-auto-response.mjs \
--repo "$GITHUB_REPOSITORY" \
--issue "$ISSUE_NUMBER" \
--comment-for-labeled-issue \
--apply
@@ -0,0 +1,62 @@
name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
permissions:
contents: read
concurrency:
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
jobs:
dispatch:
runs-on: ubuntu-latest
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
env:
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
steps:
- name: Debounce bursty metadata events
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
run: sleep 20
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
- name: Dispatch exact ClawSweeper review
env:
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
SOURCE_EVENT: ${{ github.event_name }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--arg item_kind "$ITEM_KIND" \
--arg source_event "$SOURCE_EVENT" \
--arg source_action "$SOURCE_ACTION" \
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
+100
View File
@@ -0,0 +1,100 @@
name: CodeQL Light
on:
workflow_dispatch:
inputs:
profile:
description: CodeQL light profile to run
required: false
default: all
type: choice
options:
- all
- backend-api
- frontend-publish
- cli-package
- repository-automation
- actions
push:
branches: [main]
paths:
- ".github/codeql/**"
- ".github/workflows/**"
- "convex/**"
- "packages/clawhub/**"
- "packages/schema/**"
- "scripts/**"
- "src/**"
- "bun.lock"
- "package.json"
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/codeql/**"
- ".github/workflows/**"
- "convex/**"
- "packages/clawhub/**"
- "packages/schema/**"
- "scripts/**"
- "src/**"
- "bun.lock"
- "package.json"
schedule:
- cron: "17 7 * * *"
concurrency:
group: codeql-light-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze (${{ matrix.category }})
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
category: backend-api
config_file: ./.github/codeql/codeql-backend-api-security.yml
- language: javascript-typescript
category: frontend-publish
config_file: ./.github/codeql/codeql-frontend-publish-security.yml
- language: javascript-typescript
category: cli-package
config_file: ./.github/codeql/codeql-cli-package-security.yml
- language: javascript-typescript
category: repository-automation
config_file: ./.github/codeql/codeql-repository-automation-security.yml
- language: actions
category: actions
config_file: ./.github/codeql/codeql-actions-security.yml
steps:
- name: Checkout
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Initialize CodeQL
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # 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
with:
category: "/codeql-light/${{ matrix.category }}"
+15 -7
View File
@@ -17,6 +17,10 @@ concurrency:
group: deploy-production
cancel-in-progress: true
permissions:
contents: read
statuses: read
jobs:
validate-deploy-request:
runs-on: ubuntu-latest
@@ -97,7 +101,7 @@ jobs:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
@@ -133,7 +137,7 @@ jobs:
if ! state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | .state' \
2>/dev/null | head -n1)"; then
echo "GitHub status check failed for $GITHUB_SHA; retrying..."
echo "GitHub status check failed for $GITHUB_SHA on attempt $attempt; retrying..."
sleep 10
continue
fi
@@ -148,10 +152,10 @@ jobs:
exit 1
;;
pending)
echo "Vercel deployment pending for $GITHUB_SHA; waiting..."
echo "Vercel deployment pending for $GITHUB_SHA on attempt $attempt; waiting..."
;;
*)
echo "Vercel status for $GITHUB_SHA not published yet; waiting..."
echo "Vercel status for $GITHUB_SHA not published yet on attempt $attempt; waiting..."
;;
esac
@@ -163,7 +167,11 @@ jobs:
- name: Install Playwright browser
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
run: bunx playwright install --with-deps chromium
run: bunx playwright install --with-deps chromium webkit
- name: Smoke test production HTTP
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
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 != ''
@@ -171,6 +179,6 @@ jobs:
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
- 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/upload-auth-smoke.pw.test.ts
run: bunx playwright test e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
+5 -2
View File
@@ -73,6 +73,9 @@ on:
description: Published release id when dry_run is false.
value: ${{ jobs.publish.outputs.release_id }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: ubuntu-latest
@@ -88,7 +91,7 @@ jobs:
with:
ref: ${{ github.sha }}
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
@@ -334,7 +337,7 @@ jobs:
PY
- name: Upload publish JSON artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: clawhub-package-publish-json
path: ${{ runner.temp }}/package-publish.json
+1 -1
View File
@@ -49,7 +49,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.93.8
uses: trufflesecurity/trufflehog@v3.95.2
with:
path: ./
base: ${{ steps.scan_range.outputs.base }}
+171
View File
@@ -0,0 +1,171 @@
name: Stale
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions: {}
jobs:
stale:
permissions:
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Mark stale unassigned issues and pull requests
uses: actions/stale@v10
with:
repo-token: ${{ github.token }}
days-before-issue-stale: 14
days-before-issue-close: 7
days-before-pr-stale: 7
days-before-pr-close: 5
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 1000
ascending: true
exempt-all-assignees: true
remove-stale-when-updated: true
stale-issue-message: |
This issue has been automatically marked as stale due to inactivity.
Please add updated ClawHub details or it will be closed.
stale-pr-message: |
This pull request has been automatically marked as stale due to inactivity.
Please update it or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this still affects ClawHub, reopen or file a new issue with the current URL, skill/package name, and fresh reproduction details.
close-issue-reason: not_planned
close-pr-message: |
Closing due to inactivity.
If this PR should be revived, reopen it with current context and a fresh validation plan.
- name: Mark stale assigned issues
uses: actions/stale@v10
with:
repo-token: ${{ github.token }}
days-before-issue-stale: 30
days-before-issue-close: 10
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
operations-per-run: 1000
ascending: true
include-only-assigned: true
remove-stale-when-updated: true
stale-issue-message: |
This assigned issue has been automatically marked as stale after 30 days of inactivity.
Please add an update or it will be closed.
close-issue-message: |
Closing due to inactivity.
If this still affects ClawHub, reopen or file a new issue with current evidence.
close-issue-reason: not_planned
- name: Mark stale assigned pull requests
uses: actions/stale@v10
with:
repo-token: ${{ github.token }}
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 27
days-before-pr-close: 5
stale-pr-label: stale
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 1000
ascending: true
include-only-assigned: true
ignore-pr-updates: true
remove-stale-when-updated: true
stale-pr-message: |
This assigned pull request has been automatically marked as stale after being open for 27 days.
Please add an update or it will be closed.
close-pr-message: |
Closing due to inactivity.
If this PR should be revived, reopen it with current context and a fresh validation plan.
lock-closed-issues:
permissions:
issues: write
runs-on: ubuntu-latest
steps:
- name: Lock closed issues after 48h of no comments
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const lockAfterHours = 48;
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
const cutoffMs = Date.now() - lockAfterMs;
const { owner, repo } = context.repo;
let locked = 0;
let inspected = 0;
let page = 1;
while (true) {
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: "closed",
sort: "updated",
direction: "desc",
per_page: 100,
page,
});
if (issues.length === 0) {
break;
}
for (const issue of issues) {
if (issue.pull_request || issue.locked || !issue.closed_at) {
continue;
}
inspected += 1;
const closedAtMs = Date.parse(issue.closed_at);
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
continue;
}
let lastCommentMs = 0;
if (issue.comments > 0) {
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 1,
page: 1,
sort: "created",
direction: "desc",
});
if (comments.length > 0) {
lastCommentMs = Date.parse(comments[0].created_at);
}
}
if (Math.max(closedAtMs, lastCommentMs || 0) > cutoffMs) {
continue;
}
await github.rest.issues.lock({
owner,
repo,
issue_number: issue.number,
lock_reason: "resolved",
});
locked += 1;
}
page += 1;
}
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
@@ -0,0 +1,104 @@
name: Update Convex AI Files
on:
schedule:
# Midnight Pacific during daylight saving time. GitHub cron uses UTC.
- cron: "0 7 * * 1"
workflow_dispatch:
concurrency:
group: update-convex-ai-files
cancel-in-progress: false
env:
BUN_VERSION: "1.3.10"
UPDATE_BRANCH: automation/update-convex-ai-files
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Update Convex AI files
run: |
"$(bun pm bin)/convex" ai-files update
- name: Check Convex AI files status
run: |
"$(bun pm bin)/convex" ai-files status
- name: Detect changes
id: changes
run: |
set -euo pipefail
if [[ -n "$(git status --porcelain)" ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Commit and push update branch
if: steps.changes.outputs.changed == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$UPDATE_BRANCH"
git add AGENTS.md CLAUDE.md .agents/skills convex/_generated/ai/guidelines.md convex/_generated/ai/ai-files.state.json
git commit -m "chore: update Convex AI files"
git push --force-with-lease origin "$UPDATE_BRANCH"
- name: Open or update pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
body_file="$(mktemp)"
{
printf '%s\n' '## Summary'
printf '\n'
printf '%s\n' '- refresh Convex-managed AI guidance files'
printf '%s\n' '- keep AGENTS.md / CLAUDE.md Convex sections in sync when Convex updates them'
printf '%s\n' '- update repo-local Convex developer skills under .agents/skills'
printf '\n'
printf '%s\n' '## Validation'
printf '\n'
# shellcheck disable=SC2016
printf '%s\n' '- `$(bun pm bin)/convex ai-files status`'
} > "$body_file"
if gh pr view "$UPDATE_BRANCH" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh pr edit "$UPDATE_BRANCH" \
--repo "$GITHUB_REPOSITORY" \
--title "[automation] Update Convex AI files" \
--body-file "$body_file"
else
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base main \
--head "$UPDATE_BRANCH" \
--title "[automation] Update Convex AI files" \
--body-file "$body_file"
fi
+18 -3
View File
@@ -2,6 +2,7 @@ node_modules
.DS_Store
.bun-build
*.bun-build
.data/
bin/docs-list
dist
dist-ssr
@@ -10,7 +11,9 @@ dist-ssr
*.local
.vercel
count.txt
.env
.env*
!.env.local.example
!.env.example
.nitro
.tanstack
.wrangler
@@ -21,10 +24,22 @@ todos.json
.vscode
.env*.local
coverage
eval/cache/
eval/results/
playwright-report
test-results
.playwright
convex/_generated/
convex/_generated/*
!convex/_generated/ai/
convex/_generated/ai/*
!convex/_generated/ai/guidelines.md
!convex/_generated/ai/ai-files.state.json
skills-lock.json
*/skills/*
skills/*
!.agents/skills/
!.agents/skills/convex*/
!.agents/skills/convex*/**
!.agents/skills/blacksmith-testbox/
!.agents/skills/blacksmith-testbox/**
skills/*
.codex/*
+36 -35
View File
@@ -1,37 +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-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/"
]
}
+25
View File
@@ -15,6 +15,7 @@
- `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%.
@@ -37,10 +38,12 @@
- 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.
- 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.
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
- Repo-local Convex developer skills under `.agents/skills/convex*/` are allowed when they support working on this codebase; keep top-level `skills/` reserved for installed/published skill content and ignored by git.
## Production Release
@@ -89,9 +92,31 @@
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- 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 -->
## Stat Field Migration Rules
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
| ------------------------------ | -------------------------------------- |
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
+27
View File
@@ -2,9 +2,36 @@
## Unreleased
### Fixes
- Moderation: calibrate VirusTotal Code Insight suspicious verdicts so uncorroborated AI-only findings do not keep otherwise clean skills quarantined (#1830, #1841) (thanks @deepujain).
## 0.11.0 - 2026-04-28
### Changed
- Docs: clarify that ClawHub does not support paid skills, per-skill pricing, or paywalled releases (#1752, #1844) (thanks @deepujain).
- API docs: clarify how third-party directories can reuse public ClawHub catalog endpoints while respecting rate limits and canonical links (#1825, #1845) (thanks @deepujain).
- Packages docs: document the required fields for code-plugin package publish flows (#1802) (thanks @deepujain).
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
### Fixes
- Packages: use the configured `GITHUB_TOKEN` for trusted-publisher repository identity lookups to avoid anonymous GitHub API rate limits during publish setup (#1820, #1846) (thanks @deepujain).
- Packages: keep package search fallback scans bounded, stop scanning after the requested result limit, and keep direct plugin-name matches scoped to the requested package family (OpenClaw #64025).
- Moderation: stop flagging declared env vars sent to their intended API while preserving broad env scraping and exfiltration findings (#1803) (thanks @deepujain).
- Moderation: stop treating generic webhook integration docs as suspicious unless they include explicit Discord or Slack webhook endpoints (#1716) (thanks @langningchen-openclaw).
- Search: increase initial vector candidate pools and align CLI search's default limit with the web UI so high-scoring matches are not missed at small limits (#1375, #1429) (thanks @tjefferson).
- Search: fall back to lexical skill search when embedding generation fails instead of returning empty skill results (#1291) (thanks @goulonghui).
- Search: rank exact slug matches above longer slugs that merely contain all query tokens (#1130) (thanks @QuinnH496).
- Search: widen lexical fallback coverage and scan recently created skills so newly published skills can be found before embeddings rank well (#1185, #1200) (thanks @thirumaleshp).
- Search: preserve vector scores across candidate expansion and require all query tokens to match exact-token filters so relevant skills are not crowded out (#1759, #1762) (thanks @LinPower).
- Stats maintenance: keep skill stat migration fields synchronized by treating top-level stat fields as canonical during backfill/reconcile fallback reads (#1704) (thanks @momothemage).
- Skill install: render OpenClaw CLI commands with the bare slug that the current CLI accepts (#1807).
- Skills: keep historical tags out of public skill detail surfaces while preserving manager visibility (#1804) (thanks @deepujain).
- Skills moderation: keep hash-based scanner callbacks from overwriting skill-level moderation for older versions (#1805) (thanks @deepujain).
- Skills: prevent backport publishes from clobbering `latest` state and guard malformed persisted latest semver values during publish comparisons (#1832) (thanks @momothemage).
## 0.10.0 - 2026-04-05
+25 -1
View File
@@ -87,7 +87,7 @@ bunx convex run --no-push devSeed:seedNixSkills
bunx convex run --no-push devSeedExtra:seedExtraSkillsInternal
# Refresh the cached skills count (required after seeding)
bunx convex run --no-push statsMaintenance:updateGlobalStatsInternal
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
```
To reset and re-seed:
@@ -153,6 +153,30 @@ bun run --cwd packages/clawhub verify
These are the same checks that run in CI (`.github/workflows/ci.yml`).
### Blacksmith Testbox checks
Maintainers with Blacksmith access can run the same checks in a warmed Testbox
instead of spending local CPU:
```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
```
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 `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
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:**
- Keep PRs focused — one concern per PR.
+2 -1
View File
@@ -10,7 +10,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
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.
@@ -63,6 +63,7 @@ Common CLI flows:
- Inspect without installing: `clawhub inspect <slug>`
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
- Publish plugins: `clawhub package publish <source>`
- Code-plugin manifests must include `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion`; see [`docs/cli.md`](docs/cli.md) for a minimal example.
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
+286 -391
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+14
View File
@@ -0,0 +1,14 @@
{
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
"agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
"agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d",
"installedSkillNames": [
"convex",
"convex-create-component",
"convex-migration-helper",
"convex-performance-audit",
"convex-quickstart",
"convex-setup-auth"
]
}
+365
View File
@@ -0,0 +1,365 @@
# Convex guidelines
## Function guidelines
### Http endpoint syntax
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/echo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
```
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
### Validators
- Below is an example of an array validator:
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export default mutation({
args: {
simpleArray: v.array(v.union(v.string(), v.number())),
},
handler: async (ctx, args) => {
//...
},
});
```
- Below is an example of a schema with validators that codify a discriminated union type:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
results: defineTable(
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
),
),
});
```
- Here are the valid Convex types along with their respective validators:
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Id | string | `doc._id` | `v.id(tableName)` | |
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
| Boolean | boolean | `true` | `v.boolean()` |
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
### Function registration
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
- You CANNOT register a function through the `api` or `internal` objects.
- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`.
### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
- Use `ctx.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an action.
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
```
export const f = query({
args: { name: v.string() },
handler: async (ctx, args) => {
return "Hello " + args.name;
},
});
export const g = query({
args: {},
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
return null;
},
});
```
### Function references
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
### Pagination
- Define pagination using the following syntax:
```ts
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", args.author))
.order("desc")
.paginate(args.paginationOpts);
},
});
```
Note: `paginationOpts` is an object with the following properties:
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
- A query that ends in `.paginate()` returns an object that has the following properties:
- page (contains an array of documents that you fetches)
- isDone (a boolean that represents whether or not this is the last page of documents)
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
## Schema guidelines
- Always define your schema in `convex/schema.ts`.
- Always import the schema definition functions from `convex/server`.
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent.
- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record.
## Authentication guidelines
- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`.
- Example `convex/auth.config.ts`:
```typescript
export default {
providers: [
{
domain: "https://your-auth-provider.com",
applicationID: "convex",
},
],
};
```
The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim.
- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier.
- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key.
- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`.
- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`:
```tsx
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function App({ children }: { children: React.ReactNode }) {
return (
<ConvexProviderWithAuth client={convex} useAuth={useYourAuthHook}>
{children}
</ConvexProviderWithAuth>
);
}
```
The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests.
## Typescript guidelines
- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table.
- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type.
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
```ts
import { query } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
export const exampleQuery = query({
args: { userIds: v.array(v.id("users")) },
handler: async (ctx, args) => {
const idToUsername: Record<Id<"users">, string> = {};
for (const userId of args.userIds) {
const user = await ctx.db.get("users", userId);
if (user) {
idToUsername[user._id] = user.username;
}
}
return idToUsername;
},
});
```
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
## Full text search guidelines
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
const messages = await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) =>
q.search("body", "hello hi").eq("channel", "#general"),
)
.take(10);
## Query guidelines
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way.
- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations.
- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned.
- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits.
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
### Ordering
- By default Convex always returns documents in ascending `_creationTime` order.
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
## Mutation guidelines
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })`
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })`
## Action guidelines
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.
- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`.
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
- Below is an example of the syntax for an action:
```ts
import { action } from "./_generated/server";
export const exampleAction = action({
args: {},
handler: async (ctx, args) => {
console.log("This action does not return anything");
return null;
},
});
```
## Scheduling guidelines
### Cron guidelines
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
```ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
import { internalAction } from "./_generated/server";
const empty = internalAction({
args: {},
handler: async (ctx, args) => {
console.log("empty");
},
});
const crons = cronJobs();
// Run `internal.crons.empty` every two hours.
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
export default crons;
```
- You can register Convex functions within `crons.ts` just like any other file.
- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file.
## Testing guidelines
- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`.
Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`:
```typescript
/// <reference types="vite/client" />
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
test("some behavior", async () => {
const t = convexTest(schema, modules);
await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" });
const messages = await t.query(api.messages.list);
expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]);
});
```
The `modules` argument is required so convex-test can discover and load function files. The `/// <reference types="vite/client" />` directive is needed for TypeScript to recognize `import.meta.glob`.
## File storage guidelines
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
```
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
}
export const exampleQuery = query({
args: { fileId: v.id("_storage") },
handler: async (ctx, args) => {
const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId);
console.log(metadata);
return null;
},
});
```
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
+18 -2
View File
@@ -13,6 +13,7 @@ import type * as auth from "../auth.js";
import type * as commentModeration from "../commentModeration.js";
import type * as comments from "../comments.js";
import type * as crons from "../crons.js";
import type * as depRegistryScan from "../depRegistryScan.js";
import type * as devSeed from "../devSeed.js";
import type * as devSeedExtra from "../devSeedExtra.js";
import type * as downloads from "../downloads.js";
@@ -45,6 +46,7 @@ import type * as lib_batching from "../lib/batching.js";
import type * as lib_changelog from "../lib/changelog.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_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
@@ -91,16 +93,22 @@ import type * as lib_soulPublish from "../lib/soulPublish.js";
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
import type * as lib_tokens from "../lib/tokens.js";
import type * as lib_userSearch from "../lib/userSearch.js";
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 seed from "../seed.js";
import type * as seedDemo from "../seedDemo.js";
import type * as seedSouls from "../seedSouls.js";
import type * as skillStatEvents from "../skillStatEvents.js";
import type * as skillTransfers from "../skillTransfers.js";
@@ -130,6 +138,7 @@ declare const fullApi: ApiFromModules<{
commentModeration: typeof commentModeration;
comments: typeof comments;
crons: typeof crons;
depRegistryScan: typeof depRegistryScan;
devSeed: typeof devSeed;
devSeedExtra: typeof devSeedExtra;
downloads: typeof downloads;
@@ -162,6 +171,7 @@ declare const fullApi: ApiFromModules<{
"lib/changelog": typeof lib_changelog;
"lib/commentScamPrompt": typeof lib_commentScamPrompt;
"lib/contentTypes": typeof lib_contentTypes;
"lib/depRegistryScan": typeof lib_depRegistryScan;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
@@ -208,16 +218,22 @@ declare const fullApi: ApiFromModules<{
"lib/staticPublishScan": typeof lib_staticPublishScan;
"lib/tokens": typeof lib_tokens;
"lib/userSearch": typeof lib_userSearch;
"lib/userSkillStats": typeof lib_userSkillStats;
"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;
seed: typeof seed;
seedDemo: typeof seedDemo;
seedSouls: typeof seedSouls;
skillStatEvents: typeof skillStatEvents;
skillTransfers: typeof skillTransfers;
+4 -7
View File
@@ -1,10 +1,7 @@
import { api, internal } from "./_generated/api";
import { internal } from "./_generated/api";
// Asserts that the internal-only download counters remain internal-only.
// Public exposure is prevented at runtime by `internalMutation`; this file
// just pins the public references that *should* exist.
void internal.downloads.recordDownloadInternal;
void internal.soulDownloads.incrementInternal;
// @ts-expect-error download counters must not be publicly callable
void api.downloads.increment;
// @ts-expect-error soul download counters must not be publicly callable
void api.soulDownloads.increment;
+1 -1
View File
@@ -229,7 +229,7 @@ export async function applyCommentScamResultInternalHandler(
ok: true,
shouldBan,
banned: !banResult.alreadyBanned,
alreadyBanned: Boolean(banResult.alreadyBanned),
alreadyBanned: banResult.alreadyBanned,
protectedRole: false,
wouldBan: false,
};
+1 -1
View File
@@ -509,7 +509,7 @@ describe("comments mutations", () => {
if (id === "skills:1") {
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
}
if (String(id).startsWith("comments:reported-")) return reportedComment;
if (id.startsWith("comments:reported-")) return reportedComment;
if (id === "skills:active") {
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
}
+270
View File
@@ -0,0 +1,270 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import {
dedupeDeps,
depRegistryUrl,
parseDependencyFile,
SUPPORTED_DEP_REGISTRIES,
summarizeDepRegistryChecks,
type DepEntry,
type DepRegistryResult,
type DepRegistryUnresolved,
type SupportedDepRegistry,
} from "./lib/depRegistryScan";
import { readStorageText } from "./lib/packageRegistry";
const REQUEST_TIMEOUT_MS = 8_000;
const MAX_RETRIES = 2;
const BACKOFF_BASE_MS = 750;
const INTER_REQUEST_DELAY_MS = 100;
const MAX_DEPENDENCIES_PER_SCAN = 120;
const CACHE_TTL_EXISTS_MS = 30 * 24 * 60 * 60 * 1_000;
const CACHE_TTL_NOT_EXISTS_MS = 7 * 24 * 60 * 60 * 1_000;
const registryValidator = v.union(v.literal("pypi"), v.literal("npm"), v.literal("cargo"));
type RegistryCheck =
| { kind: "found"; httpStatus: number }
| { kind: "missing"; httpStatus: number }
| { kind: "unresolved"; reason: string };
function isSupportedRegistry(value: string): value is SupportedDepRegistry {
return (SUPPORTED_DEP_REGISTRIES as readonly string[]).includes(value);
}
async function wait(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function checkRegistry(dep: DepEntry): Promise<RegistryCheck> {
const headers: Record<string, string> = { Accept: "application/json" };
if (dep.registry === "cargo") {
headers["User-Agent"] = "ClawHub-DepRegistryScan/1.0 (https://clawhub.ai)";
}
let lastStatus: number | undefined;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(depRegistryUrl(dep.registry, dep.name), {
method: "GET",
headers,
signal: controller.signal,
});
clearTimeout(timeout);
lastStatus = response.status;
if (response.status === 200) return { kind: "found", httpStatus: response.status };
if (response.status === 404) return { kind: "missing", httpStatus: response.status };
if (response.status !== 429 && response.status < 500) {
return {
kind: "unresolved",
reason: `unexpected HTTP ${response.status}`,
};
}
} catch (error) {
clearTimeout(timeout);
if (attempt === MAX_RETRIES) {
return {
kind: "unresolved",
reason: error instanceof Error ? error.message : "network error",
};
}
}
if (attempt < MAX_RETRIES) {
await wait(2 ** attempt * BACKOFF_BASE_MS);
}
}
return {
kind: "unresolved",
reason: lastStatus ? `HTTP ${lastStatus}` : "network error",
};
}
async function extractDependencies(ctx: Pick<ActionCtx, "storage">, version: Doc<"skillVersions">) {
const entries: DepEntry[] = [];
for (const file of version.files) {
const basename = file.path.split("/").pop()?.toLowerCase() ?? "";
if (
basename !== "requirements.txt" &&
basename !== "requirements-dev.txt" &&
basename !== "requirements_dev.txt" &&
basename !== "requirements-test.txt" &&
basename !== "requirements_test.txt" &&
basename !== "package.json" &&
basename !== "cargo.toml" &&
basename !== "pyproject.toml"
) {
continue;
}
const content = await readStorageText(ctx, file.storageId);
entries.push(...parseDependencyFile(file.path, content));
}
return dedupeDeps(entries);
}
export const lookupCacheInternal = internalQuery({
args: {
registry: registryValidator,
name: v.string(),
},
handler: async (ctx, args): Promise<Doc<"depRegistryCache"> | null> => {
return ctx.db
.query("depRegistryCache")
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
.unique();
},
});
export const upsertCacheInternal = internalMutation({
args: {
registry: registryValidator,
name: v.string(),
exists: v.boolean(),
httpStatus: v.number(),
checkedAt: v.number(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("depRegistryCache")
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
.unique();
const patch = {
registry: args.registry,
name: args.name,
exists: args.exists,
httpStatus: args.httpStatus,
checkedAt: args.checkedAt,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert("depRegistryCache", patch);
}
},
});
export const getRetryableVersionIdsInternal = internalQuery({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_dep_registry_scan_status_and_created", (q) =>
q.eq("depRegistryScanStatus", "error"),
)
.order("desc")
.take(limit);
return versions.map((version) => version._id);
},
});
async function checkWithCache(ctx: ActionCtx, dep: DepEntry) {
const now = Date.now();
const cached = (await ctx.runQuery(internal.depRegistryScan.lookupCacheInternal, {
registry: dep.registry,
name: dep.name,
})) as Doc<"depRegistryCache"> | null;
if (cached) {
const ttl = cached.exists ? CACHE_TTL_EXISTS_MS : CACHE_TTL_NOT_EXISTS_MS;
if (now - cached.checkedAt < ttl) {
return cached.exists
? ({ kind: "found", httpStatus: cached.httpStatus } as const)
: ({ kind: "missing", httpStatus: cached.httpStatus } as const);
}
}
const check = await checkRegistry(dep);
if (check.kind !== "unresolved") {
await ctx.runMutation(internal.depRegistryScan.upsertCacheInternal, {
registry: dep.registry,
name: dep.name,
exists: check.kind === "found",
httpStatus: check.httpStatus,
checkedAt: now,
});
}
return check;
}
export const checkDependencyRegistries = internalAction({
args: { versionId: v.id("skillVersions") },
handler: async (ctx, args) => {
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})) as Doc<"skillVersions"> | null;
if (!version) return null;
if (version.depRegistryAnalysis && version.depRegistryAnalysis.status !== "error") {
return version.depRegistryAnalysis;
}
const deps = await extractDependencies(ctx, version);
const checkableDeps = deps.slice(0, MAX_DEPENDENCIES_PER_SCAN);
const deferredDeps = deps.slice(MAX_DEPENDENCIES_PER_SCAN);
const results: DepRegistryResult[] = [];
const unresolved: DepRegistryUnresolved[] = deferredDeps.map((dep) => ({
...dep,
reason: "dependency scan limit reached",
}));
for (const dep of checkableDeps) {
if (!isSupportedRegistry(dep.registry)) continue;
const check = await checkWithCache(ctx, dep);
if (check.kind === "unresolved") {
unresolved.push({ ...dep, reason: check.reason });
} else {
results.push({
...dep,
exists: check.kind === "found",
httpStatus: check.httpStatus,
});
}
await wait(INTER_REQUEST_DELAY_MS);
}
const analysis = summarizeDepRegistryChecks({
results,
unresolved,
checkedAt: Date.now(),
});
await ctx.runMutation(internal.skills.updateVersionDepRegistryAnalysisInternal, {
versionId: args.versionId,
depRegistryAnalysis: analysis,
});
return analysis;
},
});
export const rescanErrorDepRegistryVersions = internalAction({
args: {
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const versionIds = (await ctx.runQuery(
internal.depRegistryScan.getRetryableVersionIdsInternal,
{ limit: args.batchSize ?? 25 },
)) as Id<"skillVersions">[];
let scheduled = 0;
for (const versionId of versionIds) {
await ctx.scheduler.runAfter(
scheduled * 2_000,
internal.depRegistryScan.checkDependencyRegistries,
{
versionId,
},
);
scheduled += 1;
}
return { scheduled };
},
});
+175
View File
@@ -0,0 +1,175 @@
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);
});
});
+1602 -24
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -3,13 +3,86 @@
import { describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import {
isGitHubMirrorEligibleSkillDoc,
repointPackageLatestRelease,
scheduleGitHubBackupDeletionForSkill,
scheduleOwnerPublisherDigestSync,
syncPackageSearchDigestForPackageId,
syncPackageSearchDigestsForOwnerPublisherId,
syncPackageSearchDigestsForOwnerUserId,
syncSkillSearchDigestsForOwnerPublisherId,
} from "./functions";
describe("package digest sync", () => {
it("identifies GitHub mirror eligibility from skill visibility fields", () => {
expect(isGitHubMirrorEligibleSkillDoc({ softDeletedAt: undefined })).toBe(true);
expect(
isGitHubMirrorEligibleSkillDoc({
softDeletedAt: undefined,
moderationStatus: "active",
}),
).toBe(true);
expect(
isGitHubMirrorEligibleSkillDoc({
softDeletedAt: undefined,
moderationStatus: "hidden",
}),
).toBe(false);
expect(
isGitHubMirrorEligibleSkillDoc({
softDeletedAt: undefined,
moderationStatus: "removed",
}),
).toBe(false);
expect(isGitHubMirrorEligibleSkillDoc({ softDeletedAt: 123 })).toBe(false);
});
it("schedules GitHub mirror deletion for a skill using the owner handle", async () => {
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
}),
query: vi.fn(() => ({
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(null),
})),
})),
},
scheduler: {
runAfter: vi.fn(),
},
};
await scheduleGitHubBackupDeletionForSkill(
ctx as never,
{
slug: "hidden-skill",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
softDeletedAt: 123,
moderationStatus: "hidden",
} as never,
);
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
0,
internal.githubBackupsNode.deleteGitHubBackupForSlugInternal,
{
ownerHandle: "alice",
slug: "hidden-skill",
},
);
});
it("clears latestVersion when the current package release is soft-deleted", async () => {
const pkg = {
_id: "packages:demo",
@@ -523,4 +596,68 @@ describe("publisher digest scheduling", () => {
scheduleOwnerPublisherDigestSync({} as never, "publishers:demo" as never),
).resolves.toBeUndefined();
});
it("continues owner-publisher package digest sync one page at a time", async () => {
const paginate = vi.fn().mockResolvedValue({
page: [],
isDone: false,
continueCursor: "next-packages",
});
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ paginate })),
})),
},
scheduler: {
runAfter: vi.fn().mockResolvedValue(undefined),
},
};
await syncPackageSearchDigestsForOwnerPublisherId(
ctx as never,
"publishers:demo" as never,
"current-packages",
);
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: "current-packages", numItems: 100 });
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
0,
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
{ ownerPublisherId: "publishers:demo", cursor: "next-packages" },
);
});
it("continues owner-publisher skill digest sync one page at a time", async () => {
const paginate = vi.fn().mockResolvedValue({
page: [],
isDone: false,
continueCursor: "next-skills",
});
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ paginate })),
})),
},
scheduler: {
runAfter: vi.fn().mockResolvedValue(undefined),
},
};
await syncSkillSearchDigestsForOwnerPublisherId(
ctx as never,
"publishers:demo" as never,
"current-skills",
);
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: "current-skills", numItems: 100 });
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
0,
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
{ ownerPublisherId: "publishers:demo", cursor: "next-skills" },
);
});
});
+81 -26
View File
@@ -33,6 +33,8 @@ function isMissingTableError(error: unknown, table: string) {
type PackageDigestSyncCtx = Pick<MutationCtx, "db">;
type OwnerPublisherDigestScheduleCtx = Pick<Partial<MutationCtx>, "scheduler">;
type GitHubBackupDeletionCtx = Pick<MutationCtx, "db" | "scheduler">;
const OWNER_PUBLISHER_DIGEST_PAGE_SIZE = 100;
type LatestPackageRelease = Pick<
Doc<"packageReleases">,
| "_id"
@@ -170,22 +172,25 @@ export async function syncPackageSearchDigestsForOwnerUserId(
}
export async function syncPackageSearchDigestsForOwnerPublisherId(
ctx: PackageDigestSyncCtx,
ctx: PackageDigestSyncCtx & OwnerPublisherDigestScheduleCtx,
ownerPublisherId: Id<"publishers"> | null | undefined,
cursor: string | null = null,
) {
if (!ownerPublisherId) return;
let cursor: string | null = null;
try {
while (true) {
const page = await ctx.db
.query("packages")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
.paginate({ cursor, numItems: 100 });
for (const pkg of page.page) {
await syncPackageSearchDigest(ctx, pkg);
}
if (page.isDone) break;
cursor = page.continueCursor;
const page = await ctx.db
.query("packages")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
.paginate({ cursor, numItems: OWNER_PUBLISHER_DIGEST_PAGE_SIZE });
for (const pkg of page.page) {
await syncPackageSearchDigest(ctx, pkg);
}
if (!page.isDone && ctx.scheduler && page.continueCursor) {
await ctx.scheduler.runAfter(
0,
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
{ ownerPublisherId, cursor: page.continueCursor },
);
}
} catch (error) {
if (isMissingTableError(error, "packages")) return;
@@ -213,23 +218,55 @@ async function syncSkillSearchDigestForSkill(
});
}
export function isGitHubMirrorEligibleSkillDoc(
skill: Pick<Doc<"skills">, "softDeletedAt" | "moderationStatus"> | null | undefined,
) {
if (!skill || skill.softDeletedAt) return false;
return (
skill.moderationStatus === undefined ||
skill.moderationStatus === null ||
skill.moderationStatus === "active"
);
}
export async function scheduleGitHubBackupDeletionForSkill(
ctx: GitHubBackupDeletionCtx,
skill: Pick<
Doc<"skills">,
"slug" | "ownerPublisherId" | "ownerUserId" | "softDeletedAt" | "moderationStatus"
>,
) {
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
});
const ownerHandle = owner?.handle ?? String(skill.ownerPublisherId ?? skill.ownerUserId);
await ctx.scheduler.runAfter(0, internal.githubBackupsNode.deleteGitHubBackupForSlugInternal, {
ownerHandle,
slug: skill.slug,
});
}
export async function syncSkillSearchDigestsForOwnerPublisherId(
ctx: PackageDigestSyncCtx,
ctx: PackageDigestSyncCtx & OwnerPublisherDigestScheduleCtx,
ownerPublisherId: Id<"publishers"> | null | undefined,
cursor: string | null = null,
) {
if (!ownerPublisherId) return;
let cursor: string | null = null;
try {
while (true) {
const page = await ctx.db
.query("skills")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
.paginate({ cursor, numItems: 100 });
for (const skill of page.page) {
await syncSkillSearchDigestForSkill(ctx, skill);
}
if (page.isDone) break;
cursor = page.continueCursor;
const page = await ctx.db
.query("skills")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
.paginate({ cursor, numItems: OWNER_PUBLISHER_DIGEST_PAGE_SIZE });
for (const skill of page.page) {
await syncSkillSearchDigestForSkill(ctx, skill);
}
if (!page.isDone && ctx.scheduler && page.continueCursor) {
await ctx.scheduler.runAfter(
0,
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
{ ownerPublisherId, cursor: page.continueCursor },
);
}
} catch (error) {
if (isMissingTableError(error, "skills")) return;
@@ -257,18 +294,28 @@ export async function scheduleOwnerPublisherDigestSync(
export const syncPackageSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
args: {
ownerPublisherId: v.id("publishers"),
cursor: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
await syncPackageSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
await syncPackageSearchDigestsForOwnerPublisherId(
ctx,
args.ownerPublisherId,
args.cursor ?? null,
);
},
});
export const syncSkillSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
args: {
ownerPublisherId: v.id("publishers"),
cursor: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
await syncSkillSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
await syncSkillSearchDigestsForOwnerPublisherId(
ctx,
args.ownerPublisherId,
args.cursor ?? null,
);
},
});
@@ -324,12 +371,20 @@ export async function repointPackageLatestRelease(
triggers.register("skills", async (ctx, change) => {
if (change.operation === "delete") {
await scheduleGitHubBackupDeletionForSkill(ctx, change.oldDoc);
const existing = await ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", change.id))
.unique();
if (existing) await ctx.db.delete(existing._id);
} else {
if (
change.operation === "update" &&
isGitHubMirrorEligibleSkillDoc(change.oldDoc) &&
!isGitHubMirrorEligibleSkillDoc(change.newDoc)
) {
await scheduleGitHubBackupDeletionForSkill(ctx, change.oldDoc);
}
await syncSkillSearchDigestForSkill(ctx, change.newDoc);
}
});
+109 -63
View File
@@ -4,69 +4,77 @@ import { getGitHubBackupPageInternal } from "./githubBackups";
const handler = (getGitHubBackupPageInternal as unknown as { _handler: Function })._handler;
describe("githubBackups page filtering", () => {
it("skips non-public skills (soft-deleted, hidden, removed)", async () => {
const activeSkill = {
_id: "skills:active",
it("skips non-public digests (soft-deleted, hidden, removed)", async () => {
const activeDigest = {
_id: "skillSearchDigest:active",
skillId: "skills:active",
slug: "active-skill",
displayName: "Active Skill",
ownerUserId: "users:active",
ownerHandle: "alice",
latestVersionId: "skillVersions:active",
latestVersionSummary: {
version: "1.0.0",
createdAt: 1_700_000_000_000,
changelog: "init",
},
softDeletedAt: undefined,
moderationStatus: "active",
};
const hiddenSkill = {
_id: "skills:hidden",
const hiddenDigest = {
_id: "skillSearchDigest:hidden",
skillId: "skills:hidden",
slug: "hidden-skill",
displayName: "Hidden Skill",
ownerUserId: "users:hidden",
ownerHandle: "bob",
latestVersionId: "skillVersions:hidden",
latestVersionSummary: {
version: "1.0.0",
createdAt: 1_700_000_000_000,
changelog: "init",
},
softDeletedAt: undefined,
moderationStatus: "hidden",
};
const removedSkill = {
_id: "skills:removed",
const removedDigest = {
_id: "skillSearchDigest:removed",
skillId: "skills:removed",
slug: "removed-skill",
displayName: "Removed Skill",
ownerUserId: "users:removed",
ownerHandle: "carol",
latestVersionId: "skillVersions:removed",
latestVersionSummary: {
version: "1.0.0",
createdAt: 1_700_000_000_000,
changelog: "init",
},
softDeletedAt: undefined,
moderationStatus: "removed",
};
const softDeletedSkill = {
_id: "skills:soft",
const softDeletedDigest = {
_id: "skillSearchDigest:soft",
skillId: "skills:soft",
slug: "soft-skill",
displayName: "Soft Skill",
ownerUserId: "users:soft",
ownerHandle: "dave",
latestVersionId: "skillVersions:soft",
latestVersionSummary: {
version: "1.0.0",
createdAt: 1_700_000_000_000,
changelog: "init",
},
softDeletedAt: 1,
moderationStatus: "active",
};
const get = vi.fn(async (id: string) => {
if (id === "skillVersions:active") {
return {
_id: "skillVersions:active",
version: "1.0.0",
files: [{ path: "SKILL.md", size: 10, storageId: "storage:1", sha256: "abc" }],
createdAt: 1_700_000_000_000,
};
}
if (id === "users:active") {
return {
_id: "users:active",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
});
const paginate = vi.fn().mockResolvedValue({
page: [activeSkill, hiddenSkill, removedSkill, softDeletedSkill],
page: [activeDigest, hiddenDigest, removedDigest, softDeletedDigest],
isDone: true,
continueCursor: null,
});
@@ -75,14 +83,12 @@ describe("githubBackups page filtering", () => {
const result = await handler(
{
db: {
query,
get,
},
db: { query },
} as never,
{ batchSize: 50 },
);
expect(query).toHaveBeenCalledWith("skillSearchDigest");
expect(result).toMatchObject({
isDone: true,
cursor: null,
@@ -95,42 +101,28 @@ describe("githubBackups page filtering", () => {
},
],
});
expect(get).toHaveBeenCalledTimes(2);
});
it("keeps legacy skills with undefined moderationStatus eligible", async () => {
const legacySkill = {
_id: "skills:legacy",
it("keeps legacy digests with undefined moderationStatus eligible", async () => {
const legacyDigest = {
_id: "skillSearchDigest:legacy",
skillId: "skills:legacy",
slug: "legacy-skill",
displayName: "Legacy Skill",
ownerUserId: "users:legacy",
ownerHandle: "",
latestVersionId: "skillVersions:legacy",
latestVersionSummary: {
version: "2.0.0",
createdAt: 1_700_000_000_100,
changelog: "update",
},
softDeletedAt: undefined,
moderationStatus: undefined,
};
const get = vi.fn(async (id: string) => {
if (id === "skillVersions:legacy") {
return {
_id: "skillVersions:legacy",
version: "2.0.0",
files: [{ path: "SKILL.md", size: 20, storageId: "storage:2", sha256: "def" }],
createdAt: 1_700_000_000_100,
};
}
if (id === "users:legacy") {
return {
_id: "users:legacy",
handle: null,
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
});
const paginate = vi.fn().mockResolvedValue({
page: [legacySkill],
page: [legacyDigest],
isDone: true,
continueCursor: null,
});
@@ -139,10 +131,7 @@ describe("githubBackups page filtering", () => {
const result = await handler(
{
db: {
query,
get,
},
db: { query },
} as never,
{},
);
@@ -155,4 +144,61 @@ describe("githubBackups page filtering", () => {
version: "2.0.0",
});
});
it("skips digests without ownerHandle or latestVersionSummary", async () => {
const noOwnerHandle = {
_id: "skillSearchDigest:no-owner",
skillId: "skills:no-owner",
slug: "no-owner",
displayName: "No Owner",
ownerUserId: "users:no-owner",
ownerHandle: undefined,
latestVersionId: "skillVersions:no-owner",
latestVersionSummary: { version: "1.0.0", createdAt: 1, changelog: "init" },
softDeletedAt: undefined,
moderationStatus: "active",
};
const noVersion = {
_id: "skillSearchDigest:no-version",
skillId: "skills:no-version",
slug: "no-version",
displayName: "No Version",
ownerUserId: "users:no-version",
ownerHandle: "frank",
latestVersionId: undefined,
latestVersionSummary: undefined,
softDeletedAt: undefined,
moderationStatus: "active",
};
const paginate = vi.fn().mockResolvedValue({
page: [noOwnerHandle, noVersion],
isDone: true,
continueCursor: null,
});
const order = vi.fn().mockReturnValue({ paginate });
const query = vi.fn().mockReturnValue({ order });
const result = await handler({ db: { query } } as never, {});
expect(result.items).toEqual([
{ kind: "missingOwner", skillId: "skills:no-owner", ownerUserId: "users:no-owner" },
{ kind: "missingLatestVersion", skillId: "skills:no-version" },
]);
});
it("resets stale skills-table cursors after switching to digest pagination", async () => {
const paginate = vi
.fn()
.mockRejectedValueOnce(new Error("cursor is from a different query"))
.mockResolvedValueOnce({ page: [], isDone: true, continueCursor: null });
const order = vi.fn().mockReturnValue({ paginate });
const query = vi.fn().mockReturnValue({ order });
const result = await handler({ db: { query } } as never, { cursor: "stale-cursor" });
expect(result).toMatchObject({ items: [], isDone: true, cursor: null });
expect(paginate).toHaveBeenNthCalledWith(1, { cursor: "stale-cursor", numItems: 50 });
expect(paginate).toHaveBeenNthCalledWith(2, { cursor: null, numItems: 50 });
});
});
+45 -31
View File
@@ -1,6 +1,6 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { Id } from "./_generated/dataModel";
import { action, internalMutation, internalQuery } from "./functions";
import { assertRole, requireUserFromAction } from "./lib/access";
@@ -17,11 +17,9 @@ type BackupPageItem =
displayName: string;
version: string;
ownerHandle: string;
files: Doc<"skillVersions">["files"];
publishedAt: number;
}
| { kind: "missingLatestVersion"; skillId: Id<"skills"> }
| { kind: "missingVersionDoc"; skillId: Id<"skills">; versionId: Id<"skillVersions"> }
| { kind: "missingOwner"; skillId: Id<"skills">; ownerUserId: Id<"users"> };
type BackupPageResult = {
@@ -57,49 +55,52 @@ export const getGitHubBackupPageInternal = internalQuery({
},
handler: async (ctx, args): Promise<BackupPageResult> => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const { page, isDone, continueCursor } = await ctx.db
.query("skills")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let pageResult;
try {
pageResult = await ctx.db
.query("skillSearchDigest")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
} catch (error) {
if (!args.cursor || !isStaleCursorError(error)) throw error;
pageResult = await ctx.db
.query("skillSearchDigest")
.order("asc")
.paginate({ cursor: null, numItems: batchSize });
}
const items: BackupPageItem[] = [];
for (const skill of page) {
if (!isPubliclyAvailableSkill(skill)) continue;
if (!skill.latestVersionId) {
items.push({ kind: "missingLatestVersion", skillId: skill._id });
for (const digest of pageResult.page) {
if (!isPubliclyAvailableSkill(digest)) continue;
if (!digest.latestVersionId || !digest.latestVersionSummary) {
items.push({ kind: "missingLatestVersion", skillId: digest.skillId });
continue;
}
const version = await ctx.db.get(skill.latestVersionId);
if (!version) {
if (digest.ownerHandle === undefined) {
items.push({
kind: "missingVersionDoc",
skillId: skill._id,
versionId: skill.latestVersionId,
kind: "missingOwner",
skillId: digest.skillId,
ownerUserId: digest.ownerUserId,
});
continue;
}
const owner = await ctx.db.get(skill.ownerUserId);
if (!owner || owner.deletedAt || owner.deactivatedAt) {
items.push({ kind: "missingOwner", skillId: skill._id, ownerUserId: skill.ownerUserId });
continue;
}
const ownerHandle =
digest.ownerHandle || String(digest.ownerPublisherId ?? digest.ownerUserId);
items.push({
kind: "ok",
skillId: skill._id,
versionId: version._id,
slug: skill.slug,
displayName: skill.displayName,
version: version.version,
ownerHandle: owner.handle ?? owner._id,
files: version.files,
publishedAt: version.createdAt,
skillId: digest.skillId,
versionId: digest.latestVersionId,
slug: digest.slug,
displayName: digest.displayName,
version: digest.latestVersionSummary.version,
ownerHandle,
publishedAt: digest.latestVersionSummary.createdAt,
});
}
return { items, cursor: continueCursor, isDone };
return { items, cursor: pageResult.continueCursor, isDone: pageResult.isDone };
},
});
@@ -115,6 +116,19 @@ function isPubliclyAvailableSkill(skill: {
);
}
function isStaleCursorError(error: unknown) {
const message =
typeof error === "string"
? error
: error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: "";
return (
message.includes("Failed to parse cursor") ||
message.includes("cursor is from a different query")
);
}
export const getGitHubBackupSyncStateInternal = internalQuery({
args: {},
handler: async (ctx): Promise<BackupSyncState> => {
+11 -4
View File
@@ -25,15 +25,14 @@ const MAX_PRUNE_BATCH_SIZE = 100;
type BackupPageItem =
| {
kind: "ok";
versionId: Doc<"skillVersions">["_id"];
slug: string;
version: string;
displayName: string;
ownerHandle: string;
files: Doc<"skillVersions">["files"];
publishedAt: number;
}
| { kind: "missingLatestVersion" }
| { kind: "missingVersionDoc" }
| { kind: "missingOwner" };
export type GitHubBackupSyncStats = {
@@ -136,7 +135,7 @@ export async function syncGitHubBackupsInternalHandler(
for (const item of page.items) {
if (item.kind !== "ok") {
if (item.kind === "missingLatestVersion" || item.kind === "missingVersionDoc") {
if (item.kind === "missingLatestVersion") {
stats.skillsMissingVersion += 1;
} else if (item.kind === "missingOwner") {
stats.skillsMissingOwner += 1;
@@ -152,6 +151,14 @@ export async function syncGitHubBackupsInternalHandler(
continue;
}
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: item.versionId,
})) as Doc<"skillVersions"> | null;
if (!version) {
stats.skillsMissingVersion += 1;
continue;
}
if (!dryRun) {
await backupSkillToGitHub(
ctx,
@@ -160,7 +167,7 @@ export async function syncGitHubBackupsInternalHandler(
version: item.version,
displayName: item.displayName,
ownerHandle: item.ownerHandle,
files: item.files,
files: version.files,
publishedAt: item.publishedAt,
},
context,
+4 -7
View File
@@ -422,13 +422,8 @@ describe("httpApi handlers", () => {
expect(json.skillId).toBe("s");
});
it("cliPublishHttp accepts legacy clients that omit license terms", async () => {
it("cliPublishHttp rejects omitted license terms", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "user1" } as never);
vi.mocked(publishVersionForUser).mockResolvedValueOnce({
skillId: "s",
versionId: "v",
embeddingId: "e",
} as never);
const request = new Request("https://x/api/cli/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -441,7 +436,9 @@ describe("httpApi handlers", () => {
}),
});
const response = await __handlers.cliPublishHandler(makeCtx({}), request);
expect(response.status).toBe(200);
expect(response.status).toBe(400);
expect(await response.text()).toMatch(/license terms must be accepted/i);
expect(publishVersionForUser).not.toHaveBeenCalled();
});
it("cliPublishHttp rejects explicit license refusal", async () => {
+1 -1
View File
@@ -183,7 +183,7 @@ async function cliPublishHandler(ctx: ActionCtx, request: Request) {
}
function hasAcceptedLegacyLicenseTerms(acceptLicenseTerms: boolean | undefined) {
return acceptLicenseTerms !== false;
return acceptLicenseTerms === true;
}
export const cliPublishHttp = httpAction(cliPublishHandler);
+621 -38
View File
@@ -55,6 +55,26 @@ function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
}
function makeCatalogItem(
name: string,
options: {
family: "code-plugin" | "bundle-plugin" | "skill";
updatedAt: number;
score?: number;
},
) {
return {
name,
displayName: name,
family: options.family,
channel: "community",
isOfficial: false,
createdAt: options.updatedAt,
updatedAt: options.updatedAt,
...(typeof options.score === "number" ? { score: options.score } : {}),
};
}
function makeCtx(partial: Record<string, unknown>) {
const partialRunQuery =
typeof partial.runQuery === "function"
@@ -410,9 +430,9 @@ describe("httpApiV1 handlers", () => {
it("lists skills with resolved tags using batch query", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "limit" in args) {
if ("cursor" in args || "numItems" in args) {
return {
items: [
page: [
{
skill: {
_id: "skills:1",
@@ -448,9 +468,9 @@ describe("httpApiV1 handlers", () => {
it("batches tag resolution across multiple skills into single query", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "limit" in args) {
if ("cursor" in args || "numItems" in args) {
return {
items: [
page: [
{
skill: {
_id: "skills:1",
@@ -703,18 +723,28 @@ describe("httpApiV1 handlers", () => {
});
it("lists skills supports sort aliases", async () => {
const checks: Array<[string, string]> = [
const checks: Array<[string, string | null]> = [
["createdAt", "newest"],
["created-at", "newest"],
["newest", "newest"],
["rating", "stars"],
["installs", "installsCurrent"],
["installs-all-time", "installsAllTime"],
["trending", "trending"],
["installs", "installs"],
["installs-all-time", "installs"],
["unknown", "updated"],
["trending", null],
];
for (const [input, expected] of checks) {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("sort" in args || "cursor" in args || "limit" in args) {
expect(args.sort).toBe(expected);
return { items: [], nextCursor: null };
if ("sort" in args || "cursor" in args || "numItems" in args || "limit" in args) {
if (expected === null) {
expect(args).not.toHaveProperty("sort");
} else {
expect(args.sort).toBe(expected);
}
return expected === null
? { items: [], nextCursor: null }
: { page: [], nextCursor: null };
}
return null;
});
@@ -729,9 +759,9 @@ describe("httpApiV1 handlers", () => {
it("lists skills forwards nonSuspiciousOnly", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("sort" in args || "cursor" in args || "limit" in args) {
if ("sort" in args || "cursor" in args || "numItems" in args) {
expect(args.nonSuspiciousOnly).toBe(true);
return { items: [], nextCursor: null };
return { page: [], nextCursor: null };
}
return null;
});
@@ -745,9 +775,9 @@ describe("httpApiV1 handlers", () => {
it("lists skills forwards legacy nonSuspicious alias", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("sort" in args || "cursor" in args || "limit" in args) {
if ("sort" in args || "cursor" in args || "numItems" in args) {
expect(args.nonSuspiciousOnly).toBe(true);
return { items: [], nextCursor: null };
return { page: [], nextCursor: null };
}
return null;
});
@@ -761,9 +791,9 @@ describe("httpApiV1 handlers", () => {
it("lists skills prefers canonical nonSuspiciousOnly over legacy alias", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("sort" in args || "cursor" in args || "limit" in args) {
if ("sort" in args || "cursor" in args || "numItems" in args) {
expect(args.nonSuspiciousOnly).toBeUndefined();
return { items: [], nextCursor: null };
return { page: [], nextCursor: null };
}
return null;
});
@@ -1197,6 +1227,154 @@ describe("httpApiV1 handlers", () => {
expect(json.version.security.virustotalUrl).toContain("virustotal.com/gui/file/");
});
it("surfaces static-scan suspicious status in version security snapshot", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
sha256hash: "a".repeat(64),
staticScan: {
status: "suspicious",
reasonCodes: ["suspicious.dangerous_exec"],
summary: "Detected: suspicious.dangerous_exec",
engineVersion: "v2.4.0",
checkedAt: 555,
},
vtAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: 111,
},
llmAnalysis: {
status: "completed",
verdict: "benign",
checkedAt: 222,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("suspicious");
expect(json.version.security.hasWarnings).toBe(true);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.scanners.static.normalizedStatus).toBe("suspicious");
expect(json.version.security.scanners.vt.normalizedStatus).toBe("clean");
expect(json.version.security.scanners.llm.normalizedStatus).toBe("clean");
});
it("lets static-scan malicious status dominate benign vt and llm results", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
sha256hash: "a".repeat(64),
staticScan: {
status: "malicious",
reasonCodes: ["malicious.credential_harvest"],
summary: "Detected: malicious.credential_harvest",
engineVersion: "v2.4.0",
checkedAt: 555,
},
vtAnalysis: {
status: "clean",
verdict: "benign",
checkedAt: 111,
},
llmAnalysis: {
status: "completed",
verdict: "benign",
checkedAt: 222,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("malicious");
expect(json.version.security.hasWarnings).toBe(true);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.checkedAt).toBe(555);
expect(json.version.security.scanners.static.normalizedStatus).toBe("malicious");
});
it("treats a static scan by itself as a definitive scan result", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
latestVersion: null,
owner: { handle: "owner", displayName: "Owner", image: null },
};
}
if ("skillId" in args && "version" in args) {
return {
version: "1.0.0",
createdAt: 1,
changelog: "c",
changelogSource: "auto",
staticScan: {
status: "clean",
reasonCodes: [],
summary: "No issues found",
engineVersion: "v2.4.0",
checkedAt: 555,
},
files: [],
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.version.security.status).toBe("clean");
expect(json.version.security.hasWarnings).toBe(false);
expect(json.version.security.hasScanResult).toBe(true);
expect(json.version.security.virustotalUrl).toBeNull();
expect(json.version.security.scanners.static.normalizedStatus).toBe("clean");
expect(json.version.security.scanners.vt).toBeNull();
expect(json.version.security.scanners.llm).toBeNull();
});
it("keeps hasWarnings true when llm dimensions include non-ok ratings", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
@@ -1748,16 +1926,11 @@ describe("httpApiV1 handlers", () => {
expect(publishVersionForUser).toHaveBeenCalled();
});
it("publish json accepts legacy clients that omit license terms", async () => {
it("publish json rejects omitted license terms", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({
userId: "users:1",
user: { handle: "p" },
} as never);
vi.mocked(publishVersionForUser).mockResolvedValueOnce({
skillId: "s",
versionId: "v",
embeddingId: "e",
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const body = JSON.stringify({
slug: "demo",
@@ -1782,7 +1955,9 @@ describe("httpApiV1 handlers", () => {
body,
}),
);
expect(response.status).toBe(200);
expect(response.status).toBe(400);
expect(await response.text()).toMatch(/license terms must be accepted/i);
expect(publishVersionForUser).not.toHaveBeenCalled();
});
it("publish multipart succeeds", async () => {
@@ -1822,16 +1997,11 @@ describe("httpApiV1 handlers", () => {
}
});
it("publish multipart accepts legacy clients that omit license terms", async () => {
it("publish multipart rejects omitted license terms", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({
userId: "users:1",
user: { handle: "p" },
} as never);
vi.mocked(publishVersionForUser).mockResolvedValueOnce({
skillId: "s",
versionId: "v",
embeddingId: "e",
} as never);
const runMutation = vi.fn().mockResolvedValue(okRate());
const form = new FormData();
form.set(
@@ -1853,7 +2023,9 @@ describe("httpApiV1 handlers", () => {
body: form,
}),
);
expect(response.status).toBe(200);
expect(response.status).toBe(400);
expect(await response.text()).toMatch(/license terms must be accepted/i);
expect(publishVersionForUser).not.toHaveBeenCalled();
});
it("publish rejects explicit license refusal", async () => {
@@ -1959,6 +2131,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.handle).toBe("p");
expect(json.user.role).toBeNull();
});
it("delete and undelete require auth", async () => {
@@ -2007,6 +2180,86 @@ describe("httpApiV1 handlers", () => {
expect(response2.status).toBe(200);
});
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "skill",
name: args.slug,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "skill",
name: "demo",
remainingRequests: 2,
maxRequests: 3,
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
);
});
it("package rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "package",
name: args.name,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "package",
name: "@scope/demo",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
);
});
it("transfer request requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2248,6 +2501,71 @@ describe("httpApiV1 handlers", () => {
);
});
it("unban user requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/users/unban", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ handle: "demo" }),
}),
);
expect(response.status).toBe(401);
});
it("unban user succeeds with handle", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runQuery = vi.fn().mockResolvedValue({ _id: "users:2" });
const runMutation = vi
.fn()
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce({ ok: true, alreadyUnbanned: false, restoredSkills: 2 });
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/users/unban", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ handle: "demo" }),
}),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.restoredSkills).toBe(2);
});
it("unban user forwards reason", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runQuery = vi.fn().mockResolvedValue({ _id: "users:2" });
const runMutation = vi
.fn()
.mockResolvedValueOnce(okRate())
.mockResolvedValueOnce({ ok: true, alreadyUnbanned: false, restoredSkills: 0 });
await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/users/unban", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ handle: "demo", reason: "appeal accepted" }),
}),
);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
actorUserId: "users:1",
targetUserId: "users:2",
reason: "appeal accepted",
}),
);
});
it("set role requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2344,7 +2662,11 @@ describe("httpApiV1 handlers", () => {
});
it("packages search forwards executesCode and capabilityTag", async () => {
const runQuery = vi.fn().mockResolvedValue([]);
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
@@ -2356,10 +2678,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "test",
limit: 5,
executesCode: true,
capabilityTag: "tools",
paginationOpts: { cursor: null, numItems: 50 },
}),
);
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
@@ -2387,6 +2708,123 @@ describe("httpApiV1 handlers", () => {
);
});
it("plugins list defaults to plugin package families", async () => {
const codePlugin = {
name: "code-plugin",
displayName: "Code Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 20,
updatedAt: 200,
};
const bundlePlugin = {
name: "bundle-plugin",
displayName: "Bundle Plugin",
family: "bundle-plugin",
channel: "community",
isOfficial: false,
createdAt: 10,
updatedAt: 100,
};
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin") {
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=7"),
);
expect(response.status).toBe(200);
expect((await response.json()).items.map((entry: { name: string }) => entry.name)).toEqual([
"code-plugin",
"bundle-plugin",
]);
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
for (const [, args] of runQuery.mock.calls) {
expect(args).toEqual(
expect.objectContaining({
paginationOpts: { cursor: null, numItems: 7 },
}),
);
}
});
it("plugins list paginates with separate plugin family cursors", async () => {
const codeNewest = makeCatalogItem("code-newest", {
family: "code-plugin",
updatedAt: 300,
});
const codeOlder = makeCatalogItem("code-older", {
family: "code-plugin",
updatedAt: 100,
});
const bundleMiddle = makeCatalogItem("bundle-middle", {
family: "bundle-plugin",
updatedAt: 200,
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
const pagination = args.paginationOpts as { cursor: string | null };
if (args.family === "code-plugin" && pagination.cursor === null) {
return { page: [codeNewest], isDone: false, continueCursor: "code-cursor" };
}
if (args.family === "code-plugin" && pagination.cursor === "code-cursor") {
return { page: [codeOlder], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin" && pagination.cursor === null) {
return { page: [bundleMiddle], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected args ${JSON.stringify(args)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const firstResponse = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=1"),
);
expect(firstResponse.status).toBe(200);
const firstJson = await firstResponse.json();
expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual(["code-newest"]);
expect(firstJson.nextCursor).toMatch(/^pkgplugins:/);
const secondUrl = new URL("https://example.com/api/v1/plugins");
secondUrl.searchParams.set("limit", "1");
secondUrl.searchParams.set("cursor", firstJson.nextCursor);
const secondResponse = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(secondUrl),
);
expect(secondResponse.status).toBe(200);
const secondJson = await secondResponse.json();
expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([
"bundle-middle",
]);
const packageCalls = runQuery.mock.calls
.map(([, args]) => args as { family?: string; paginationOpts?: { cursor: string | null } })
.filter((args) => args.family === "code-plugin" || args.family === "bundle-plugin");
expect(
packageCalls.map((args) => ({
family: args.family,
cursor: args.paginationOpts?.cursor ?? null,
})),
).toEqual([
{ family: "code-plugin", cursor: null },
{ family: "bundle-plugin", cursor: null },
{ family: "code-plugin", cursor: "code-cursor" },
{ family: "bundle-plugin", cursor: null },
]);
});
it("packages search supports family=skill on the generic route", async () => {
const runQuery = vi.fn().mockResolvedValue([]);
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2405,6 +2843,114 @@ describe("httpApiV1 handlers", () => {
);
});
it("plugins search defaults to plugin package families", async () => {
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return {
page: [
{
name: "weather-code",
displayName: "Weather Code",
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 10,
updatedAt: 100,
},
],
isDone: true,
continueCursor: "",
};
}
if (args.family === "bundle-plugin") {
return {
page: [
{
name: "weather-bundle",
displayName: "Weather Bundle",
family: "bundle-plugin",
channel: "community",
isOfficial: false,
createdAt: 20,
updatedAt: 200,
},
],
isDone: true,
continueCursor: "",
};
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/search?q=weather&limit=7"),
);
expect(response.status).toBe(200);
expect(
(await response.json()).results.map(
(entry: { package: { name: string } }) => entry.package.name,
),
).toEqual(["weather-bundle", "weather-code"]);
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
for (const [, args] of runQuery.mock.calls) {
expect(args).toEqual(
expect.objectContaining({
paginationOpts: { cursor: null, numItems: 50 },
}),
);
}
});
it("plugins search dedupes and sorts results from both plugin families", async () => {
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return {
page: [
makeCatalogItem("shared-plugin", { family: "code-plugin", updatedAt: 100 }),
makeCatalogItem("plugin-code", { family: "code-plugin", updatedAt: 50 }),
],
isDone: true,
continueCursor: "",
};
}
if (args.family === "bundle-plugin") {
return {
page: [
makeCatalogItem("plugin-bundle", { family: "bundle-plugin", updatedAt: 80 }),
makeCatalogItem("shared-plugin", { family: "bundle-plugin", updatedAt: 60 }),
],
isDone: true,
continueCursor: "",
};
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/search?q=plugin&limit=3"),
);
expect(response.status).toBe(200);
expect(
(await response.json()).results.map(
(entry: { score: number; package: { family: string; name: string } }) => ({
family: entry.package.family,
name: entry.package.name,
}),
),
).toEqual([
{ family: "bundle-plugin", name: "plugin-bundle" },
{ family: "code-plugin", name: "plugin-code" },
{ family: "code-plugin", name: "shared-plugin" },
]);
});
it("packages list forwards viewerUserId for authenticated private package browsing", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
@@ -2428,7 +2974,12 @@ describe("httpApiV1 handlers", () => {
it("packages search forwards viewerUserId for authenticated private package search", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const runQuery = vi.fn().mockResolvedValue([]);
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if ("userId" in args) return { _id: args.userId };
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
@@ -2440,9 +2991,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "secret",
channel: "private",
viewerUserId: "users:owner",
paginationOpts: { cursor: null, numItems: 50 },
}),
);
});
@@ -2474,7 +3025,8 @@ describe("httpApiV1 handlers", () => {
if (query === internal.users.getByIdInternal) {
throw new Error("Table mismatch");
}
if ("query" in args && args.query === "secret") return [];
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
if ("query" in args) return [];
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -2492,9 +3044,9 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: "secret",
channel: "community",
viewerUserId: undefined,
paginationOpts: { cursor: null, numItems: 50 },
}),
);
});
@@ -3138,6 +3690,9 @@ describe("httpApiV1 handlers", () => {
"package/package.json",
]);
expect(zipEntries["_meta.json"]).toBeUndefined();
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
packageId: "packages:1",
});
});
it("package download fails when any stored file is missing", async () => {
@@ -3925,6 +4480,34 @@ describe("httpApiV1 handlers", () => {
expect(setCall?.[1]).not.toHaveProperty("environment");
});
it("deletes a package", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { _id: "users:1", handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return { ok: true };
});
const response = await __handlers.packagesDeleteRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/packages/%40openclaw%2Fdemo-plugin", {
method: "DELETE",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
userId: "users:1",
name: "@openclaw/demo-plugin",
}),
);
});
it("deletes trusted publisher config for a package", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
+334 -27
View File
@@ -1,4 +1,3 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import {
PackagePublishRequestSchema,
PackageTrustedPublisherUpsertRequestSchema,
@@ -8,6 +7,7 @@ import {
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import {
fetchGitHubRepositoryIdentity,
@@ -28,6 +28,7 @@ import {
requireApiTokenUserOrResponse,
requirePackagePublishAuthOrResponse,
safeTextFileResponse,
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from "./shared";
@@ -61,6 +62,9 @@ const internalRefs = internal as unknown as {
getReleaseByPackageAndVersionInternal: unknown;
getReleaseByIdInternal: unknown;
insertAuditLogInternal: unknown;
recordPackageDownloadInternal: unknown;
requestRescanForApiTokenInternal: unknown;
softDeletePackageInternal: unknown;
};
packagePublishTokens: {
createInternal: unknown;
@@ -88,12 +92,8 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
if (apiTokenUserId) return apiTokenUserId;
try {
const userId = (await getAuthUserId(ctx)) ?? null;
const userId = (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
if (!userId) return null;
const user = await runQueryRef<Doc<"users"> | null>(ctx, internal.users.getByIdInternal, {
userId,
});
if (!user || user.deletedAt || user.deactivatedAt) return null;
return userId;
} catch {
// Public package reads should degrade to anonymous when cookie-backed auth is stale.
@@ -105,6 +105,7 @@ type PackageListQueryArgs = {
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -252,6 +253,11 @@ type UnifiedCatalogCursorState = {
skills: CatalogSourceCursorState;
};
type PluginCatalogCursorState = {
codePlugins: CatalogSourceCursorState;
bundlePlugins: CatalogSourceCursorState;
};
type CatalogPageResult = {
page: CatalogListItem[];
isDone: boolean;
@@ -266,6 +272,7 @@ type CatalogSourceState = {
};
const UNIFIED_CATALOG_CURSOR_PREFIX = "pkgcatalog:";
const PLUGIN_CATALOG_CURSOR_PREFIX = "pkgplugins:";
function defaultCatalogSourceCursorState(): CatalogSourceCursorState {
return { cursor: null, offset: 0, pageSize: null, done: false };
@@ -306,6 +313,42 @@ function decodeUnifiedCatalogCursor(raw: string | null | undefined): UnifiedCata
}
}
function encodePluginCatalogCursor(state: PluginCatalogCursorState) {
return `${PLUGIN_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`;
}
function decodePluginCatalogCursor(raw: string | null | undefined): PluginCatalogCursorState {
const normalize = (
input: Partial<CatalogSourceCursorState> | undefined,
): CatalogSourceCursorState => ({
cursor: typeof input?.cursor === "string" ? input.cursor : null,
offset: typeof input?.offset === "number" && input.offset > 0 ? input.offset : 0,
pageSize: typeof input?.pageSize === "number" && input.pageSize > 0 ? input.pageSize : null,
done: input?.done === true,
});
if (!raw?.startsWith(PLUGIN_CATALOG_CURSOR_PREFIX)) {
return {
codePlugins: { ...defaultCatalogSourceCursorState(), cursor: raw ?? null },
bundlePlugins: defaultCatalogSourceCursorState(),
};
}
try {
const parsed = JSON.parse(
raw.slice(PLUGIN_CATALOG_CURSOR_PREFIX.length),
) as Partial<PluginCatalogCursorState>;
return {
codePlugins: normalize(parsed.codePlugins),
bundlePlugins: normalize(parsed.bundlePlugins),
};
} catch {
return {
codePlugins: defaultCatalogSourceCursorState(),
bundlePlugins: defaultCatalogSourceCursorState(),
};
}
}
function initCatalogSource(state: CatalogSourceCursorState): CatalogSourceState {
return {
state: { ...state },
@@ -322,7 +365,7 @@ function finalizeCatalogSource(source: CatalogSourceState): CatalogSourceCursorS
cursor: source.pageCursor,
offset: source.index,
pageSize: source.state.pageSize,
done: source.page.isDone,
done: false,
};
}
return {
@@ -370,6 +413,102 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
return a.name.localeCompare(b.name);
}
const HTTP_PACKAGE_SEARCH_PAGE_SIZE = 50;
const HTTP_PACKAGE_SEARCH_SCAN_PAGES = 20;
function catalogSearchScore(item: CatalogListItem, queryText: string) {
const needle = queryText.toLowerCase();
const name = item.name.toLowerCase();
const display = item.displayName.toLowerCase();
const runtimeId = item.runtimeId?.toLowerCase() ?? "";
const summary = (item.summary ?? "").toLowerCase();
let score = 0;
if (name === needle) score += 200;
else if (name.startsWith(needle)) score += 120;
else if (name.includes(needle)) score += 80;
if (display === needle) score += 150;
else if (display.startsWith(needle)) score += 70;
else if (display.includes(needle)) score += 40;
if (runtimeId === needle) score += 180;
else if (runtimeId.startsWith(needle)) score += 90;
else if (runtimeId.includes(needle)) score += 45;
if (summary.includes(needle)) score += 20;
if ((item.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
score += 12;
}
if (item.isOfficial) score += 5;
return score;
}
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
return (
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
compareCatalogItems(a.package, b.package)
);
}
async function searchPackageCatalogByListing(
ctx: ActionCtx,
args: {
query: string;
limit: number;
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
},
): Promise<CatalogSearchEntry[]> {
const queryText = args.query.trim().toLowerCase();
if (!queryText) return [];
const matches: CatalogSearchEntry[] = [];
const seen = new Set<string>();
let cursor: string | null = null;
let done = false;
let loops = 0;
while (!done && loops < HTTP_PACKAGE_SEARCH_SCAN_PAGES) {
loops += 1;
const result: {
page: CatalogListItem[];
isDone: boolean;
continueCursor: string | null;
} = await runQueryRef(ctx, internalRefs.packages.listPageForViewerInternal, {
family: args.family,
channel: args.channel,
isOfficial: args.isOfficial,
highlightedOnly: args.highlightedOnly,
executesCode: args.executesCode,
capabilityTag: args.capabilityTag,
viewerUserId: args.viewerUserId,
paginationOpts: { cursor, numItems: HTTP_PACKAGE_SEARCH_PAGE_SIZE },
});
for (const item of result.page) {
const key = `${item.family}:${item.name}`;
if (seen.has(key)) continue;
const score = catalogSearchScore(item, queryText);
if (score <= 0) continue;
seen.add(key);
matches.push({ score, package: item });
}
done = result.isDone;
cursor = result.continueCursor;
if (!cursor && !done) break;
}
return matches.sort(compareCatalogSearchEntries).slice(0, args.limit);
}
async function resolveSkillTags(
ctx: ActionCtx,
tags: Record<string, Id<"skillVersions">>,
@@ -502,7 +641,7 @@ async function listPackages(
ctx: ActionCtx,
request: Request,
family?: PackageListQueryArgs["family"],
options?: { includeSkills?: boolean },
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
@@ -515,6 +654,11 @@ async function listPackages(
const channelRaw = url.searchParams.get("channel")?.trim();
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const effectiveFamily =
family ??
@@ -539,6 +683,7 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor, numItems: limit },
@@ -566,6 +711,7 @@ async function listPackages(
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -585,6 +731,7 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor: pageCursor, numItems },
@@ -629,6 +776,85 @@ async function listPackages(
);
}
if (!effectiveFamily && options?.pluginFamilies?.length) {
const decodedCursor = decodePluginCatalogCursor(cursor);
const codePluginSource = initCatalogSource(decodedCursor.codePlugins);
const bundlePluginSource = initCatalogSource(decodedCursor.bundlePlugins);
const pageSize = limit;
const items: CatalogListItem[] = [];
const fetchPluginPage = async (
pluginFamily: "code-plugin" | "bundle-plugin",
pageCursor: string | null,
numItems: number,
) => {
const result = await runQueryRef<{
page: CatalogListItem[];
isDone: boolean;
continueCursor: string | null;
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: pageCursor, numItems },
});
return {
page: result.page,
isDone: result.isDone,
continueCursor: result.continueCursor ?? "",
};
};
while (items.length < limit) {
const [codePluginCandidate, bundlePluginCandidate] = await Promise.all([
options.pluginFamilies.includes("code-plugin")
? ensureCatalogSourcePage(codePluginSource, pageSize, (pageCursor, numItems) =>
fetchPluginPage("code-plugin", pageCursor, numItems),
)
: Promise.resolve(null),
options.pluginFamilies.includes("bundle-plugin")
? ensureCatalogSourcePage(bundlePluginSource, pageSize, (pageCursor, numItems) =>
fetchPluginPage("bundle-plugin", pageCursor, numItems),
)
: Promise.resolve(null),
]);
if (!codePluginCandidate && !bundlePluginCandidate) break;
if (
!bundlePluginCandidate ||
(codePluginCandidate &&
compareCatalogItems(codePluginCandidate, bundlePluginCandidate) <= 0)
) {
items.push(codePluginCandidate!);
codePluginSource.index += 1;
} else {
items.push(bundlePluginCandidate);
bundlePluginSource.index += 1;
}
}
const nextState = {
codePlugins: finalizeCatalogSource(codePluginSource),
bundlePlugins: finalizeCatalogSource(bundlePluginSource),
};
const isDoneAll =
nextState.codePlugins.done &&
nextState.codePlugins.offset === 0 &&
nextState.bundlePlugins.done &&
nextState.bundlePlugins.offset === 0;
return json(
{
items,
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
},
200,
rate.headers,
);
}
const result = await runQueryRef<{
page: unknown[];
isDone: boolean;
@@ -637,6 +863,7 @@ async function listPackages(
family: effectiveFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -654,7 +881,10 @@ export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
}
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
return await listPackages(ctx, request, undefined, { includeSkills: false });
return await listPackages(ctx, request, undefined, {
includeSkills: false,
pluginFamilies: ["code-plugin", "bundle-plugin"],
});
}
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
@@ -821,6 +1051,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[1] === "rescan" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.packages.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
name: segments[0]!,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404);
}
@@ -870,14 +1125,27 @@ export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Reque
export async function packagesDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404);
}
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
if (segments.length === 1) {
try {
await runMutationRef(ctx, internalRefs.packages.softDeletePackageInternal, {
userId: auth.userId,
name: segments[0]!,
});
return json({ ok: true }, 200, rate.headers);
} catch (error) {
return softDeleteErrorToResponse("package", error, rate.headers);
}
}
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404, rate.headers);
}
try {
await runMutationRef(ctx, internalRefs.packages.deleteTrustedPublisherForUserInternal, {
actorUserId: auth.userId,
@@ -1013,7 +1281,7 @@ async function getSkillVersionForRequest(
async function searchPackages(
ctx: ActionCtx,
request: Request,
options?: { includeSkills?: boolean },
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
@@ -1025,6 +1293,11 @@ async function searchPackages(
const familyRaw = url.searchParams.get("family");
const channelRaw = url.searchParams.get("channel");
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const family =
@@ -1051,32 +1324,60 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
},
);
} else if (family || !includeSkills) {
results = await runQueryRef<CatalogSearchEntry[]>(
ctx,
internalRefs.packages.searchForViewerInternal,
{
if (!family && options?.pluginFamilies?.length) {
const pluginResults = await Promise.all(
options.pluginFamilies.map((pluginFamily) =>
searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
}),
),
);
const seen = new Set<string>();
results = pluginResults
.flat()
.filter((entry) => {
const key = `${entry.package.family}:${entry.package.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort(compareCatalogSearchEntries)
.slice(0, limit);
} else {
results = await searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
family,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
},
);
});
}
} else {
const [packageResults, skillResults] = await Promise.all([
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
searchPackageCatalogByListing(ctx, {
query: queryText,
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1086,6 +1387,7 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
}),
@@ -1098,12 +1400,7 @@ async function searchPackages(
seen.add(key);
return true;
})
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
compareCatalogItems(a.package, b.package),
)
.sort(compareCatalogSearchEntries)
.slice(0, limit);
}
return json({ results }, 200, rate.headers);
@@ -1393,6 +1690,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
});
}
const zip = buildDeterministicPackageZip(entries);
try {
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
packageId: publicPackage!._id,
});
} catch {
// Best-effort metric path; never fail package downloads.
}
return new Response(new Blob([zip], { type: "application/zip" }), {
status: 200,
headers: mergeHeaders(
@@ -1413,7 +1717,10 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request
const segments = getPathSegments(request, "/api/v1/plugins/");
if (segments.length === 0) return text("Not found", 404);
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
return await searchPackages(ctx, request, { includeSkills: false });
return await searchPackages(ctx, request, {
includeSkills: false,
pluginFamilies: ["code-plugin", "bundle-plugin"],
});
}
return text("Not found", 404);
}
+1 -1
View File
@@ -331,7 +331,7 @@ export function parsePublishBody(body: unknown) {
}
export function softDeleteErrorToResponse(
entity: "skill" | "soul",
entity: "skill" | "soul" | "package",
error: unknown,
headers: HeadersInit,
) {
+110 -17
View File
@@ -1,11 +1,15 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
import type { LlmEvalDimension } from "../lib/securityPrompt";
import type {
LlmAgenticRiskFinding,
LlmEvalDimension,
LlmRiskSummary,
} from "../lib/securityPrompt";
import { publishVersionForUser } from "../skills";
import {
MAX_RAW_FILE_BYTES,
@@ -71,6 +75,11 @@ type PublicSkillVersionParsed = {
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } };
};
type PublicSkillVersionStaticScan = Pick<
NonNullable<Doc<"skillVersions">["staticScan"]>,
"status" | "reasonCodes" | "summary" | "engineVersion" | "checkedAt"
>;
type PublicSkillVersionResponse = {
_id: Id<"skillVersions">;
version: string;
@@ -83,6 +92,7 @@ type PublicSkillVersionResponse = {
sha256hash?: string;
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
staticScan?: PublicSkillVersionStaticScan;
capabilityTags?: string[];
};
@@ -194,6 +204,14 @@ type SkillSecuritySnapshot = {
virustotalUrl: string | null;
capabilityTags: string[];
scanners: {
static: {
status: string;
normalizedStatus: NormalizedSecurityStatus;
reasonCodes: string[];
summary: string | null;
engineVersion: string | null;
checkedAt: number | null;
} | null;
vt: {
status: string;
verdict: string | null;
@@ -211,12 +229,24 @@ type SkillSecuritySnapshot = {
dimensions: LlmEvalDimension[] | null;
guidance: string | null;
findings: string | null;
agenticRiskFindings: LlmAgenticRiskFinding[] | null;
riskSummary: LlmRiskSummary | null;
model: string | null;
checkedAt: number | null;
} | null;
};
};
const internalRefs = internal as unknown as {
skills: {
requestRescanForApiTokenInternal: unknown;
};
};
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
function isDefinitiveSecurityStatus(
status: NormalizedSecurityStatus | null | undefined,
): status is "clean" | "suspicious" | "malicious" {
@@ -263,9 +293,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
);
}
function hasLlmDimensionWarnings(
dimensions: LlmEvalDimension[] | undefined,
) {
function hasLlmDimensionWarnings(dimensions: LlmEvalDimension[] | undefined) {
if (!Array.isArray(dimensions)) return false;
return dimensions.some((dimension) => {
if (!dimension || typeof dimension !== "object") return false;
@@ -277,30 +305,35 @@ function hasLlmDimensionWarnings(
function buildSkillSecuritySnapshot(
version: Pick<
PublicSkillVersionResponse,
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "capabilityTags"
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "staticScan" | "capabilityTags"
>,
): SkillSecuritySnapshot | null {
const capabilityTags = version.capabilityTags ?? [];
const sha256hash = version.sha256hash ?? null;
const vt = version.vtAnalysis;
const llm = version.llmAnalysis;
const staticScan = version.staticScan;
if (!sha256hash && !vt && !llm && capabilityTags.length === 0) return null;
if (!sha256hash && !vt && !llm && !staticScan && capabilityTags.length === 0) return null;
const staticStatus = staticScan ? normalizeSecurityStatus(staticScan.status) : null;
const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null;
const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null;
const statuses: NormalizedSecurityStatus[] = [];
if (staticStatus) statuses.push(staticStatus);
if (vtStatus) statuses.push(vtStatus);
if (llmStatus) statuses.push(llmStatus);
if (statuses.length === 0 && sha256hash) statuses.push("pending");
const status = mergeSecurityStatuses(statuses);
const hasScanResult =
isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus);
isDefinitiveSecurityStatus(staticStatus) ||
isDefinitiveSecurityStatus(vtStatus) ||
isDefinitiveSecurityStatus(llmStatus);
const hasWarnings =
status === "suspicious" || status === "malicious" || hasLlmDimensionWarnings(llm?.dimensions);
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter(
const checkedAtCandidates = [staticScan?.checkedAt, vt?.checkedAt, llm?.checkedAt].filter(
(value): value is number => typeof value === "number",
);
const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null;
@@ -315,6 +348,16 @@ function buildSkillSecuritySnapshot(
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
capabilityTags,
scanners: {
static: staticScan
? {
status: staticScan.status,
normalizedStatus: staticStatus ?? "pending",
reasonCodes: staticScan.reasonCodes ?? [],
summary: staticScan.summary ?? null,
engineVersion: staticScan.engineVersion ?? null,
checkedAt: staticScan.checkedAt ?? null,
}
: null,
vt: vt
? {
status: vt.status,
@@ -335,6 +378,8 @@ function buildSkillSecuritySnapshot(
dimensions: llm.dimensions ?? null,
guidance: llm.guidance ?? null,
findings: llm.findings ?? null,
agenticRiskFindings: llm.agenticRiskFindings ?? null,
riskSummary: llm.riskSummary ?? null,
model: llm.model ?? null,
checkedAt: llm.checkedAt ?? null,
}
@@ -402,6 +447,7 @@ export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Requ
}
type SkillListSort =
| "createdAt"
| "updated"
| "downloads"
| "stars"
@@ -409,8 +455,13 @@ type SkillListSort =
| "installsAllTime"
| "trending";
type PublicListSort = "newest" | "updated" | "downloads" | "stars" | "installs";
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase();
if (normalized === "createdat" || normalized === "created-at" || normalized === "newest") {
return "createdAt";
}
if (normalized === "downloads") return "downloads";
if (normalized === "stars" || normalized === "rating") return "stars";
if (
@@ -428,6 +479,13 @@ function parseListSort(value: string | null): SkillListSort {
return "updated";
}
function toPublicListSort(sort: Exclude<SkillListSort, "trending">): PublicListSort {
if (sort === "createdAt") return "newest";
if (sort === "updated") return "updated";
if (sort === "downloads" || sort === "stars") return sort;
return "installs";
}
export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
@@ -442,12 +500,24 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
url.searchParams.get("nonSuspicious"),
);
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
nonSuspiciousOnly: nonSuspiciousOnly || undefined,
})) as ListSkillsResult;
let result: ListSkillsResult;
if (sort === "trending") {
result = (await ctx.runQuery(api.skills.listPublicTrendingPage, {
limit,
nonSuspiciousOnly: nonSuspiciousOnly || undefined,
})) as ListSkillsResult;
} else {
const pageResult = (await ctx.runQuery(api.skills.listPublicPageV4, {
cursor,
numItems: limit,
sort: toPublicListSort(sort),
nonSuspiciousOnly: nonSuspiciousOnly || undefined,
})) as { page?: ListSkillsResult["items"]; nextCursor?: string | null };
result = {
items: pageResult.page ?? [],
nextCursor: pageResult.nextCursor ?? null,
};
}
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveTagsBatch(
@@ -675,7 +745,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
summary: mod.summary,
engineVersion: mod.engineVersion,
updatedAt: mod.updatedAt,
evidence: sanitizeEvidence(mod.evidence, Boolean(isOwner || isStaff)),
evidence: sanitizeEvidence(mod.evidence, isOwner || isStaff),
legacyReason: isOwner || isStaff ? mod.reason : null,
}
: null,
@@ -909,7 +979,7 @@ export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
}
function hasAcceptedLegacyLicenseTerms(acceptLicenseTerms: boolean | undefined) {
return acceptLicenseTerms !== false;
return acceptLicenseTerms === true;
}
type TransferDecisionAction = "accept" | "reject" | "cancel";
@@ -1118,6 +1188,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
}
}
if (segments.length === 2 && action === "rescan") {
if (!slug) return text("Slug required", 400, rate.headers);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.skills.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
slug,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (action === "transfer") {
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
}
+25
View File
@@ -24,6 +24,7 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
const action = segments[0];
if (
action !== "ban" &&
action !== "unban" &&
action !== "role" &&
action !== "restore" &&
action !== "reclaim" &&
@@ -109,6 +110,30 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
}
}
if (action === "unban") {
const reason = reasonRaw.length > 0 ? reasonRaw : undefined;
if (reason && reason.length > 500) {
return text("Reason too long (max 500 chars)", 400, rate.headers);
}
try {
const result = await ctx.runMutation(internal.users.unbanUserInternal, {
actorUserId,
targetUserId,
reason,
});
return json(result, 200, rate.headers);
} catch (error) {
const message = error instanceof Error ? error.message : "Unban failed";
if (message.toLowerCase().includes("forbidden")) {
return text("Forbidden", 403, rate.headers);
}
if (message.toLowerCase().includes("not found")) {
return text(message, 404, rate.headers);
}
return text(message, 400, rate.headers);
}
}
if (!role) {
return text("Invalid role", 400, rate.headers);
}
+1
View File
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
},
200,
+62 -6
View File
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
export type Role = "admin" | "moderator" | "user";
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
function readEnv(name: string) {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function isDevImpersonationAllowed() {
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
return (
deployment.startsWith("anonymous:") ||
deployment.startsWith("dev:") ||
deployment.startsWith("local:") ||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
);
}
async function getDevImpersonatedUserId(
ctx: Pick<MutationCtx | QueryCtx, "db">,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
.unique();
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
async function getDevImpersonatedUserIdFromAction(
ctx: ActionCtx,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
});
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
export async function getOptionalActiveAuthUserId(
ctx: MutationCtx | QueryCtx,
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserId(ctx);
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserId(ctx);
}
}
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserIdFromAction(ctx);
}
}
export async function requireUser(ctx: MutationCtx | QueryCtx) {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserId(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
export async function requireUserFromAction(
ctx: ActionCtx,
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Scheduler } from "convex/server";
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
export function scheduleNextBatchIfNeeded(
scheduler: Scheduler,
fn: unknown,
args: TArgs,
args: { cursor?: string } & Record<string, unknown>,
isDone: boolean,
continueCursor: string | null,
) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
depRegistryUrl,
mergeDepRegistryFinding,
parseDependencyFile,
summarizeDepRegistryChecks,
} from "./depRegistryScan";
import { summarizeReasonCodes, verdictFromCodes } from "./moderationReasonCodes";
describe("depRegistryScan", () => {
it("parses registry dependency manifests and skips vendored or non-registry specs", () => {
expect(
parseDependencyFile(
"package.json",
JSON.stringify({
dependencies: {
"@types/node": "^24.0.0",
local: "file:../local",
remote: "github:owner/repo",
},
optionalDependencies: {
undici: "^7.0.0",
},
}),
),
).toEqual([
{ name: "@types/node", registry: "npm", source: "package.json" },
{ name: "undici", registry: "npm", source: "package.json" },
]);
expect(
parseDependencyFile("vendor/package.json", '{"dependencies":{"phantom":"1.0.0"}}'),
).toEqual([]);
expect(
parseDependencyFile(
"requirements.txt",
["requests>=2", "demo @ git+https://example.test/demo.git", "-r dev.txt"].join("\n"),
),
).toEqual([{ name: "requests", registry: "pypi", source: "requirements.txt" }]);
});
it("keeps npm scope names compatible with registry URL lookup", () => {
expect(depRegistryUrl("npm", "@types/node")).toBe("https://registry.npmjs.org/@types%2Fnode");
});
it("does not produce clean status when registry lookups are unresolved", () => {
const analysis = summarizeDepRegistryChecks({
checkedAt: 123,
results: [{ name: "requests", registry: "pypi", source: "requirements.txt", exists: true }],
unresolved: [
{
name: "maybe-real",
registry: "npm",
source: "package.json",
reason: "network error",
},
],
});
expect(analysis.status).toBe("error");
expect(analysis.notFoundPackages).toEqual([]);
expect(analysis.unresolvedPackages).toEqual(["maybe-real (npm)"]);
});
it("injects a static finding only for confirmed missing packages", () => {
const suspicious = summarizeDepRegistryChecks({
checkedAt: 456,
results: [
{
name: "phantom-package-xyz",
registry: "npm",
source: "package.json",
exists: false,
httpStatus: 404,
},
],
unresolved: [],
});
const merged = mergeDepRegistryFinding({
staticScan: undefined,
analysis: suspicious,
statusFromCodes: verdictFromCodes,
summarizeCodes: summarizeReasonCodes,
});
expect(merged.status).toBe("suspicious");
expect(merged.reasonCodes).toEqual(["suspicious.dep_not_found_on_registry"]);
expect(merged.findings[0]?.file).toBe("Dependency manifests");
const cleanAgain = mergeDepRegistryFinding({
staticScan: merged,
analysis: summarizeDepRegistryChecks({ checkedAt: 789, results: [], unresolved: [] }),
statusFromCodes: verdictFromCodes,
summarizeCodes: summarizeReasonCodes,
});
expect(cleanAgain.status).toBe("clean");
expect(cleanAgain.findings).toEqual([]);
});
});
+321
View File
@@ -0,0 +1,321 @@
import {
MODERATION_ENGINE_VERSION,
REASON_CODES,
type ModerationFinding,
type ModerationVerdict,
} from "./moderationReasonCodes";
export const SUPPORTED_DEP_REGISTRIES = ["pypi", "npm", "cargo"] as const;
export type SupportedDepRegistry = (typeof SUPPORTED_DEP_REGISTRIES)[number];
export type DepRegistryStatus = "clean" | "suspicious" | "error";
export type DepEntry = {
name: string;
registry: SupportedDepRegistry;
source: string;
};
export type DepRegistryResult = DepEntry & {
exists: boolean;
httpStatus?: number;
};
export type DepRegistryUnresolved = DepEntry & {
reason: string;
};
export type DepRegistryAnalysis = {
status: DepRegistryStatus;
results: DepRegistryResult[];
notFoundPackages: string[];
unresolvedPackages: string[];
summary: string;
checkedAt: number;
};
const DEP_FILE_PARSERS: Record<string, (content: string, path: string) => DepEntry[]> = {
"requirements.txt": parseRequirementsTxt,
"requirements-dev.txt": parseRequirementsTxt,
"requirements_dev.txt": parseRequirementsTxt,
"requirements-test.txt": parseRequirementsTxt,
"requirements_test.txt": parseRequirementsTxt,
"package.json": parsePackageJson,
"cargo.toml": parseCargoToml,
"pyproject.toml": parsePyprojectToml,
};
const NON_REGISTRY_NPM_SPEC_PREFIXES = [
"file:",
"link:",
"git+",
"git://",
"github:",
"bitbucket:",
"gist:",
"http:",
"https:",
"workspace:",
"npm:",
];
const VENDORED_PATH_PATTERNS = [
/(^|\/)node_modules\//,
/(^|\/)vendor\//,
/(^|\/)__pycache__\//,
/(^|\/)\.venv\//,
/(^|\/)venv\//,
/(^|\/)target\//,
/(^|\/)\.cargo\//,
/(^|\/)dist\//,
/(^|\/)build\//,
];
function normalizeName(name: string, registry: SupportedDepRegistry) {
const normalized = name.trim().toLowerCase();
return registry === "cargo" ? normalized.replaceAll("_", "-") : normalized;
}
export function isVendoredDependencyPath(path: string) {
return VENDORED_PATH_PATTERNS.some((pattern) => pattern.test(path));
}
export function parseDependencyFile(path: string, content: string): DepEntry[] {
if (isVendoredDependencyPath(path)) return [];
const basename = path.split("/").pop()?.toLowerCase() ?? "";
const parser = DEP_FILE_PARSERS[basename];
return parser ? dedupeDeps(parser(content, path)) : [];
}
export function dedupeDeps(entries: DepEntry[]) {
const seen = new Set<string>();
return entries.filter((entry) => {
const key = `${entry.registry}:${entry.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function stripInlineComment(line: string) {
return line.replace(/\s+#.*$/, "").trim();
}
function parseRequirementsTxt(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (!line || line.startsWith("-")) continue;
if (/^(?:git\+|https?:|file:|\.{0,2}\/)/i.test(line)) continue;
if (/\s@\s/.test(line)) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
if (!match) continue;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
return entries;
}
function parsePackageJson(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(content) as Record<string, unknown>;
} catch {
return entries;
}
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
const deps = pkg[field];
if (!deps || typeof deps !== "object" || Array.isArray(deps)) continue;
for (const [rawName, rawSpec] of Object.entries(deps as Record<string, unknown>)) {
const spec = typeof rawSpec === "string" ? rawSpec.trim().toLowerCase() : "";
if (NON_REGISTRY_NPM_SPEC_PREFIXES.some((prefix) => spec.startsWith(prefix))) continue;
entries.push({ name: normalizeName(rawName, "npm"), registry: "npm", source: path });
}
}
return entries;
}
function parseCargoToml(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let inDepSection = false;
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (/^\[.*\]$/.test(line)) {
const section = line.replace(/[[\]\s]/g, "").toLowerCase();
inDepSection =
section === "dependencies" ||
section === "dev-dependencies" ||
section === "build-dependencies";
continue;
}
if (!inDepSection || !line) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*=/);
if (!match) continue;
entries.push({ name: normalizeName(match[1], "cargo"), registry: "cargo", source: path });
}
return entries;
}
function parsePyprojectToml(content: string, path: string): DepEntry[] {
const entries: DepEntry[] = [];
let inDepArray = false;
let inPoetryDepTable = false;
for (const rawLine of content.split("\n")) {
const line = stripInlineComment(rawLine);
if (/^\[.*\]$/.test(line)) {
inDepArray = false;
const section = line.replace(/[[\]\s]/g, "").toLowerCase();
inPoetryDepTable =
section === "tool.poetry.dependencies" ||
section === "tool.poetry.dev-dependencies" ||
section === "tool.poetry.group.dev.dependencies";
continue;
}
if (/^dependencies\s*=\s*\[/.test(line)) {
inDepArray = true;
const inline = line.match(/\[\s*(.*)\s*\]/);
if (inline) {
for (const item of extractQuotedStrings(inline[1])) addPyPiDependency(entries, item, path);
inDepArray = false;
}
continue;
}
if (inDepArray) {
if (line === "]") {
inDepArray = false;
continue;
}
const quoted = line.match(/^["']([^"']+)["']/);
if (quoted) addPyPiDependency(entries, quoted[1], path);
continue;
}
if (!inPoetryDepTable || !line) continue;
const match = line.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)\s*=/);
if (!match || match[1].toLowerCase() === "python") continue;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
return entries;
}
function addPyPiDependency(entries: DepEntry[], spec: string, path: string) {
if (/\s@\s/.test(spec)) return;
const match = spec.match(/^([a-zA-Z0-9_][a-zA-Z0-9._-]*)/);
if (!match) return;
entries.push({ name: normalizeName(match[1], "pypi"), registry: "pypi", source: path });
}
function extractQuotedStrings(s: string) {
return [...s.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
}
export function depRegistryUrl(registry: SupportedDepRegistry, packageName: string) {
const encoded =
registry === "npm" && packageName.startsWith("@")
? `@${encodeURIComponent(packageName.slice(1))}`
: encodeURIComponent(packageName);
if (registry === "pypi") return `https://pypi.org/pypi/${encoded}/json`;
if (registry === "npm") return `https://registry.npmjs.org/${encoded}`;
return `https://crates.io/api/v1/crates/${encoded}`;
}
export function summarizeDepRegistryChecks(params: {
results: DepRegistryResult[];
unresolved: DepRegistryUnresolved[];
checkedAt?: number;
}): DepRegistryAnalysis {
const notFound = params.results.filter((result) => !result.exists);
const notFoundPackages = notFound.map((result) => `${result.name} (${result.registry})`);
const unresolvedPackages = params.unresolved.map(
(result) => `${result.name} (${result.registry})`,
);
const checkedAt = params.checkedAt ?? Date.now();
if (notFoundPackages.length > 0) {
const partial =
unresolvedPackages.length > 0
? ` ${unresolvedPackages.length} package(s) could not be checked and will be retried.`
: "";
return {
status: "suspicious",
results: params.results,
notFoundPackages,
unresolvedPackages,
summary: `${notFoundPackages.length} declared dependency package(s) were not found on their public registry: ${notFoundPackages.join(", ")}.${partial}`,
checkedAt,
};
}
if (unresolvedPackages.length > 0) {
return {
status: "error",
results: params.results,
notFoundPackages: [],
unresolvedPackages,
summary: `${unresolvedPackages.length} dependency package(s) could not be verified due to registry lookup errors. The scan will be retried.`,
checkedAt,
};
}
return {
status: "clean",
results: params.results,
notFoundPackages: [],
unresolvedPackages: [],
summary: `All ${params.results.length} declared dependency package(s) verified as present on their public registries.`,
checkedAt,
};
}
export function buildDepRegistryFinding(analysis: DepRegistryAnalysis): ModerationFinding | null {
if (analysis.status !== "suspicious" || analysis.notFoundPackages.length === 0) return null;
return {
code: REASON_CODES.DEP_NOT_FOUND,
severity: "critical",
file: "Dependency manifests",
line: 1,
message: `${analysis.notFoundPackages.length} package(s) referenced in dependency files do not exist on their public registries: ${analysis.notFoundPackages.join(", ")}`,
evidence:
"An attacker could register these phantom package names and inject malicious install-time code through dependency confusion.",
};
}
export function mergeDepRegistryFinding(params: {
staticScan:
| {
status: ModerationVerdict;
reasonCodes: string[];
findings: ModerationFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
}
| undefined;
analysis: DepRegistryAnalysis;
statusFromCodes: (codes: string[]) => ModerationVerdict;
summarizeCodes: (codes: string[]) => string;
}) {
const base = params.staticScan ?? {
status: "clean" as ModerationVerdict,
reasonCodes: [],
findings: [],
summary: "No suspicious patterns detected.",
engineVersion: MODERATION_ENGINE_VERSION,
checkedAt: params.analysis.checkedAt,
};
const findings = base.findings.filter((finding) => finding.code !== REASON_CODES.DEP_NOT_FOUND);
const depFinding = buildDepRegistryFinding(params.analysis);
if (depFinding) findings.push(depFinding);
const reasonCodes = Array.from(new Set(findings.map((finding) => finding.code))).sort((a, b) =>
a.localeCompare(b),
);
return {
...base,
status: params.statusFromCodes(reasonCodes),
reasonCodes,
findings,
summary: params.summarizeCodes(reasonCodes),
checkedAt: params.analysis.checkedAt,
};
}
+57 -1
View File
@@ -1,8 +1,9 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
extractWorkflowFilenameFromWorkflowRef,
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
type TrustedGitHubActionsPublisher,
} from "./githubActionsOidc";
@@ -30,6 +31,10 @@ const signingKeyPairPromise = crypto.subtle.generateKey(
["sign", "verify"],
);
afterEach(() => {
vi.unstubAllEnvs();
});
describe("extractWorkflowFilenameFromWorkflowRef", () => {
it("extracts the workflow filename from workflow_ref", () => {
expect(
@@ -41,6 +46,57 @@ describe("extractWorkflowFilenameFromWorkflowRef", () => {
});
});
describe("fetchGitHubRepositoryIdentity", () => {
it("uses GITHUB_TOKEN for repository lookup when configured", async () => {
vi.stubEnv("GITHUB_TOKEN", "ghs_test_token");
const fetchMock = vi.fn(async () =>
Response.json({
id: 123,
full_name: "openclaw/clawhub",
owner: { login: "openclaw", id: 456 },
}),
);
await expect(fetchGitHubRepositoryIdentity("openclaw/clawhub", fetchMock)).resolves.toEqual({
repository: "openclaw/clawhub",
repositoryId: "123",
repositoryOwner: "openclaw",
repositoryOwnerId: "456",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/repos/openclaw/clawhub",
expect.objectContaining({
headers: expect.objectContaining({
Accept: "application/vnd.github+json",
Authorization: "Bearer ghs_test_token",
"User-Agent": "clawhub/package-trusted-publisher",
}),
}),
);
});
it("omits Authorization for repository lookup when GITHUB_TOKEN is blank", async () => {
vi.stubEnv("GITHUB_TOKEN", " ");
const fetchMock = vi.fn(async () =>
Response.json({
id: 123,
full_name: "openclaw/clawhub",
owner: { login: "openclaw", id: 456 },
}),
);
await fetchGitHubRepositoryIdentity("openclaw/clawhub", fetchMock);
expect(fetchMock).toHaveBeenCalledWith("https://api.github.com/repos/openclaw/clawhub", {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "clawhub/package-trusted-publisher",
},
});
});
});
describe("verifyGitHubActionsTrustedPublishJwt", () => {
it("accepts a valid GitHub Actions token", async () => {
const { token, jwks } = await createSignedToken({
+17 -8
View File
@@ -217,10 +217,7 @@ export async function fetchGitHubRepositoryIdentity(
throw new Error(`Invalid GitHub repository: ${repository}`);
}
const response = await fetchImpl(`https://api.github.com/repos/${normalizedRepository}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "clawhub/package-trusted-publisher",
},
headers: buildGitHubRepositoryLookupHeaders(),
});
if (!response.ok) {
throw new Error(
@@ -242,6 +239,18 @@ export async function fetchGitHubRepositoryIdentity(
};
}
function buildGitHubRepositoryLookupHeaders() {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "clawhub/package-trusted-publisher",
};
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
export function normalizeGitHubRepository(repository: string) {
const trimmed = repository
.trim()
@@ -279,8 +288,8 @@ function decodeJwt(jwt: string) {
const parts = jwt.trim().split(".");
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
const header = parseJsonSegment(encodedHeader, "header") as JwtHeader;
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
return {
header,
payload,
@@ -289,9 +298,9 @@ function decodeJwt(jwt: string) {
};
}
function parseJsonSegment<T>(segment: string, label: string) {
function parseJsonSegment(segment: string, label: string): unknown {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
} catch {
throw new Error(`Invalid GitHub OIDC ${label}`);
}
+5 -16
View File
@@ -11,7 +11,9 @@ type SkillVisibilityFields = Pick<
type GlobalStatsReadCtx = Pick<MutationCtx | QueryCtx, "db">;
type GlobalStatsWriteCtx = Pick<MutationCtx, "db">;
export function isPublicSkillDoc(skill: SkillVisibilityFields | null | undefined) {
export function isPublicSkillDoc<T extends SkillVisibilityFields>(
skill: T | null | undefined,
): skill is T {
if (!skill || skill.softDeletedAt) return false;
if (skill.moderationStatus && skill.moderationStatus !== "active") return false;
if (skill.moderationFlags?.includes("blocked.malware")) return false;
@@ -52,18 +54,6 @@ export function isGlobalStatsStorageNotReadyError(error: unknown) {
);
}
export async function countPublicSkillsForGlobalStats(ctx: GlobalStatsReadCtx) {
const digests = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.collect();
let count = 0;
for (const digest of digests) {
if (isPublicSkillDoc(digest)) count += 1;
}
return count;
}
export async function setGlobalPublicSkillsCount(
ctx: GlobalStatsWriteCtx,
count: number,
@@ -117,9 +107,8 @@ export async function adjustGlobalPublicSkillsCount(
}
if (!existing) {
// No baseline yet (e.g. fresh deploy). Initialize via full recount once.
const count = await countPublicSkillsForGlobalStats(ctx);
await setGlobalPublicSkillsCount(ctx, count, now);
// No baseline yet. The paginated stats maintenance action reconciles the full count.
await setGlobalPublicSkillsCount(ctx, Math.max(0, normalizedDelta), now);
return;
}
+44
View File
@@ -268,6 +268,50 @@ describe("applyRateLimit headers", () => {
expect(result.response.headers.get("Retry-After")).toBe("30");
});
it("scopes anonymous download fallback buckets when client ip is missing", async () => {
vi.spyOn(Date, "now").mockReturnValue(4_500_000);
const ctx = makeRateLimitCtx({
ip: {
allowed: true,
remaining: 19,
limit: 20,
resetAt: 4_530_000,
},
});
const request = new Request(
"https://example.com/api/v1/packages/tickflow-assist/download?version=0.2.10",
);
const result = await applyRateLimit(ctx, request, "download");
expect(result.ok).toBe(true);
const runMutation = (ctx as unknown as { runMutation: ReturnType<typeof vi.fn> }).runMutation;
const consumedKeys = runMutation.mock.calls.map(([, args]) => String(args.key));
expect(consumedKeys).toContain(
"ip:unknown:download:/api/v1/packages/tickflow-assist/download?version=0.2.10",
);
});
it("keeps non-download missing-ip anonymous requests on the shared unknown bucket", async () => {
vi.spyOn(Date, "now").mockReturnValue(4_600_000);
const ctx = makeRateLimitCtx({
ip: {
allowed: true,
remaining: 19,
limit: 20,
resetAt: 4_630_000,
},
});
const request = new Request("https://example.com/api/v1/search?q=demo");
const result = await applyRateLimit(ctx, request, "read");
expect(result.ok).toBe(true);
const runMutation = (ctx as unknown as { runMutation: ReturnType<typeof vi.fn> }).runMutation;
const consumedKeys = runMutation.mock.calls.map(([, args]) => String(args.key));
expect(consumedKeys).toContain("ip:unknown");
});
it("falls back to ip enforcement when bearer token is invalid", async () => {
vi.spyOn(Date, "now").mockReturnValue(5_000_000);
const ctx = makeRateLimitCtx({
+34 -4
View File
@@ -60,7 +60,11 @@ export async function applyRateLimit(
}
// Anonymous requests remain IP-enforced.
const ipResult = await checkRateLimit(ctx, `ip:${ip}`, RATE_LIMITS[kind].ip);
const ipResult = await checkRateLimit(
ctx,
getAnonymousRateLimitKey(request, kind, ip),
RATE_LIMITS[kind].ip,
);
const headers = rateHeaders(ipResult);
if (!ipResult.allowed) {
@@ -91,6 +95,12 @@ export async function applyRateLimit(
return { ok: true, headers };
}
function getAnonymousRateLimitKey(request: Request, kind: keyof typeof RATE_LIMITS, ip: string) {
if (ip !== "unknown") return `ip:${ip}`;
if (kind !== "download") return "ip:unknown";
return `ip:unknown:download:${getDownloadRateLimitScope(request)}`;
}
export function getClientIp(request: Request) {
const cfHeader = request.headers.get("cf-connecting-ip");
if (cfHeader) return splitFirstIp(cfHeader);
@@ -189,10 +199,30 @@ function splitFirstIp(header: string | null) {
return trimmed || null;
}
function getDownloadRateLimitScope(request: Request) {
try {
const url = new URL(request.url);
const path = normalizeRateLimitKeyPart(url.pathname.replace(/\/{2,}/g, "/") || "/");
const params = new URLSearchParams();
for (const name of ["slug", "version", "tag"] as const) {
const value = url.searchParams.get(name)?.trim();
if (value) params.set(name, normalizeRateLimitKeyPart(value));
}
const query = params.toString();
return query ? `${path}?${query}` : path;
} catch {
return "unknown";
}
}
function normalizeRateLimitKeyPart(value: string) {
return value.slice(0, 500);
}
function shouldTrustForwardedIps() {
const value = String(process.env.TRUST_FORWARDED_IPS ?? "")
.trim()
.toLowerCase();
const value = (process.env.TRUST_FORWARDED_IPS ?? "").trim().toLowerCase();
// Hardening default: CF-only. Forwarded headers are trivial to spoof unless you
// control the trusted proxy layer.
if (!value) return false;
+16 -2
View File
@@ -36,7 +36,7 @@ describe("deriveModerationFlags", () => {
skill: {
slug: "test",
displayName: "Test",
summary: "Send data to discord.gg/xyz",
summary: "Send data to https://discord.com/api/webhooks/123/token",
},
parsed: { frontmatter: {} },
files: [],
@@ -44,6 +44,19 @@ describe("deriveModerationFlags", () => {
expect(flags).toContain("suspicious.webhook");
});
test("does not flag generic webhook integrations", () => {
const flags = deriveModerationFlags({
skill: {
slug: "wordpress-api",
displayName: "WordPress API",
summary: "Manage WordPress REST API webhooks and Zapier callbacks.",
},
parsed: { frontmatter: {} },
files: [],
});
expect(flags).not.toContain("suspicious.webhook");
});
test("flags slack webhooks", () => {
const flags = deriveModerationFlags({
skill: {
@@ -194,7 +207,8 @@ describe("deriveModerationFlags", () => {
skill: {
slug: "test",
displayName: "Test",
summary: "Malware stealer that posts to discord.gg webhooks via curl | bash from bit.ly",
summary:
"Malware stealer that posts to discord.gg/hook via curl | bash from bit.ly",
},
parsed: { frontmatter: {} },
files: [],
+6 -2
View File
@@ -11,8 +11,12 @@ const FLAG_RULES: Array<{ flag: string; pattern: RegExp }> = [
// Malicious intent keywords
{ flag: "suspicious.keyword", pattern: /(malware|stealer|phish|phishing|keylogger)/i },
// Data exfiltration patterns - webhooks are unusual in skills
{ flag: "suspicious.webhook", pattern: /(discord\.gg|webhook|hooks\.slack)/i },
// Data exfiltration patterns - flag explicit Discord/Slack webhook endpoints,
// not legitimate integrations that mention generic webhook support.
{
flag: "suspicious.webhook",
pattern: /(discord\.gg\/|discord\.com\/api\/webhooks|discordapp\.com\/api\/webhooks|hooks\.slack)/i,
},
// Arbitrary code execution - curl | bash is dangerous
{ flag: "suspicious.script", pattern: /(curl[^\n]+\|\s*(sh|bash))/i },
File diff suppressed because it is too large Load Diff
+823 -20
View File
@@ -13,6 +13,22 @@ import {
} from "./moderationReasonCodes";
type TextFile = { path: string; content: string };
type VirusTotalEngineStats = {
malicious?: number;
suspicious?: number;
undetected?: number;
harmless?: number;
};
type VirusTotalAnalysis = {
status?: string;
scanner?: string;
source?: string;
engineStats?: VirusTotalEngineStats;
metadata?: {
stats?: VirusTotalEngineStats;
};
};
export type StaticScanInput = {
slug: string;
@@ -49,6 +65,8 @@ const MARKDOWN_EXTENSION = /\.(md|markdown|mdx)$/i;
const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/i;
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
const CGNAT_HTTP_URL_PATTERN =
/http:\/\/100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}(?::\d+)?(?:\/[^\s"'`]*)?/i;
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
@@ -58,6 +76,91 @@ const HARDCODED_CONNECTION_ID_PATTERN =
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
const DESTRUCTIVE_DELETE_PATTERN =
/\brm\s+-[A-Za-z]*r[A-Za-z]*f[A-Za-z]*\s+(["']?)(\/root\/\.openclaw\/|\/home\/[^/\s"'`]+\/\.openclaw\/|\/Users\/[^/\s"'`]+\/\.openclaw\/|~\/\.openclaw\/|\$HOME\/\.openclaw\/|\$\{HOME\}\/\.openclaw\/|\/etc\/|\/usr\/|\/opt\/|\/Library\/|\/Applications\/)[^\s"'`;|&)]*\1/i;
const SHELL_POSITIONAL_ASSIGNMENT_PATTERN =
/^\s*([A-Z_][A-Z0-9_]*)=(["']?)\$(?:[1-9][0-9]*|@|\*)\2\s*(?:#.*)?$/gm;
const SECRET_ASSIGNMENT_PATTERN =
/\b(?:[A-Za-z0-9]+[_\s-]+)*(?:(?:api|client|consumer)[_\s-]?(?:secret|key|token)|secret[_\s-]?key|access[_\s-]?(?:token|key|secret|grant)|auth[_\s-]?token|bearer(?:[_\s-]?token)?|private[_\s-]?key|service[_\s-]?role[_\s-]?key|github[_\s-]?(?:pat|token)|(?:openrouter|supabase|storj)[_\s-]?(?:key|token|secret|access[_\s-]?grant)|password)\b\s*[:=]\s*["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
const AUTH_HEADER_SECRET_PATTERN =
/\b(?:authorization|x-api-key|x-api-secret)\b\s*[:=]\s*(?:Bearer\s+)?["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
const SHELL_CREDENTIAL_VARIABLE_PATTERN =
/\$(?:\{)?[A-Z_][A-Z0-9_]*(?:TOKEN|PAT|SECRET|KEY)[A-Z0-9_]*(?:\})?/;
const GIT_REMOTE_CREDENTIAL_URL_PATTERN =
/\bgit\s+remote\s+set-url\b[^\n]*https?:\/\/[^\s"'`]*\$(?:\{)?[A-Z_][A-Z0-9_]*(?:TOKEN|PAT|SECRET|KEY)[A-Z0-9_]*(?:\})?[^\s"'`]*@/i;
const MEMORY_CREDENTIAL_STORAGE_PATTERN =
/\bsave\s+(?:it|the\s+(?:token|secret|credential|key|pat))\s+to\s+(?:your\s+)?(?:memory|conversation|chat)\b/i;
const HOST_PLATFORM_SOURCE_CONTEXT_PATTERN =
/\$[{]?OPENCLAW_DIR[}]?.{0,200}\/src\/|\/src\/agents\/|\/src\/tools\//is;
const HOST_PLATFORM_PATCH_COMMAND_PATTERN =
/\b(?:sed\s+-i|perl\s+-0?pi|cp\s+|cat\s+>|python3?\b.{0,120}(?:write|replace))/i;
const HOST_PLATFORM_REBUILD_PATTERN = /\b(?:pnpm\s+build|npm\s+run\s+build|bun\s+run\s+build)\b/i;
const BROWSER_USE_PASSWORD_ARGV_PATTERN =
/\bbrowser-use\s+input\b[^\n]*(?:password|passwd|\$[A-Z_]*(?:PASSWORD|PASS|PWD)[A-Z0-9_]*|<password>|\{password\})/i;
const BROWSER_USE_AUTH_EVAL_PATTERN = /\bbrowser-use\s+(?:eval|python)\b/i;
const AUTHENTICATED_MAIL_CONTEXT_PATTERN = /\b(?:mail\.google\.com|gmail|webmail|mailbox|inbox)\b/i;
const PERSISTENCE_SCHEDULER_PATTERN =
/\b(?:launchctl\s+load|crontab\b|LaunchAgents\/|systemctl\s+(?:--user\s+)?enable)\b/i;
const SECRET_ARGV_WARNING_PATTERN =
/\b(?:do\s+not|don't|avoid|never|reject)\b[^\n]{0,120}\b(?:argv|argument|from-mnemonic|private[-_\s]?key|seed[-\s]?phrase|mnemonic)\b/i;
const FROM_MNEMONIC_ARGV_PATTERN =
/\b(?:npx|bunx|pnpm\s+dlx|npm\s+exec|node|python3?|uvx)\b[^\n]{0,200}\bfrom-mnemonic\b[^\n]{0,200}(?:"[^"\n]{8,}"|'[^'\n]{8,}'|<[^>\n]{6,}>|\$[A-Z_][A-Z0-9_]*(?:MNEMONIC|SEED|PHRASE)[A-Z0-9_]*)/i;
const SECRET_FLAG_ARGV_PATTERN =
/\b(?:npx|bunx|pnpm\s+dlx|npm\s+exec|node|python3?|uvx|docker\s+run)\b[^\n]{0,240}--(?:private-key|seed|seed-phrase|mnemonic|password|token)\s+(?:"[^"\n]{8,}"|'[^'\n]{8,}'|<[^>\n]{4,}>|\$[A-Z_][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|MNEMONIC|SEED|PHRASE)[A-Z0-9_]*)/i;
const SECRET_ARGV_REDACTION_PATTERN =
/(\b(?:from-mnemonic|--(?:private-key|seed|seed-phrase|mnemonic|password|token))\s+)(["'`])([^"'`]{8,})\2/gi;
const DYNAMIC_CODE_EXECUTION_PATTERN =
/\beval\s*\(|new\s+Function\s*\(|\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?loader\.exec_module\s*\(/;
const SHELL_BASE64_FILE_READ_PATTERN =
/(?:\bcat\s+["']?\$[A-Za-z_][A-Za-z0-9_]*["']?\s*\|\s*base64\b|\bbase64\b[^\n]{0,80}["']?\$[A-Za-z_][A-Za-z0-9_]*["']?)/i;
const SHELL_NETWORK_UPLOAD_PATTERN =
/\bcurl\b[\s\S]{0,1600}(?:--data(?:-binary|-raw)?\b|-d\b|--form\b|-F\b|--upload-file\b|Authorization\s*:)/i;
const PLAYWRIGHT_CHROMIUM_PATTERN = /\b(?:playwright\.)?chromium\.launch\s*\(/i;
const FILE_URL_BROWSER_NAVIGATION_PATTERN = /\bpage\.goto\s*\([^)]*file:\/\//i;
const SVG_HTML_INTERPOLATION_PATTERN =
/(?:<body>[\s\S]{0,240}\$\{[^}]*svg[^}]*\}|writeFile(?:Sync)?\s*\([^)]*\.html[^)]*\$\{[^}]*svg[^}]*\}|\$\{[^}]*svg[^}]*\}[\s\S]{0,240}<\/body>)/i;
const BROWSER_JS_DISABLED_PATTERN =
/javaScriptEnabled\s*:\s*false|Content-Security-Policy|script-src\s+['"]?none/i;
const AGENT_OUTPUT_DIR_ARGUMENT_PATTERN =
/add_argument\s*\(\s*["']--outdir["']|args\.outdir|output_path\s*=\s*Path\s*\(\s*args\.outdir\s*\)/i;
const FFMPEG_FORCE_OUTPUT_PATTERN =
/subprocess\.run\s*\(\s*\[[\s\S]{0,1000}["']ffmpeg["'][\s\S]{0,1000}["']-y["'][\s\S]{0,1000}str\s*\(\s*output_path\s*\)/i;
const OUTPUT_PATH_GUARD_PATTERN =
/TemporaryDirectory|mkdtemp|tempfile\.|resolve\s*\(\s*\).*relative_to|is_relative_to\s*\(/i;
const INSECURE_TLS_VERIFICATION_PATTERN =
/ssl\._create_unverified_context\s*\(|ssl\.CERT_NONE\b|check_hostname\s*=\s*False\b|verify\s*=\s*False\b|rejectUnauthorized\s*:\s*false\b|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*["']?0["']?/i;
const PYTHON_AGENT_FILENAME_PATTERN =
/\b(?:filename\s*:\s*str|req\.filename|filename\s*=|["']filename["'])\b/i;
const PYTHON_RCLONE_FILENAME_SINK_PATTERN =
/(?:rclone_dir\s*\/\s*filename|f["']\.\/\{filename\}|open\s*\(\s*temp_file_path\s*,|subprocess\.run\s*\([\s\S]{0,1000}["']\.\/rclone["'])/i;
const PYTHON_FILENAME_GUARD_PATTERN =
/\b(?:secure_filename|basename\s*\(|Path\s*\(\s*filename\s*\)\.name|filename\s*=\s*Path\s*\(\s*filename\s*\)\.name|resolve\s*\(\s*\).*relative_to|is_relative_to\s*\(|["']\.\.["']\s+in\s+filename|["']\/["']\s+in\s+filename)/i;
const PYTHON_CREDENTIAL_ENV_PATTERN =
/\b(?:os\.environ(?:\.get)?|os\.getenv|getenv)\s*(?:\[\s*|\(\s*)["'][A-Za-z_][A-Za-z0-9_]*(?:PASS|PASSWORD|SECRET|TOKEN|KEY)[A-Za-z0-9_]*["']/i;
const PYTHON_URL_ENV_PATTERN =
/\b(?:os\.environ(?:\.get)?|os\.getenv|getenv)\s*(?:\[\s*|\(\s*)["'][A-Za-z_][A-Za-z0-9_]*(?:BASE_URL|URL|HOST|ENDPOINT)[A-Za-z0-9_]*["']/i;
const PYTHON_HTTP_POST_PATTERN =
/\b(?:requests|session|self\.session|client)\.post\s*\(|\.post\s*\(/i;
const PASSWORD_PAYLOAD_PATTERN = /["']password["']\s*:|password\s*=/i;
const AUTONOMOUS_AGENT_SCHEDULE_PATTERN =
/\bAUTO_ANSWER\s*=\s*(?:true|os\.getenv\s*\(\s*["']AUTO_ANSWER["']\s*,\s*["']true["'])|while\s+True\s*:|time\.sleep\s*\(\s*(?:[3-9]\d{2,}|[1-9]\d{3,})\s*\)|\binterval\s*=\s*(?:[3-9]\d{2,}|[1-9]\d{3,})|"kind"\s*:\s*"cron"|"expr"\s*:\s*["'][^"']*\*\/(?:[1-5]?\d)\b/is;
const CREDENTIAL_BEARING_AGENT_PATTERN =
/\b(?:X-API-Key|api_key|API_KEY|VDOOB_API_KEY|AGENT_ID|agent_config\.json)\b/i;
const AUTONOMOUS_ANSWER_EGRESS_PATTERN =
/\b(?:requests|session|client)\.post\s*\([\s\S]{0,1000}(?:submit-answer|agent-withdrawals|agents\/register|messages\/agent)|\b(?:submit_answer|answer_question|act_cron_check)\b/i;
const HARDCODED_OPERATOR_BASE_URL_PATTERN =
/\bBASE_URL\s*=\s*["']https:\/\/(?!your-|example\.|localhost\b|127\.0\.0\.1\b)[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?(?:\/[^"']*)?["']/i;
const OAUTH_CLIENT_SECRET_FLOW_PATTERN =
/\b(?:oauth\/register|oauth\/token|client_secret|Authorization:\s*Bearer|ACCESS_TOKEN)\b/i;
const LIGHTNING_BILLING_FLOW_PATTERN =
/\b(?:billing\/agent\/(?:create|check)-invoice|amount_sats|LNURL|Lightning|PAYG)\b/i;
const OUTBOUND_POST_PATTERN = /\b(?:curl\s+-X\s+POST|requests\.post\s*\(|fetch\s*\()/i;
const REMOTE_RECIPE_FETCH_PATTERN =
/\b(?:curl|requests\.get|fetch)\b[\s\S]{0,600}(?:error-codes\.json|recipes?\.json|patterns\.json|docs\.openclaw\.ai)|ERROR_CODES_URL\s*=/i;
const MUTABLE_RECIPE_STORE_PATTERN =
/\b(?:error-patterns\.json|recipes?\.json|safe_auto|fix_recipe_id|["']command["'])\b/i;
const TEMPLATED_SUBPROCESS_EXECUTION_PATTERN =
/\bsubstitute_params\s*\([\s\S]{0,500}\b(?:shlex\.split|subprocess\.run)\b|\b(?:shlex\.split|subprocess\.run)\b[\s\S]{0,500}\bsubstitute_params\s*\(/i;
function hasMaliciousInstallPrompt(content: string) {
const hasTerminalInstruction =
@@ -87,6 +190,198 @@ function looksLikePlaceholderIdentifier(identifier: string) {
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
}
function looksLikePlaceholderSecret(secret: string) {
const normalized = secret.trim().toLowerCase();
if (!normalized) return true;
if (/^(?:x+|_+|-+|\*+|\.{3})$/.test(normalized)) return true;
if (/process\.env\.|os\.environ[.[]|getenv\s*\(/.test(normalized)) return true;
return /(your|example|placeholder|change-?me|replace|redacted|dummy|sample|test-token|token-here|secret-here|api-key-here)/i.test(
normalized,
);
}
function findHardcodedSecret(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const match = line.match(SECRET_ASSIGNMENT_PATTERN) ?? line.match(AUTH_HEADER_SECRET_PATTERN);
const secret = match?.[1];
if (!secret || looksLikePlaceholderSecret(secret)) continue;
return {
line: i + 1,
text: line.replaceAll(secret, "[REDACTED]"),
};
}
return null;
}
function findCredentialExposureInstruction(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (
GIT_REMOTE_CREDENTIAL_URL_PATTERN.test(line) ||
(MEMORY_CREDENTIAL_STORAGE_PATTERN.test(line) &&
SHELL_CREDENTIAL_VARIABLE_PATTERN.test(content))
) {
return { line: i + 1, text: line };
}
}
return null;
}
function findBrowserCredentialAutomation(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (BROWSER_USE_PASSWORD_ARGV_PATTERN.test(line)) {
return { line: i + 1, text: line };
}
}
if (
BROWSER_USE_AUTH_EVAL_PATTERN.test(content) &&
AUTHENTICATED_MAIL_CONTEXT_PATTERN.test(content) &&
PERSISTENCE_SCHEDULER_PATTERN.test(content)
) {
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (BROWSER_USE_AUTH_EVAL_PATTERN.test(line) || PERSISTENCE_SCHEDULER_PATTERN.test(line)) {
return { line: i + 1, text: line };
}
}
}
return null;
}
function redactSecretArgvEvidence(line: string) {
return line.replace(SECRET_ARGV_REDACTION_PATTERN, "$1$2[REDACTED]$2");
}
function findSecretArgvExposure(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (SECRET_ARGV_WARNING_PATTERN.test(line)) continue;
if (FROM_MNEMONIC_ARGV_PATTERN.test(line) || SECRET_FLAG_ARGV_PATTERN.test(line)) {
return { line: i + 1, text: redactSecretArgvEvidence(line) };
}
}
return null;
}
function findHostPlatformSourcePatch(content: string) {
if (!HOST_PLATFORM_SOURCE_CONTEXT_PATTERN.test(content)) return null;
if (!HOST_PLATFORM_REBUILD_PATTERN.test(content)) return null;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (!HOST_PLATFORM_PATCH_COMMAND_PATTERN.test(line)) continue;
if (hasNearbyConfirmationGate(lines, i)) continue;
return { line: i + 1, text: line };
}
return null;
}
function scanSecretLiteralFile(path: string, content: string, findings: ModerationFinding[]) {
const secretMatch = findHardcodedSecret(content);
if (!secretMatch) return;
addFinding(findings, {
code: REASON_CODES.EXPOSED_SECRET_LITERAL,
severity: "critical",
file: path,
line: secretMatch.line,
message: "File appears to expose a hardcoded API secret or token.",
evidence: secretMatch.text,
});
}
function scanPlaintextCgnatEndpointFile(
path: string,
content: string,
findings: ModerationFinding[],
) {
if (!CGNAT_HTTP_URL_PATTERN.test(content)) return;
const match = findFirstLine(content, CGNAT_HTTP_URL_PATTERN);
addFinding(findings, {
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
severity: "critical",
file: path,
line: match.line,
message: "Plaintext HTTP endpoint targets a CGNAT/Tailscale-range address.",
evidence: match.text,
});
}
function hasNearbyConfirmationGate(lines: string[], commandIndex: number) {
const start = Math.max(0, commandIndex - 8);
const context = lines.slice(start, commandIndex + 1).join("\n");
return [
/\bask\s+(?:the\s+)?user\b.{0,120}\b(?:confirm|confirmation|approve|approval|continue|yes)\b/is,
/\b(?:prompt\s+for|require|request|obtain)\s+(?:explicit\s+)?(?:user\s+)?(?:confirmation|approval)\b/is,
/\buser\s+(?:confirmation|approval)\b/is,
/\bcontinue\?\s*\(?(?:yes\/no|y\/n)\)?/is,
/\breply\s+["']?yes["']?\b/is,
/\bonly\s+(?:continue\s+)?after\s+(?:the\s+)?user\b.{0,80}\b(?:confirms?|approves?|answers?\s+yes)\b/is,
].some((pattern) => pattern.test(context));
}
function findUnguardedDestructiveDelete(content: string) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!DESTRUCTIVE_DELETE_PATTERN.test(lines[i])) continue;
if (hasNearbyConfirmationGate(lines, i)) continue;
return { line: i + 1, text: lines[i] };
}
return null;
}
function hasShellVariableValidation(content: string, variable: string, useIndex: number) {
const escaped = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const beforeUse = content.slice(0, useIndex);
const variableReference = String.raw`(?:\$\{${escaped}\}|\$${escaped})`;
const lengthCheck = new RegExp(
String.raw`\$\{#${escaped}\}\s*(?:-[a-z]\s+)?(?:[<>!=]=?|-[gl][te])`,
"m",
);
const controlCharStrip = new RegExp(
String.raw`(?:tr\s+-d\s+["']?\\(?:000|x00).{0,80}\\(?:037|x1[fF]|177|x7[fF])|${escaped}\s*=.*tr\s+-d)`,
"s",
);
const explicitValidation = new RegExp(
String.raw`(?:validate|sanitize|strip|clean)[A-Za-z0-9_ -]{0,60}${variableReference}|${variableReference}.{0,60}(?:validate|sanitize|strip|clean)`,
"is",
);
return (
lengthCheck.test(beforeUse) ||
controlCharStrip.test(beforeUse) ||
explicitValidation.test(beforeUse)
);
}
function findUnsafeBrowserTextInput(content: string) {
for (const assignment of content.matchAll(SHELL_POSITIONAL_ASSIGNMENT_PATTERN)) {
const variable = assignment[1];
if (!variable) continue;
const escaped = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const browserTextPattern = new RegExp(
String.raw`\bbrowser\s+action=act\b[^\n]*\bkind=["']?type["']?[^\n]*\btext=(?:"\$${escaped}"|'\$${escaped}'|\$${escaped})(?![A-Za-z0-9_])`,
"i",
);
const match = content.match(browserTextPattern);
if (!match || match.index === undefined) continue;
if (hasShellVariableValidation(content, variable, match.index)) continue;
return findLineAtIndex(content, match.index);
}
return null;
}
function addFinding(
findings: ModerationFinding[],
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
@@ -112,25 +407,272 @@ function findLineAtIndex(content: string, index: number) {
return { line, text: content.slice(lineStart, lineEnd) };
}
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
function findCallEnd(content: string, openParenIndex: number) {
let depth = 0;
let quote: '"' | "'" | "`" | undefined;
let escaped = false;
for (let i = openParenIndex; i < content.length; i += 1) {
const char = content[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) quote = undefined;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) return i + 1;
}
}
return content.length;
}
function isSafeLiteralExecFileCall(callText: string) {
const match = callText.match(/\b(execFile|execFileSync)\s*\(\s*(["'])([^"']+)\2\s*,\s*\[/);
if (!match) return false;
if (/\bshell\s*:\s*true\b/.test(callText)) return false;
const executable = match[3]?.trim().toLowerCase();
if (!executable) return false;
const basename = executable.split(/[\\/]/).at(-1) ?? executable;
return !/^(?:sh|bash|zsh|fish|cmd|powershell|pwsh)$/.test(basename);
}
function findDangerousChildProcessCall(content: string) {
if (!/child_process/.test(content)) return null;
const execPattern = /\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/g;
for (const match of content.matchAll(execPattern)) {
const callName = match[1];
const callIndex = match.index;
if (callIndex === undefined || !callName) continue;
if (callName === "execFile" || callName === "execFileSync") {
const openParenIndex = content.indexOf("(", callIndex);
const callEnd = findCallEnd(content, openParenIndex);
const callText = content.slice(callIndex, callEnd);
if (isSafeLiteralExecFileCall(callText)) continue;
}
return findLineAtIndex(content, callIndex);
}
return null;
}
function findShellBase64FileUpload(content: string) {
if (!/\bcurl\b/i.test(content) || !/\bbase64\b/i.test(content)) return null;
if (!SHELL_NETWORK_UPLOAD_PATTERN.test(content)) return null;
return findFirstLine(content, SHELL_BASE64_FILE_READ_PATTERN);
}
function findUnsafeBrowserFileRender(content: string) {
if (!PLAYWRIGHT_CHROMIUM_PATTERN.test(content)) return null;
if (!FILE_URL_BROWSER_NAVIGATION_PATTERN.test(content)) return null;
if (!SVG_HTML_INTERPOLATION_PATTERN.test(content)) return null;
if (BROWSER_JS_DISABLED_PATTERN.test(content)) return null;
return findFirstLine(content, FILE_URL_BROWSER_NAVIGATION_PATTERN);
}
function findUnsafeAgentControlledFileWrite(content: string) {
if (!AGENT_OUTPUT_DIR_ARGUMENT_PATTERN.test(content)) return null;
if (!FFMPEG_FORCE_OUTPUT_PATTERN.test(content)) return null;
if (OUTPUT_PATH_GUARD_PATTERN.test(content)) return null;
return findFirstLine(content, /subprocess\.run\s*\(|["']-y["']|output_path\s*=/);
}
function findUnsafePythonRcloneFilename(content: string) {
if (!PYTHON_AGENT_FILENAME_PATTERN.test(content)) return null;
if (!/\brclone\b/.test(content) || !/subprocess\.run\s*\(/.test(content)) return null;
if (!PYTHON_RCLONE_FILENAME_SINK_PATTERN.test(content)) return null;
if (PYTHON_FILENAME_GUARD_PATTERN.test(content)) return null;
return findFirstLine(
content,
/rclone_dir\s*\/\s*filename|f["']\.\/\{filename\}|subprocess\.run\s*\(/,
);
}
function findPythonCredentialPostToEnvUrl(content: string) {
if (!PYTHON_CREDENTIAL_ENV_PATTERN.test(content)) return null;
if (!PYTHON_URL_ENV_PATTERN.test(content)) return null;
if (!PYTHON_HTTP_POST_PATTERN.test(content)) return null;
if (!PASSWORD_PAYLOAD_PATTERN.test(content)) return null;
return findFirstLine(content, PYTHON_HTTP_POST_PATTERN);
}
function findAutonomousCredentialEgress(files: TextFile[]) {
const packageText = files.map((file) => file.content).join("\n");
if (!AUTONOMOUS_AGENT_SCHEDULE_PATTERN.test(packageText)) return null;
if (!CREDENTIAL_BEARING_AGENT_PATTERN.test(packageText)) return null;
if (!AUTONOMOUS_ANSWER_EGRESS_PATTERN.test(packageText)) return null;
for (const file of files) {
if (!AUTONOMOUS_ANSWER_EGRESS_PATTERN.test(file.content)) continue;
const match = findFirstLine(file.content, AUTONOMOUS_ANSWER_EGRESS_PATTERN);
return { file: file.path, line: match.line, text: match.text };
}
const fallback = files[0];
if (!fallback) return null;
return { file: fallback.path, line: 1, text: fallback.content.split("\n")[0] ?? "" };
}
function findRemoteRecipeExecution(files: TextFile[]) {
const packageText = files.map((file) => file.content).join("\n");
if (!REMOTE_RECIPE_FETCH_PATTERN.test(packageText)) return null;
if (!MUTABLE_RECIPE_STORE_PATTERN.test(packageText)) return null;
if (!TEMPLATED_SUBPROCESS_EXECUTION_PATTERN.test(packageText)) return null;
for (const file of files) {
if (!TEMPLATED_SUBPROCESS_EXECUTION_PATTERN.test(file.content)) continue;
const match = findFirstLine(
file.content,
/substitute_params\s*\(|shlex\.split|subprocess\.run/,
);
return { file: file.path, line: match.line, text: match.text };
}
const fallback = files[0];
if (!fallback) return null;
return { file: fallback.path, line: 1, text: fallback.content.split("\n")[0] ?? "" };
}
function findHardcodedOperatorBillingEndpoint(content: string) {
if (!HARDCODED_OPERATOR_BASE_URL_PATTERN.test(content)) return null;
if (!OAUTH_CLIENT_SECRET_FLOW_PATTERN.test(content)) return null;
if (!LIGHTNING_BILLING_FLOW_PATTERN.test(content)) return null;
if (!OUTBOUND_POST_PATTERN.test(content)) return null;
return findFirstLine(content, HARDCODED_OPERATOR_BASE_URL_PATTERN);
}
function normalizeEnvName(value: unknown) {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed.toUpperCase() : undefined;
}
function addDeclaredEnvName(names: Set<string>, value: unknown) {
const normalized = normalizeEnvName(value);
if (normalized) names.add(normalized);
}
function addDeclaredEnvNamesFromList(names: Set<string>, value: unknown) {
if (!Array.isArray(value)) return;
for (const entry of value) {
if (typeof entry === "string") {
addDeclaredEnvName(names, entry);
continue;
}
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
addDeclaredEnvName(names, (entry as { name?: unknown }).name);
}
}
}
function addDeclaredEnvNamesFromRecord(names: Set<string>, record: Record<string, unknown>) {
const requires =
record.requires && typeof record.requires === "object" && !Array.isArray(record.requires)
? (record.requires as Record<string, unknown>)
: undefined;
addDeclaredEnvName(names, record.primaryEnv);
addDeclaredEnvNamesFromList(names, record.envVars);
addDeclaredEnvNamesFromList(names, record.env);
addDeclaredEnvNamesFromList(names, requires?.env);
}
function addDeclaredEnvNamesFromManifestBlock(names: Set<string>, value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
addDeclaredEnvNamesFromRecord(names, value as Record<string, unknown>);
}
function collectDeclaredEnvNames(input: {
frontmatter: Record<string, unknown>;
metadata?: unknown;
}) {
const names = new Set<string>();
const sources: unknown[] = [input.frontmatter, input.metadata];
for (const source of sources) {
if (!source || typeof source !== "object" || Array.isArray(source)) continue;
const record = source as Record<string, unknown>;
addDeclaredEnvNamesFromRecord(names, record);
addDeclaredEnvNamesFromManifestBlock(names, record.openclaw);
addDeclaredEnvNamesFromManifestBlock(names, record.clawdis);
addDeclaredEnvNamesFromManifestBlock(names, record.clawdbot);
if (record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata)) {
const metadata = record.metadata as Record<string, unknown>;
addDeclaredEnvNamesFromManifestBlock(names, metadata.openclaw);
addDeclaredEnvNamesFromManifestBlock(names, metadata.clawdis);
addDeclaredEnvNamesFromManifestBlock(names, metadata.clawdbot);
}
}
return names;
}
function collectReferencedEnvNames(content: string) {
const names = new Set<string>();
const patterns = [
/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
/process\.env\[\s*["']([A-Za-z_][A-Za-z0-9_]*)["']\s*\]/g,
];
for (const pattern of patterns) {
for (const match of content.matchAll(pattern)) {
addDeclaredEnvName(names, match[1]);
}
}
return names;
}
function hasBroadEnvAccess(content: string) {
return (
/Object\.(?:keys|values|entries)\s*\(\s*process\.env\s*\)/.test(content) ||
/process\.env(?!\s*(?:\.|\[))/.test(content) ||
/process\.env\[\s*[^"'`\]]/.test(content)
);
}
function scanCodeFile(
path: string,
content: string,
findings: ModerationFinding[],
declaredEnvNames: Set<string>,
) {
if (!CODE_EXTENSION.test(path)) return;
const hasChildProcess = /child_process/.test(content);
const execPattern = /\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/;
if (hasChildProcess && execPattern.test(content)) {
const match = findFirstLine(content, execPattern);
const dangerousChildProcessCall = findDangerousChildProcessCall(content);
if (dangerousChildProcessCall) {
addFinding(findings, {
code: REASON_CODES.DANGEROUS_EXEC,
severity: "critical",
file: path,
line: match.line,
line: dangerousChildProcessCall.line,
message: "Shell command execution detected (child_process).",
evidence: match.text,
evidence: dangerousChildProcessCall.text,
});
}
if (/\beval\s*\(|new\s+Function\s*\(/.test(content)) {
const match = findFirstLine(content, /\beval\s*\(|new\s+Function\s*\(/);
if (DYNAMIC_CODE_EXECUTION_PATTERN.test(content)) {
const match = findFirstLine(content, DYNAMIC_CODE_EXECUTION_PATTERN);
addFinding(findings, {
code: REASON_CODES.DYNAMIC_CODE,
severity: "critical",
@@ -141,6 +683,79 @@ function scanCodeFile(path: string, content: string, findings: ModerationFinding
});
}
const unsafeBrowserTextInput = findUnsafeBrowserTextInput(content);
if (unsafeBrowserTextInput) {
addFinding(findings, {
code: REASON_CODES.UNSAFE_BROWSER_TEXT_INPUT,
severity: "warn",
file: path,
line: unsafeBrowserTextInput.line,
message: "Shell positional input is typed into browser automation without validation.",
evidence: unsafeBrowserTextInput.text,
});
}
const hostPlatformSourcePatch = findHostPlatformSourcePatch(content);
if (hostPlatformSourcePatch) {
addFinding(findings, {
code: REASON_CODES.HOST_PLATFORM_SOURCE_PATCH,
severity: "critical",
file: path,
line: hostPlatformSourcePatch.line,
message: "Install code patches host platform source and rebuilds without confirmation.",
evidence: hostPlatformSourcePatch.text,
});
}
const unsafeBrowserFileRender = findUnsafeBrowserFileRender(content);
if (unsafeBrowserFileRender) {
addFinding(findings, {
code: REASON_CODES.BROWSER_FILE_RENDER,
severity: "critical",
file: path,
line: unsafeBrowserFileRender.line,
message:
"Browser automation renders interpolated SVG/HTML from a file URL with JavaScript enabled.",
evidence: unsafeBrowserFileRender.text,
});
}
const unsafeAgentControlledFileWrite = findUnsafeAgentControlledFileWrite(content);
if (unsafeAgentControlledFileWrite) {
addFinding(findings, {
code: REASON_CODES.UNSAFE_FILE_WRITE,
severity: "critical",
file: path,
line: unsafeAgentControlledFileWrite.line,
message: "Agent-controlled output path is passed to an overwrite-capable subprocess.",
evidence: unsafeAgentControlledFileWrite.text,
});
}
if (INSECURE_TLS_VERIFICATION_PATTERN.test(content)) {
const match = findFirstLine(content, INSECURE_TLS_VERIFICATION_PATTERN);
addFinding(findings, {
code: REASON_CODES.INSECURE_TLS_VERIFICATION,
severity: "warn",
file: path,
line: match.line,
message: "HTTPS certificate verification is disabled.",
evidence: match.text,
});
}
const unsafePythonRcloneFilename = findUnsafePythonRcloneFilename(content);
if (unsafePythonRcloneFilename) {
addFinding(findings, {
code: REASON_CODES.UNSAFE_FILE_WRITE,
severity: "critical",
file: path,
line: unsafePythonRcloneFilename.line,
message: "Agent-controlled filename is written and passed to rclone without path validation.",
evidence: unsafePythonRcloneFilename.text,
});
}
if (/stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i.test(content)) {
const match = findFirstLine(content, /stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i);
addFinding(findings, {
@@ -183,19 +798,65 @@ function scanCodeFile(path: string, content: string, findings: ModerationFinding
});
}
const hasProcessEnv = /process\.env/.test(content);
if (hasProcessEnv && hasNetworkSend) {
const match = findFirstLine(content, /process\.env/);
const shellBase64FileUpload = findShellBase64FileUpload(content);
if (shellBase64FileUpload) {
addFinding(findings, {
code: REASON_CODES.EXFILTRATION,
severity: "critical",
file: path,
line: shellBase64FileUpload.line,
message: "Shell script base64-encodes a local file and sends it over the network.",
evidence: shellBase64FileUpload.text,
});
}
const pythonCredentialPost = findPythonCredentialPostToEnvUrl(content);
if (pythonCredentialPost) {
addFinding(findings, {
code: REASON_CODES.CREDENTIAL_HARVEST,
severity: "critical",
file: path,
line: match.line,
message: "Environment variable access combined with network send.",
evidence: match.text,
line: pythonCredentialPost.line,
message:
"Python code POSTs credential environment variables to an environment-controlled URL.",
evidence: pythonCredentialPost.text,
});
}
const hardcodedOperatorBilling = findHardcodedOperatorBillingEndpoint(content);
if (hardcodedOperatorBilling) {
addFinding(findings, {
code: REASON_CODES.HARDCODED_OPERATOR_BILLING,
severity: "critical",
file: path,
line: hardcodedOperatorBilling.line,
message:
"Hardcoded operator endpoint combines OAuth credentials with Lightning billing calls.",
evidence: hardcodedOperatorBilling.text,
});
}
const hasProcessEnv = /process\.env/.test(content);
if (hasProcessEnv && hasNetworkSend) {
const referencedEnvNames = collectReferencedEnvNames(content);
const accessesOnlyDeclaredEnvNames =
referencedEnvNames.size > 0 &&
[...referencedEnvNames].every((name) => declaredEnvNames.has(name)) &&
!hasBroadEnvAccess(content);
if (!accessesOnlyDeclaredEnvNames) {
const match = findFirstLine(content, /process\.env/);
addFinding(findings, {
code: REASON_CODES.CREDENTIAL_HARVEST,
severity: "critical",
file: path,
line: match.line,
message: "Environment variable access combined with network send.",
evidence: match.text,
});
}
}
if (
/(\\x[0-9a-fA-F]{2}){6,}/.test(content) ||
/(?:atob|Buffer\.from)\s*\(\s*["'][A-Za-z0-9+/=]{200,}["']/.test(content)
@@ -215,6 +876,55 @@ function scanCodeFile(path: string, content: string, findings: ModerationFinding
function scanMarkdownFile(path: string, content: string, findings: ModerationFinding[]) {
if (!MARKDOWN_EXTENSION.test(path)) return;
const credentialExposure = findCredentialExposureInstruction(content);
if (credentialExposure) {
addFinding(findings, {
code: REASON_CODES.CREDENTIAL_EXPOSURE_INSTRUCTIONS,
severity: "critical",
file: path,
line: credentialExposure.line,
message: "Instructions expose credentials through shell, git config, or agent memory.",
evidence: credentialExposure.text,
});
}
const browserCredentialAutomation = findBrowserCredentialAutomation(content);
if (browserCredentialAutomation) {
addFinding(findings, {
code: REASON_CODES.BROWSER_CREDENTIAL_AUTOMATION,
severity: "critical",
file: path,
line: browserCredentialAutomation.line,
message: "Browser automation instructions expose credentials or persist authenticated eval.",
evidence: browserCredentialAutomation.text,
});
}
const secretArgvExposure = findSecretArgvExposure(content);
if (secretArgvExposure) {
addFinding(findings, {
code: REASON_CODES.SECRET_ARGV_EXPOSURE,
severity: "critical",
file: path,
line: secretArgvExposure.line,
message: "Instructions pass high-value credentials through process argv.",
evidence: secretArgvExposure.text,
});
}
const hardcodedOperatorBilling = findHardcodedOperatorBillingEndpoint(content);
if (hardcodedOperatorBilling) {
addFinding(findings, {
code: REASON_CODES.HARDCODED_OPERATOR_BILLING,
severity: "critical",
file: path,
line: hardcodedOperatorBilling.line,
message:
"Hardcoded operator endpoint combines OAuth credentials with Lightning billing calls.",
evidence: hardcodedOperatorBilling.text,
});
}
if (hasMaliciousInstallPrompt(content)) {
const match = findFirstLine(
content,
@@ -230,6 +940,31 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
});
}
const destructiveDelete = findUnguardedDestructiveDelete(content);
if (destructiveDelete) {
addFinding(findings, {
code: REASON_CODES.DESTRUCTIVE_DELETE_COMMAND,
severity: "warn",
file: path,
line: destructiveDelete.line,
message:
"Documentation contains a destructive delete command without an explicit confirmation gate.",
evidence: destructiveDelete.text,
});
}
const unsafeBrowserTextInput = findUnsafeBrowserTextInput(content);
if (unsafeBrowserTextInput) {
addFinding(findings, {
code: REASON_CODES.UNSAFE_BROWSER_TEXT_INPUT,
severity: "warn",
file: path,
line: unsafeBrowserTextInput.line,
message: "Shell positional input is typed into browser automation without validation.",
evidence: unsafeBrowserTextInput.text,
});
}
if (
/ignore\s+(all\s+)?previous\s+instructions/i.test(content) ||
/system\s*prompt\s*[:=]/i.test(content)
@@ -289,7 +1024,8 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
severity: "critical",
file: path,
line: match.line,
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
message:
"Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
evidence: match.text,
});
break;
@@ -330,11 +1066,42 @@ function dedupeEvidence(evidence: ModerationFinding[]) {
return out.slice(0, 40);
}
function addScannerStatusReason(reasonCodes: string[], scanner: "vt" | "llm", status?: string) {
function isStaticScanClean(staticScan: StaticScanResult | undefined) {
// Older moderation records can predate static scan persistence; absence means
// there are no static findings available to corroborate an external signal.
return !staticScan || staticScan.reasonCodes.length === 0 || staticScan.status === "clean";
}
function isAvEngineStatsClean(stats: VirusTotalEngineStats | undefined) {
if (!stats) return false;
return (stats.malicious ?? 0) === 0 && (stats.suspicious ?? 0) === 0;
}
function getVtEngineStats(analysis: VirusTotalAnalysis | undefined) {
return analysis?.engineStats ?? analysis?.metadata?.stats;
}
function isUncorroboratedVtCodeInsightSuspicious(params: {
vtAnalysis?: VirusTotalAnalysis;
staticScan?: StaticScanResult;
llmStatus?: string;
}) {
if (params.vtAnalysis?.scanner !== "code_insight") return false;
if (!isExternalScannerClean(params.llmStatus)) return false;
if (!isStaticScanClean(params.staticScan)) return false;
return isAvEngineStatsClean(getVtEngineStats(params.vtAnalysis));
}
function addScannerStatusReason(
reasonCodes: string[],
scanner: "vt" | "llm",
status?: string,
options: { suppressSuspicious?: boolean } = {},
) {
const normalized = status?.trim().toLowerCase();
if (normalized === "malicious") {
reasonCodes.push(`malicious.${scanner}_malicious`);
} else if (normalized === "suspicious") {
} else if (normalized === "suspicious" && !options.suppressSuspicious) {
reasonCodes.push(`suspicious.${scanner}_suspicious`);
}
}
@@ -342,13 +1109,41 @@ function addScannerStatusReason(reasonCodes: string[], scanner: "vt" | "llm", st
export function runStaticModerationScan(input: StaticScanInput): StaticScanResult {
const findings: ModerationFinding[] = [];
const files = [...input.fileContents].sort((a, b) => a.path.localeCompare(b.path));
const declaredEnvNames = collectDeclaredEnvNames(input);
for (const file of files) {
scanCodeFile(file.path, file.content, findings);
scanSecretLiteralFile(file.path, file.content, findings);
scanPlaintextCgnatEndpointFile(file.path, file.content, findings);
scanCodeFile(file.path, file.content, findings, declaredEnvNames);
scanMarkdownFile(file.path, file.content, findings);
scanManifestFile(file.path, file.content, findings);
}
const autonomousCredentialEgress = findAutonomousCredentialEgress(files);
if (autonomousCredentialEgress) {
addFinding(findings, {
code: REASON_CODES.AUTONOMOUS_CREDENTIAL_EGRESS,
severity: "critical",
file: autonomousCredentialEgress.file,
line: autonomousCredentialEgress.line,
message:
"Autonomous schedule or loop submits credential-bearing agent output without per-call consent.",
evidence: autonomousCredentialEgress.text,
});
}
const remoteRecipeExecution = findRemoteRecipeExecution(files);
if (remoteRecipeExecution) {
addFinding(findings, {
code: REASON_CODES.REMOTE_RECIPE_EXECUTION,
severity: "critical",
file: remoteRecipeExecution.file,
line: remoteRecipeExecution.line,
message: "Remote recipe/catalog data can influence templated subprocess command execution.",
evidence: remoteRecipeExecution.text,
});
}
const installJson = JSON.stringify(input.metadata ?? {});
if (/https?:\/\/(bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd)\//i.test(installJson)) {
addFinding(findings, {
@@ -410,6 +1205,7 @@ function isExternalScannerClean(status: string | undefined): boolean {
export function buildModerationSnapshot(params: {
staticScan?: StaticScanResult;
vtAnalysis?: VirusTotalAnalysis;
vtStatus?: string;
llmStatus?: string;
sourceVersionId?: Id<"skillVersions">;
@@ -427,7 +1223,14 @@ export function buildModerationSnapshot(params: {
}
const reasonCodes = [...staticCodes];
addScannerStatusReason(reasonCodes, "vt", params.vtStatus);
const vtStatus = params.vtStatus ?? params.vtAnalysis?.status;
addScannerStatusReason(reasonCodes, "vt", vtStatus, {
suppressSuspicious: isUncorroboratedVtCodeInsightSuspicious({
vtAnalysis: params.vtAnalysis,
staticScan: params.staticScan,
llmStatus: params.llmStatus,
}),
});
addScannerStatusReason(reasonCodes, "llm", params.llmStatus);
const normalizedCodes = normalizeReasonCodes(reasonCodes);
+15 -1
View File
@@ -12,13 +12,26 @@ export type ModerationFinding = {
evidence: string;
};
export const MODERATION_ENGINE_VERSION = "v2.4.0";
export const MODERATION_ENGINE_VERSION = "v2.4.22";
export const REASON_CODES = {
DANGEROUS_EXEC: "suspicious.dangerous_exec",
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
DESTRUCTIVE_DELETE_COMMAND: "suspicious.destructive_delete_command",
UNSAFE_BROWSER_TEXT_INPUT: "suspicious.unsafe_browser_text_input",
EXPOSED_SECRET_LITERAL: "suspicious.exposed_secret_literal",
CREDENTIAL_EXPOSURE_INSTRUCTIONS: "suspicious.credential_exposure_instructions",
BROWSER_CREDENTIAL_AUTOMATION: "suspicious.browser_credential_automation",
SECRET_ARGV_EXPOSURE: "suspicious.secret_argv_exposure",
HOST_PLATFORM_SOURCE_PATCH: "suspicious.host_platform_source_patch",
BROWSER_FILE_RENDER: "suspicious.browser_file_render",
UNSAFE_FILE_WRITE: "suspicious.unsafe_file_write",
INSECURE_TLS_VERIFICATION: "suspicious.insecure_tls_verification",
AUTONOMOUS_CREDENTIAL_EGRESS: "suspicious.autonomous_credential_egress",
HARDCODED_OPERATOR_BILLING: "suspicious.hardcoded_operator_billing",
REMOTE_RECIPE_EXECUTION: "suspicious.remote_recipe_execution",
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
EXFILTRATION: "suspicious.potential_exfiltration",
OBFUSCATED_CODE: "suspicious.obfuscated_code",
@@ -29,6 +42,7 @@ export const REASON_CODES = {
MANIFEST_PRIVILEGED_ALWAYS: "suspicious.privileged_always",
MALICIOUS_INSTALL_PROMPT: "malicious.install_terminal_payload",
KNOWN_BLOCKED_SIGNATURE: "malicious.known_blocked_signature",
DEP_NOT_FOUND: "suspicious.dep_not_found_on_registry",
} as const;
const MALICIOUS_CODES = new Set<string>([
+6 -6
View File
@@ -144,13 +144,13 @@ export async function deletePackageSearchDigests(
}
}
function hasDigestChanged<
TExisting extends Record<string, unknown>,
TFields extends Record<string, unknown>,
>(existing: TExisting, fields: TFields): boolean {
function hasDigestChanged(
existing: Record<string, unknown>,
fields: Record<string, unknown>,
): boolean {
for (const key of Object.keys(fields)) {
const oldValue = (existing as Record<string, unknown>)[key];
const newValue = (fields as Record<string, unknown>)[key];
const oldValue = existing[key];
const newValue = fields[key];
if (oldValue === newValue) continue;
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) return true;
}
+28
View File
@@ -77,6 +77,34 @@ export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRo
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
}
export type OwnedResourceActor = {
_id: Id<"users">;
role?: Doc<"users">["role"];
};
export async function assertCanManageOwnedResource(
ctx: DbCtx,
params: {
actor: OwnedResourceActor;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
allowedPublisherRoles?: PublisherRole[];
allowPlatformAdmin?: boolean;
},
) {
if (params.allowPlatformAdmin && params.actor.role === "admin") return;
if (params.ownerUserId === params.actor._id) return;
if (!params.ownerPublisherId) throw new ConvexError("Forbidden");
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actor._id);
if (
!membership ||
!isPublisherRoleAllowed(membership.role, params.allowedPublisherRoles ?? ["admin"])
) {
throw new ConvexError("Forbidden");
}
}
export async function getPublisherByHandle(ctx: DbCtx, handle: string | undefined | null) {
const normalized = normalizePublisherHandle(handle);
if (!normalized) return null;
+6 -5
View File
@@ -13,17 +13,18 @@ describe("searchText", () => {
]);
});
it("matchesExactTokens requires at least one query token to prefix-match", () => {
it("matchesExactTokens requires every query token to prefix-match", () => {
const queryTokens = tokenize("Remind Me");
expect(matchesExactTokens(queryTokens, ["Remind Me", "/remind-me", "Short summary"])).toBe(
true,
);
// "Reminder" starts with "remind", so it matches with prefix matching
// "Reminder" starts with "remind", but no token matches "me".
expect(matchesExactTokens(queryTokens, ["Reminder tool", "/reminder", "Short summary"])).toBe(
true,
false,
);
expect(matchesExactTokens(queryTokens, ["Remind tool", "/remind", "Short summary"])).toBe(
false,
);
// Matches because "remind" token is present
expect(matchesExactTokens(queryTokens, ["Remind tool", "/remind", "Short summary"])).toBe(true);
// No matching tokens at all
expect(matchesExactTokens(queryTokens, ["Other tool", "/other", "Short summary"])).toBe(false);
});

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