Compare commits

...
Author SHA1 Message Date
Peter Steinberger add8b51d5c fix(ui): make scan summary selectable 2026-02-15 14:39:00 +01:00
Borisolver 9e33607629 UI: allow copying security scan summary text 2026-02-15 11:43:59 +01:00
Peter Steinberger 10b704278a fix: match skill hero cta widths 2026-02-15 05:28:59 +01:00
Peter Steinberger a289f9cbd9 fix: make ghost buttons look like buttons 2026-02-15 05:27:55 +01:00
Peter Steinberger 97d68a1be5 feat: improve skill card meta layout 2026-02-15 05:26:08 +01:00
Peter Steinberger 57e0d39cdc feat: sync GitHub profile name 2026-02-15 05:26:03 +01:00
Peter Steinberger 1c033868e7 feat: show skill owner avatars on home + lists 2026-02-15 05:06:23 +01:00
Peter Steinberger 4532366009 style: polish markdown code blocks 2026-02-15 05:00:18 +01:00
Ian AllowayDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Peter Steinberger
8e9fa44fc2 Devin/1771112524 skill metadata update (#312)
* fix: sync GitHub profile on login to handle username renames (#303)

When a user renames their GitHub account, the stored username becomes stale
and causes 'GitHub account lookup failed' errors during skill publishing.

This fix:
- Adds syncGitHubProfile function that fetches current profile using the
  immutable GitHub numeric ID
- Adds syncGitHubProfileInternal mutation to update user's name, handle,
  displayName, and image when they change
- Schedules the sync as a background action on every login via
  afterUserCreatedOrUpdated callback

The sync is best-effort (silently fails if GitHub API unavailable) since
it's not on the critical path. It only updates fields if the username
has actually changed.

Fixes #303

Co-Authored-By: Ian Alloway <adapter_burners.1y@icloud.com>

* fix: allow updating skill summary/description on subsequent publishes (#301)

Previously, the skill summary was only extracted from metadata.description
in the SKILL.md frontmatter. This change also checks for a direct
'description' field in the frontmatter, ensuring that users can update
their skill description by modifying either location.

The fix prioritizes the new description from the current publish over
the existing skill summary, allowing updates to be reflected correctly.

Fixes #301

Co-Authored-By: Ian Alloway <adapter_burners.1y@icloud.com>

* fix: throttle GitHub profile sync

* feat: show skill owner avatars

* fix: avoid nested owner links

* refactor: centralize profile sync + owner lookup

* docs: changelog for #312 (thanks @ianalloway)

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-15 04:55:49 +01:00
Peter Steinberger 11a66ea148 refactor: dedupe v1 file response + unify embedding patches (#316) 2026-02-15 03:43:30 +01:00
Peter Steinberger f94e20d4c3 refactor: split httpApiV1 + consolidate moderation batches (#315) 2026-02-15 03:29:53 +01:00
Peter Steinberger 71c74f61e2 refactor: post-#298 cleanup (#313)
* refactor: consolidate slug + embedding helpers

* refactor: batch ban/unban skill updates

* refactor: report batched ban/unban scheduling

* fix: unblock package typecheck
2026-02-15 02:18:54 +01:00
e2592684ed feat: anti-squatting protection, backup restore, and ban flow improvements (#298)
* feat: anti-squatting protection, backup restore, and ban flow improvements

- Add `reservedSlugs` table with 90-day cooldown to prevent slug squatting
  after skill deletion. Hard-delete finalize phase reserves slugs for the
  original owner; `insertVersion` blocks non-owners during cooldown.

- Change ban flow from hard-delete to soft-delete: `banUserWithActor` now
  sets `moderationReason: 'user.banned'` and syncs embedding visibility.
  `unbanUserWithActor` restores all ban-hidden skills and releases slug
  reservations automatically.

- Align `autobanMalwareAuthorInternal` with the same soft-delete + embedding
  visibility pattern so unban recovery works uniformly.

- Add admin `reclaimSlug` / `reclaimSlugInternal` mutations for reclaiming
  squatted slugs, with audit logging.

- Add GitHub backup restore system (`githubRestore.ts`,
  `githubRestoreMutations.ts`, `githubRestoreHelpers.ts`) that reads from
  the `clawdbot/skills` backup repo and re-creates skill records. Squatter
  eviction runs synchronously in the same transaction as restore to avoid
  async race conditions.

- Add `POST /api/v1/users/restore` and `POST /api/v1/users/reclaim` admin
  HTTP endpoints for bulk operations.

- Add `trustedPublisher` flag on users; trusted publishers bypass the
  `pending.scan` auto-hide for new skill publishes.

- Add `setTrustedPublisher` / `setTrustedPublisherInternal` admin mutations.

Addresses: slug squatting prevention, skill backup/restore, ban recovery,
and trusted publisher workflow improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden restore/reclaim + ban flow (#298) (thanks @autogame-17)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-15 01:16:46 +01:00
David AronchickandPeter Steinberger 79c9381201 fix(cli): clarify logout only affects local config (#166)
* fix(cli): clarify logout only affects local config

Users may assume 'clawhub logout' revokes their token everywhere.
In reality, the token remains valid on the server until explicitly
revoked in the web UI. This could be a security concern on shared
machines.

Update the message to set correct expectations.

* fix(cli): clarify logout revocation scope (#166) (thanks @aronchick)

* chore: sync changelog for merge (#166) (thanks @aronchick)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 22:56:10 +01:00
Peter Steinberger 6a5712fdb6 Merge pull request #309 from openclaw/chore/merge-all
docs: changelog credit + v1 delete status codes
2026-02-14 22:21:56 +01:00
Peter Steinberger a85faf76ac docs: changelog credit + v1 delete status codes 2026-02-14 22:21:37 +01:00
Peter Steinberger c0a04210e9 fix: default to CF-only client IP parsing 2026-02-14 22:20:58 +01:00
Peter Steinberger 5adb334cb2 Merge pull request #35 from sergical/fix/delete-error-handling
fix: return proper HTTP status codes for delete/undelete errors
2026-02-14 22:11:04 +01:00
Peter Steinberger 182ec8741f test(api): reposition soft-delete mapping test 2026-02-14 22:08:05 +01:00
Peter Steinberger bafd17b00a test(api): cover v1 soft-delete error mapping 2026-02-14 22:06:02 +01:00
Peter Steinberger 802ee58054 chore(cli): align http client with main 2026-02-14 22:04:02 +01:00
Peter Steinberger 0e83ba00b9 fix(api): centralize v1 soft-delete error mapping 2026-02-14 21:59:14 +01:00
Peter Steinberger 3326a5c838 refactor: simplify GitHub age gate cache 2026-02-14 20:53:05 +01:00
Matt Krokosz f05dd556db fix: gate publish by immutable GitHub account ID 2026-02-14 20:25:15 +01:00
964893a622 fix: handle duplicate Convex Auth user records in publish ownership check (#180)
* fix: handle duplicate user records in publish ownership check

* fix: heal publish ownership via GitHub auth identity

---------

Co-authored-by: Emmet Brown <emmet@Emmets-Mac-mini.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 19:39:04 +01:00
Peter Steinberger 9a804b951f refactor: batch resolve tags in v1 API (#112) (thanks @mkrokosz) 2026-02-14 19:01:02 +01:00
Matthew KrokoszandClaude Opus 4.5 d699087786 fix: add null guard and short-circuit for empty tags
- Short-circuit when no version IDs to resolve
- Add null coalescing for runQuery response
- Fixes potential crash when tags are empty or query returns null

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 19:01:02 +01:00
Matthew KrokoszandClaude Opus 4.5 26b42727fe perf: batch tag resolution to reduce action→query round-trips
- Add getVersionsByIds batch query to skills.ts and souls.ts
- Replace per-item tag resolution with batch resolution in httpApiV1.ts
- Reduces N action→query round-trips to 1 for list endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 19:01:02 +01:00
Peter Steinberger 8756b78a4e chore: drop convex-helpers (#302) 2026-02-14 17:54:24 +01:00
e9c771d55d fix(skills): keep global sorting across pagination (#98)
* fix: initial skill sorting

* chore: update unit test

* fix: use correct indexes for skill sorting

* chore: cleanup

* fix(skills): preserve server order for paginated sorting

* chore(lint): apply biome formatting fixes

* chore(convex): bump tsconfig lib to ES2022

* fix(skills): add deterministic tie-breaker for search sorting

* fix(skills): stable sorting across pagination (#98) (thanks @CodeBBakGoSu)

---------

Co-authored-by: Brian Kasper <bkasperr@gmail.com>
Co-authored-by: knox-glorang <knox@glorang.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 17:44:07 +01:00
Peter Steinberger a58f0166fa refactor: centralize CORS + CLI auth token (#297)
* refactor(convex): centralize CORS headers

* refactor(cli): centralize auth token lookup
2026-02-14 15:31:03 +01:00
Peter SteinbergerandGrenghis-Khan 4328d4d700 fix(cors): complete CORS + tokenized CLI reads (#296)
* fix(cors): add Access-Control-Allow-Origin headers to API and downloads

* fix: add CORS to error/raw paths & add CLI install auth

* fix: add OPTIONS handler for CORS preflight

* fix(cors): complete CORS + tokenized CLI reads

* test(cli): fix config mock typing

---------

Co-authored-by: Grenghis-Khan <63885013+Grenghis-Khan@users.noreply.github.com>
2026-02-14 14:45:15 +01:00
Peter Steinberger 28ee2618c1 refactor: simplify user ensure updates 2026-02-14 13:56:15 +01:00
Peter Steinberger a4b850ec33 feat: improve moderation/admin UX + language-aware quality gate
- API: owner-visible responses for hidden/soft-deleted skills\n- Admin: add unban user mutations + docs\n- Quality: Intl.Segmenter tokenization + CJK signal to reduce false rejects\n- Jobs: skill-stat-events interval 15m -> 5m\n- Tests: add coverage for owner-visible states + non-Latin docs\n- Changelog: add Unreleased entry
2026-02-14 13:54:03 +01:00
Peter Steinberger 7e0b21f7c8 fix: sync handle on user ensure (#293) (thanks @christianhpoe) 2026-02-14 13:48:56 +01:00
ChristianHPoe 71c6705ab1 fix: sync handle on user ensure 2026-02-14 13:48:56 +01:00
a57769771f fix: add retry logic for OpenAI embedding API failures (#272)
* fix: add retry logic for OpenAI embedding API failures

Fixes #149

When importing or uploading skills, the OpenAI embedding API call could
fail with transient errors (rate limits, timeouts, network issues),
causing the entire import to fail with a generic "Server Error".

This adds retry logic with exponential backoff (1s, 2s, 4s delays):
- Retries on 429 (rate limit) and 5xx server errors
- Retries on network/fetch errors
- Logs warnings for debugging
- Max 3 retries before failing with clear error message

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

* fix: correct retry count and broaden network error catch

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

* fix: address retry loop off-by-one, broaden error catch, preserve original error

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

* fix: harden embeddings retry semantics

* style: format embeddings retry changes

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 04:54:18 +01:00
Peter Steinberger 6a2c131a8a style: remove residual blue accents and warm base palette 2026-02-14 04:40:50 +01:00
Peter Steinberger 67ac157545 fix: keep new skill versions pending until VT verdict 2026-02-14 02:53:02 +01:00
Peter Steinberger ef36cfd698 refactor(cli): centralize HTTP status errors and timeout tests (#286) 2026-02-14 02:39:42 +01:00
Peter SteinbergerandSash Zats e0637ad6aa fix(cli): throw Error for all timeout aborts (#283)
* fix(cli): throw Error on timeout aborts

Users have seen an elevated number of:\n  clawdhub search image\n  ✖ Non-error was thrown: "Timeout". You should only throw errors.\n\nInvestigation shows we were aborting with a string instead of an Error. Switching to controller.abort(new Error('Timeout')) makes retries/formatting treat it as a real error and clears the message.\n\nExample after change:\n  clawdhub search image\n  table-image v1.0.0  Table Image  (0.332)\n  nano-banana-pro v1.0.1  Nano Banana Pro  (0.319)\n  vap-media v1.0.1  AI media generation API - Flux2pro, Veo3.1, Suno Ai  (0.281)\n  clawdbot-meshyai-skill v0.1.0  Meshy AI  (0.276)\n  venice-ai-media v1.0.0  Venice AI Media  (0.274)\n  daily-recap v1.0.2  Daily Recap  (0.260)\n  openai-image-gen v1.0.1  Openai Image Gen  (0.260)\n  bible-votd v1.0.1  Bible Verse of the Day  (0.248)\n  orf v1.0.1  ORF  (0.224)\n  smalltalk v1.0.1  Smalltalk  (0.161)

* fix(http): wrap fetch calls in try-finally to prevent timer leaks

Addresses Vercel review comment: clearTimeout was not called on error paths when fetch throws an exception.

* fix(cli): unify timeout abort handling

---------

Co-authored-by: Sash Zats <sash@zats.io>
2026-02-14 02:27:34 +01:00
Sergiy Dybskiy eb9a67f2af fix: use Error for timeout abort in e2e helper 2026-01-26 12:41:29 +00:00
Sergiy Dybskiy 1ae0498595 test: add e2e test for delete error handling
Verifies that deleting a non-existent skill returns a proper 'not found'
error instead of a generic 'Unauthorized' message.
2026-01-26 12:40:39 +00:00
Sergiy Dybskiy f1a5254755 fix(cli): use proper Error objects in abort timeouts
When AbortController.abort() receives a string instead of an Error,
the string itself is thrown. pRetry then wraps it in a confusing
message: 'Non-error was thrown: Timeout'

Changed all 3 occurrences in http.ts:
- apiRequest (line 57)
- apiRequestForm (line 106)
- downloadZip (line 141)

Now timeouts will surface as proper Error objects with clear messages.
2026-01-25 21:39:54 +00:00
Sergiy Dybskiy de2542e391 fix: return proper HTTP status codes for delete/undelete errors
The delete and undelete handlers for skills and souls were catching all
errors and returning 401 Unauthorized, even for errors like:
- 'Skill not found' (should be 404)
- 'Forbidden' (should be 403)
- Other validation errors (should be 400)

This change updates the error handling to return appropriate status codes:
- 401 Unauthorized: authentication failures
- 403 Forbidden: authorization failures (not owner/admin/moderator)
- 404 Not Found: skill/soul/user not found
- 400 Bad Request: other errors with descriptive message

Fixes #34
2026-01-25 21:12:02 +00:00
87 changed files with 5259 additions and 2001 deletions
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## Unreleased
### Added
- Admin: add manual unban for banned users (clears `deletedAt` + `banReason`, audit log entry). Revoked API tokens stay revoked.
- Admin: bulk restore skills from GitHub backup; reclaim squatted slugs via v1 endpoints + internal tooling (#298) (thanks @autogame-17).
- Users: add `trustedPublisher` flag and admin mutations to bypass pending-scan auto-hide for trusted publishers (#298) (thanks @autogame-17).
- Skills/Web: show skill owner avatar + handle on skill cards, lists, and detail pages (#312) (thanks @ianalloway).
### Changed
- Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs.
- Jobs: run skill stat event processing every 5 minutes (was 15).
- API performance: batch resolve skill/soul tags in v1 list/get endpoints (fewer action->query round-trips) (#112) (thanks @mkrokosz).
- Skills: reserve deleted slugs for prior owners (90-day cooldown) to prevent squatting; add admin reclaim flow (#298) (thanks @autogame-17).
- Moderation: ban flow soft-deletes owned skills (reversible) and removes them from vector search (#298) (thanks @autogame-17).
### Fixed
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
- Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway).
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
- API: return proper status codes for delete/undelete errors (#35) (thanks @sergical).
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).
- CLI: clarify `logout` only removes the local token; token remains valid until revoked in the web UI (#166) (thanks @aronchick).
- Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98).
- Skills: allow updating skill description/summary from frontmatter on subsequent publishes (#312) (thanks @ianalloway).
## 0.6.1 - 2026-02-13
### Added
-3
View File
@@ -24,7 +24,6 @@
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.7",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.11",
"lucide-react": "^0.563.0",
@@ -791,8 +790,6 @@
"convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="],
"convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
+36
View File
@@ -16,26 +16,44 @@ import type * as devSeedExtra from "../devSeedExtra.js";
import type * as downloads from "../downloads.js";
import type * as githubBackups from "../githubBackups.js";
import type * as githubBackupsNode from "../githubBackupsNode.js";
import type * as githubIdentity from "../githubIdentity.js";
import type * as githubImport from "../githubImport.js";
import type * as githubRestore from "../githubRestore.js";
import type * as githubRestoreMutations from "../githubRestoreMutations.js";
import type * as githubSoulBackups from "../githubSoulBackups.js";
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
import type * as http from "../http.js";
import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
import type * as httpApiV1_starsV1 from "../httpApiV1/starsV1.js";
import type * as httpApiV1_usersV1 from "../httpApiV1/usersV1.js";
import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
import type * as httpPreflight from "../httpPreflight.js";
import type * as leaderboards from "../leaderboards.js";
import type * as lib_access from "../lib/access.js";
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_badges from "../lib/badges.js";
import type * as lib_batching from "../lib/batching.js";
import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_contentTypes from "../lib/contentTypes.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";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
import type * as lib_githubImport from "../lib/githubImport.js";
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
import type * as lib_githubRestoreHelpers from "../lib/githubRestoreHelpers.js";
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
import type * as lib_leaderboards from "../lib/leaderboards.js";
import type * as lib_moderation from "../lib/moderation.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
@@ -87,26 +105,44 @@ declare const fullApi: ApiFromModules<{
downloads: typeof downloads;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;
githubIdentity: typeof githubIdentity;
githubImport: typeof githubImport;
githubRestore: typeof githubRestore;
githubRestoreMutations: typeof githubRestoreMutations;
githubSoulBackups: typeof githubSoulBackups;
githubSoulBackupsNode: typeof githubSoulBackupsNode;
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
"httpApiV1/starsV1": typeof httpApiV1_starsV1;
"httpApiV1/usersV1": typeof httpApiV1_usersV1;
"httpApiV1/whoamiV1": typeof httpApiV1_whoamiV1;
httpPreflight: typeof httpPreflight;
leaderboards: typeof leaderboards;
"lib/access": typeof lib_access;
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/badges": typeof lib_badges;
"lib/batching": typeof lib_batching;
"lib/changelog": typeof lib_changelog;
"lib/contentTypes": typeof lib_contentTypes;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubIdentity": typeof lib_githubIdentity;
"lib/githubImport": typeof lib_githubImport;
"lib/githubProfileSync": typeof lib_githubProfileSync;
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
"lib/httpHeaders": typeof lib_httpHeaders;
"lib/httpRateLimit": typeof lib_httpRateLimit;
"lib/leaderboards": typeof lib_leaderboards;
"lib/moderation": typeof lib_moderation;
"lib/public": typeof lib_public;
"lib/reservedSlugs": typeof lib_reservedSlugs;
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/skillBackfill": typeof lib_skillBackfill;
+19 -3
View File
@@ -2,7 +2,9 @@ import GitHub from '@auth/core/providers/github'
import { convexAuth } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { ConvexError } from 'convex/values'
import { internal } from './_generated/api'
import type { DataModel, Id } from './_generated/dataModel'
import { shouldScheduleGitHubProfileSync } from './lib/githubProfileSync'
export const BANNED_REAUTH_MESSAGE =
'Your account has been banned for uploading malicious skills. If you believe this is a mistake, please contact security@openclaw.ai and we will work with you to restore access.'
@@ -14,8 +16,9 @@ const REAUTH_BLOCKING_BAN_ACTIONS = new Set(['user.ban', 'user.autoban.malware']
export async function handleDeletedUserSignIn(
ctx: GenericMutationCtx<DataModel>,
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
userOverride?: { deletedAt?: number; deactivatedAt?: number; purgedAt?: number } | null,
) {
const user = await ctx.db.get(args.userId)
const user = userOverride !== undefined ? userOverride : await ctx.db.get(args.userId)
if (!user?.deletedAt && !user?.deactivatedAt) return
// Verify that the incoming identity matches the existing account to prevent bypass.
@@ -69,14 +72,27 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
],
callbacks: {
/**
* Block sign-in for deleted/deactivated users.
* Block sign-in for deleted/deactivated users and sync GitHub profile.
*
* Performance note: This callback runs on every OAuth sign-in, but the
* audit log query ONLY executes when a legacy deleted user attempts to sign
* in (user.deletedAt is set). For active users, this is a single field check.
*
* The GitHub profile sync is scheduled as a background action to handle
* the case where a user renames their GitHub account (fixes #303).
*/
async afterUserCreatedOrUpdated(ctx, args) {
await handleDeletedUserSignIn(ctx, args)
const user = await ctx.db.get(args.userId)
await handleDeletedUserSignIn(ctx, args, user)
// Schedule GitHub profile sync to handle username renames (fixes #303)
// This runs as a background action so it doesn't block sign-in
const now = Date.now()
if (shouldScheduleGitHubProfileSync(user, now)) {
await ctx.scheduler.runAfter(0, internal.users.syncGitHubProfileAction, {
userId: args.userId,
})
}
},
},
})
+1 -1
View File
@@ -26,7 +26,7 @@ crons.interval(
crons.interval(
'skill-stat-events',
{ minutes: 15 },
{ minutes: 5 },
internal.skillStatEvents.processSkillStatEventsAction,
{},
)
+42 -17
View File
@@ -3,6 +3,7 @@ import { api, internal } from './_generated/api'
import { httpAction, internalMutation, mutation } from './_generated/server'
import { getOptionalApiTokenUserId } from './lib/apiTokenAuth'
import { applyRateLimit, getClientIp } from './lib/httpRateLimit'
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
import { buildDeterministicZip } from './lib/skillZip'
import { hashToken } from './lib/tokens'
import { insertStatEvent } from './skillStatEvents'
@@ -19,7 +20,10 @@ export const downloadZip = httpAction(async (ctx, request) => {
const tagParam = url.searchParams.get('tag')?.trim()
if (!slug) {
return new Response('Missing slug', { status: 400 })
return new Response('Missing slug', {
status: 400,
headers: corsHeaders(),
})
}
const rate = await applyRateLimit(ctx, request, 'download')
@@ -27,7 +31,10 @@ export const downloadZip = httpAction(async (ctx, request) => {
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
if (!skillResult?.skill) {
return new Response('Skill not found', { status: 404 })
return new Response('Skill not found', {
status: 404,
headers: mergeHeaders(rate.headers, corsHeaders()),
})
}
// Block downloads based on moderation status.
@@ -35,20 +42,32 @@ export const downloadZip = httpAction(async (ctx, request) => {
if (mod?.isMalwareBlocked) {
return new Response(
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
{ status: 403 },
{
status: 403,
headers: mergeHeaders(rate.headers, corsHeaders()),
},
)
}
if (mod?.isPendingScan) {
return new Response(
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
{ status: 423 },
{
status: 423,
headers: mergeHeaders(rate.headers, corsHeaders()),
},
)
}
if (mod?.isRemoved) {
return new Response('This skill has been removed by a moderator.', { status: 410 })
return new Response('This skill has been removed by a moderator.', {
status: 410,
headers: mergeHeaders(rate.headers, corsHeaders()),
})
}
if (mod?.isHiddenByMod) {
return new Response('This skill is currently unavailable.', { status: 403 })
return new Response('This skill is currently unavailable.', {
status: 403,
headers: mergeHeaders(rate.headers, corsHeaders()),
})
}
const skill = skillResult.skill
@@ -67,10 +86,16 @@ export const downloadZip = httpAction(async (ctx, request) => {
}
if (!version) {
return new Response('Version not found', { status: 404 })
return new Response('Version not found', {
status: 404,
headers: mergeHeaders(rate.headers, corsHeaders()),
})
}
if (version.softDeletedAt) {
return new Response('Version not available', { status: 410 })
return new Response('Version not available', {
status: 410,
headers: mergeHeaders(rate.headers, corsHeaders()),
})
}
const entries: Array<{ path: string; bytes: Uint8Array }> = []
@@ -104,11 +129,15 @@ export const downloadZip = httpAction(async (ctx, request) => {
return new Response(zipBlob, {
status: 200,
headers: mergeHeaders(rate.headers, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
'Cache-Control': 'private, max-age=60',
}),
headers: mergeHeaders(
rate.headers,
{
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
'Cache-Control': 'private, max-age=60',
},
corsHeaders(),
),
})
})
@@ -194,7 +223,3 @@ export const __test = {
getHourStart,
getDownloadIdentityValue,
}
function mergeHeaders(base: HeadersInit, extra: HeadersInit) {
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
}
+9
View File
@@ -0,0 +1,9 @@
import { v } from 'convex/values'
import { internalQuery } from './_generated/server'
import { getGitHubProviderAccountId } from './lib/githubIdentity'
export const getGitHubProviderAccountIdInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, args) => getGitHubProviderAccountId(ctx, args.userId),
})
+216
View File
@@ -0,0 +1,216 @@
'use node'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { internalAction } from './_generated/server'
import {
fetchGitHubSkillMeta,
getGitHubBackupContext,
isGitHubBackupConfigured,
} from './lib/githubBackup'
import { assertAdmin } from './lib/access'
import {
listGitHubBackupFiles,
readGitHubBackupFile,
} from './lib/githubRestoreHelpers'
import { publishVersionForUser } from './lib/skillPublish'
import { guessContentTypeForPath } from './lib/contentTypes'
type RestoreResult = {
slug: string
status: 'restored' | 'slug_conflict' | 'already_exists' | 'no_backup' | 'error'
detail?: string
}
type BulkRestoreResult = {
results: RestoreResult[]
totalRestored: number
totalConflicts: number
totalSkipped: number
totalErrors: number
}
/**
* Admin-only: restore a single skill from GitHub backup.
* Reads the backup files from the GitHub repo and re-creates the skill in the database.
*/
export const restoreSkillFromBackup = internalAction({
args: {
actorUserId: v.id('users'),
ownerHandle: v.string(),
ownerUserId: v.id('users'),
slug: v.string(),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<RestoreResult> => {
try {
const actor = await ctx.runQuery(internal.users.getByIdInternal, {
userId: args.actorUserId,
})
if (!actor || actor.deletedAt || actor.deactivatedAt) {
return { slug: args.slug, status: 'error', detail: 'Actor not found' }
}
assertAdmin(actor as Doc<'users'>)
if (!isGitHubBackupConfigured()) {
return { slug: args.slug, status: 'error', detail: 'GitHub backup not configured' }
}
const ghContext = await getGitHubBackupContext()
// Check if skill already exists in the DB
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug: args.slug,
})) as Doc<'skills'> | null
if (existingSkill) {
if (existingSkill.ownerUserId === args.ownerUserId) {
return { slug: args.slug, status: 'already_exists', detail: 'Skill already owned by user' }
}
if (!args.forceOverwriteSquatter) {
return {
slug: args.slug,
status: 'slug_conflict',
detail: `Slug occupied by another user. Set forceOverwriteSquatter=true to reclaim.`,
}
}
// Free the slug in-transaction by renaming the squatter, then enqueue cleanup.
await ctx.runMutation(internal.githubRestoreMutations.evictSquatterSkillForRestoreInternal, {
actorUserId: args.actorUserId,
slug: args.slug,
rightfulOwnerUserId: args.ownerUserId,
})
}
// Fetch metadata from GitHub backup
const meta = await fetchGitHubSkillMeta(ghContext, args.ownerHandle, args.slug)
if (!meta) {
return { slug: args.slug, status: 'no_backup', detail: 'No backup found in GitHub repo' }
}
// Read the actual files from the backup
const backupFiles = await listGitHubBackupFiles(ghContext, args.ownerHandle, args.slug)
if (backupFiles.length === 0) {
return { slug: args.slug, status: 'no_backup', detail: 'Backup has no files' }
}
// Download and store each file in Convex storage
const storedFiles: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType: string
}> = []
for (const filePath of backupFiles) {
const fileContent = await readGitHubBackupFile(ghContext, args.ownerHandle, args.slug, filePath)
if (!fileContent) continue
const sha256 = await sha256Hex(fileContent)
const contentType = guessContentTypeForPath(filePath)
const blob = new Blob([Buffer.from(fileContent)], { type: contentType })
const storageId = await ctx.storage.store(blob)
storedFiles.push({
path: filePath,
size: fileContent.byteLength,
storageId,
sha256,
contentType,
})
}
if (storedFiles.length === 0) {
return { slug: args.slug, status: 'error', detail: 'Could not download any backup files' }
}
await publishVersionForUser(
ctx,
args.ownerUserId,
{
slug: args.slug,
displayName: meta.displayName,
version: meta.latest.version,
changelog: 'Restored from GitHub backup',
files: storedFiles,
},
{
bypassGitHubAccountAge: true,
bypassNewSkillRateLimit: true,
bypassQualityGate: true,
skipBackup: true,
skipWebhook: true,
},
)
return { slug: args.slug, status: 'restored' }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[restore] Failed to restore ${args.slug}:`, message)
return { slug: args.slug, status: 'error', detail: message }
}
},
})
/**
* Admin-only: bulk restore all skills for a user from GitHub backup.
*/
export const restoreUserSkillsFromBackup = internalAction({
args: {
actorUserId: v.id('users'),
ownerHandle: v.string(),
ownerUserId: v.id('users'),
slugs: v.array(v.string()),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<BulkRestoreResult> => {
const results: RestoreResult[] = []
let totalRestored = 0
let totalConflicts = 0
let totalSkipped = 0
let totalErrors = 0
for (const slug of args.slugs) {
const result = (await ctx.runAction(internal.githubRestore.restoreSkillFromBackup, {
actorUserId: args.actorUserId,
ownerHandle: args.ownerHandle,
ownerUserId: args.ownerUserId,
slug,
forceOverwriteSquatter: args.forceOverwriteSquatter,
})) as RestoreResult
results.push(result)
switch (result.status) {
case 'restored':
totalRestored += 1
break
case 'slug_conflict':
totalConflicts += 1
break
case 'already_exists':
case 'no_backup':
totalSkipped += 1
break
case 'error':
totalErrors += 1
break
}
}
return { results, totalRestored, totalConflicts, totalSkipped, totalErrors }
},
})
async function sha256Hex(bytes: Uint8Array) {
const { createHash } = await import('node:crypto')
const hash = createHash('sha256')
hash.update(bytes)
return hash.digest('hex')
}
// guessContentTypeForPath in lib/contentTypes.ts
+84
View File
@@ -0,0 +1,84 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { internalMutation } from './_generated/server'
import { assertAdmin } from './lib/access'
export const evictSquatterSkillForRestoreInternal = internalMutation({
args: {
actorUserId: v.id('users'),
slug: v.string(),
rightfulOwnerUserId: v.id('users'),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('Actor not found')
assertAdmin(actor)
const slug = args.slug.trim().toLowerCase()
if (!slug) throw new Error('Slug required')
const now = Date.now()
const existingSkill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (!existingSkill) return { ok: true as const, action: 'noop' as const }
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
return { ok: true as const, action: 'already_owned' as const }
}
const evictedSlug = buildEvictedSlug(slug, now)
// Free the slug immediately (same transaction) by renaming the squatter's skill.
await ctx.db.patch(existingSkill._id, {
slug: evictedSlug,
softDeletedAt: now,
hiddenAt: existingSkill.hiddenAt ?? now,
hiddenBy: existingSkill.hiddenBy ?? actor._id,
updatedAt: now,
})
// Remove from vector search ASAP.
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', existingSkill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: 'deleted',
updatedAt: now,
})
}
// Cleanup the rest asynchronously (versions, fingerprints, installs, etc.)
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: existingSkill._id,
actorUserId: actor._id,
phase: 'versions',
})
await ctx.db.insert('auditLogs', {
actorUserId: actor._id,
action: 'slug.reclaim.sync',
targetType: 'skill',
targetId: existingSkill._id,
metadata: {
slug,
evictedSlug,
squatterUserId: existingSkill.ownerUserId,
rightfulOwnerUserId: args.rightfulOwnerUserId,
reason: 'Synchronous eviction during GitHub restore',
},
createdAt: now,
})
return { ok: true as const, action: 'evicted' as const, evictedSlug }
},
})
function buildEvictedSlug(slug: string, now: number) {
const suffix = now.toString(36)
return `${slug}-evicted-${suffix}`
}
+7
View File
@@ -32,6 +32,7 @@ import {
usersPostRouterV1Http,
whoamiV1Http,
} from './httpApiV1'
import { preflightHandler } from './httpPreflight'
const http = httpRouter()
@@ -145,6 +146,12 @@ http.route({
handler: soulsDeleteRouterV1Http,
})
http.route({
pathPrefix: '/api/',
method: 'OPTIONS',
handler: preflightHandler,
})
// TODO: remove legacy /api routes after deprecation window.
http.route({
path: LegacyApiRoutes.download,
+15 -8
View File
@@ -11,6 +11,7 @@ import type { Id } from './_generated/dataModel'
import type { ActionCtx } from './_generated/server'
import { httpAction } from './_generated/server'
import { requireApiTokenUser } from './lib/apiTokenAuth'
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
import { publishVersionForUser } from './skills'
type SearchSkillEntry = {
@@ -241,20 +242,26 @@ export const cliTelemetrySyncHttp = httpAction(cliTelemetrySyncHandler)
function json(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
headers: mergeHeaders(
{
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
corsHeaders(),
),
})
}
function text(value: string, status: number) {
return new Response(value, {
status,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
headers: mergeHeaders(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
corsHeaders(),
),
})
}
+441 -4
View File
@@ -3,13 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./lib/apiTokenAuth', () => ({
requireApiTokenUser: vi.fn(),
getOptionalApiTokenUserId: vi.fn(),
}))
vi.mock('./skills', () => ({
publishVersionForUser: vi.fn(),
}))
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { getOptionalApiTokenUserId, requireApiTokenUser } = await import('./lib/apiTokenAuth')
const { publishVersionForUser } = await import('./skills')
const { __handlers } = await import('./httpApiV1')
@@ -27,6 +28,12 @@ function isRateLimitArgs(args: unknown): args is RateLimitArgs {
)
}
function hasSlugArgs(args: unknown): args is { slug: string } {
if (!args || typeof args !== 'object') return false
const value = args as Record<string, unknown>
return typeof value.slug === 'string'
}
function makeCtx(partial: Record<string, unknown>) {
const partialRunQuery =
typeof partial.runQuery === 'function'
@@ -59,6 +66,8 @@ const blockedRate = () => ({
})
beforeEach(() => {
vi.mocked(getOptionalApiTokenUserId).mockReset()
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null)
vi.mocked(requireApiTokenUser).mockReset()
vi.mocked(publishVersionForUser).mockReset()
})
@@ -78,6 +87,122 @@ describe('httpApiV1 handlers', () => {
expect(runAction).not.toHaveBeenCalled()
})
it('users/restore forbids non-admin api tokens', async () => {
const runQuery = vi.fn()
const runAction = vi.fn()
const runMutation = vi.fn().mockResolvedValue(okRate())
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:actor',
user: { _id: 'users:actor', role: 'user' },
} as never)
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runAction, runMutation }),
new Request('https://example.com/api/v1/users/restore', {
method: 'POST',
body: JSON.stringify({ handle: 'target', slugs: ['a'] }),
}),
)
expect(response.status).toBe(403)
expect(runQuery).not.toHaveBeenCalled()
expect(runAction).not.toHaveBeenCalled()
})
it('users/restore calls restore action for admin', async () => {
const runAction = vi.fn().mockResolvedValue({ ok: true, totalRestored: 1, results: [] })
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate()
return { ok: true }
})
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('handle' in args) return { _id: 'users:target' }
return null
})
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:admin',
user: { _id: 'users:admin', role: 'admin' },
} as never)
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runAction, runMutation }),
new Request('https://example.com/api/v1/users/restore', {
method: 'POST',
body: JSON.stringify({
handle: 'Target',
slugs: ['a', 'b'],
forceOverwriteSquatter: true,
}),
}),
)
if (response.status !== 200) throw new Error(await response.text())
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
actorUserId: 'users:admin',
ownerHandle: 'target',
ownerUserId: 'users:target',
slugs: ['a', 'b'],
forceOverwriteSquatter: true,
})
})
it('users/reclaim forbids non-admin api tokens', async () => {
const runQuery = vi.fn()
const runAction = vi.fn()
const runMutation = vi.fn().mockResolvedValue(okRate())
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:actor',
user: { _id: 'users:actor', role: 'user' },
} as never)
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runAction, runMutation }),
new Request('https://example.com/api/v1/users/reclaim', {
method: 'POST',
body: JSON.stringify({ handle: 'target', slugs: ['a'] }),
}),
)
expect(response.status).toBe(403)
expect(runQuery).not.toHaveBeenCalled()
})
it('users/reclaim calls reclaim mutation for admin', async () => {
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate()
return { ok: true }
})
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('handle' in args) return { _id: 'users:target' }
return null
})
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:admin',
user: { _id: 'users:admin', role: 'admin' },
} as never)
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runAction: vi.fn(), runMutation }),
new Request('https://example.com/api/v1/users/reclaim', {
method: 'POST',
body: JSON.stringify({ handle: 'Target', slugs: [' A ', 'b'], reason: 'r' }),
}),
)
if (response.status !== 200) throw new Error(await response.text())
const reclaimCalls = runMutation.mock.calls.filter(([, args]) => hasSlugArgs(args))
expect(reclaimCalls).toHaveLength(2)
expect(reclaimCalls[0]?.[1]).toMatchObject({
actorUserId: 'users:admin',
slug: 'a',
rightfulOwnerUserId: 'users:target',
reason: 'r',
})
expect(reclaimCalls[1]?.[1]).toMatchObject({
actorUserId: 'users:admin',
slug: 'b',
rightfulOwnerUserId: 'users:target',
reason: 'r',
})
})
it('search forwards limit and highlightedOnly', async () => {
const runAction = vi.fn().mockResolvedValue([
{
@@ -148,7 +273,7 @@ describe('httpApiV1 handlers', () => {
expect(json.match.version).toBe('1.0.0')
})
it('lists skills with resolved tags', async () => {
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) {
return {
@@ -170,7 +295,10 @@ describe('httpApiV1 handlers', () => {
nextCursor: null,
}
}
if ('versionId' in args) return { version: '1.0.0' }
// Batch query: versionIds (plural)
if ('versionIds' in args) {
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
@@ -183,6 +311,209 @@ describe('httpApiV1 handlers', () => {
expect(json.items[0].tags.latest).toBe('1.0.0')
})
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) {
return {
items: [
{
skill: {
_id: 'skills:1',
slug: 'skill-a',
displayName: 'Skill A',
summary: 's',
tags: { latest: 'versions:1', stable: 'versions:2' },
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
},
{
skill: {
_id: 'skills:2',
slug: 'skill-b',
displayName: 'Skill B',
summary: 's',
tags: { latest: 'versions:3' },
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
},
],
nextCursor: null,
}
}
// Batch query should receive all version IDs from all skills
if ('versionIds' in args) {
const ids = args.versionIds as string[]
expect(ids).toHaveLength(3)
expect(ids).toContain('versions:1')
expect(ids).toContain('versions:2')
expect(ids).toContain('versions:3')
return [
{ _id: 'versions:1', version: '2.0.0', softDeletedAt: undefined },
{ _id: 'versions:2', version: '1.0.0', softDeletedAt: undefined },
{ _id: 'versions:3', version: '1.0.0', softDeletedAt: undefined },
]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.listSkillsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/skills'),
)
expect(response.status).toBe(200)
const json = await response.json()
// Verify tags are correctly resolved for each skill
expect(json.items[0].tags.latest).toBe('2.0.0')
expect(json.items[0].tags.stable).toBe('1.0.0')
expect(json.items[1].tags.latest).toBe('1.0.0')
// Verify batch query was called exactly once (not per-tag)
const batchCalls = runQuery.mock.calls.filter(
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
)
expect(batchCalls).toHaveLength(1)
})
it('lists souls 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) {
return {
items: [
{
soul: {
_id: 'souls:1',
slug: 'demo-soul',
displayName: 'Demo Soul',
summary: 's',
tags: { latest: 'soulVersions:1' },
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
},
],
nextCursor: null,
}
}
if ('versionIds' in args) {
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.listSoulsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/souls?limit=1'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.items[0].tags.latest).toBe('1.0.0')
})
it('batches tag resolution across multiple souls into single query', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('cursor' in args || 'limit' in args) {
return {
items: [
{
soul: {
_id: 'souls:1',
slug: 'soul-a',
displayName: 'Soul A',
summary: 's',
tags: { latest: 'soulVersions:1', stable: 'soulVersions:2' },
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
},
{
soul: {
_id: 'souls:2',
slug: 'soul-b',
displayName: 'Soul B',
summary: 's',
tags: { latest: 'soulVersions:3' },
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
},
],
nextCursor: null,
}
}
if ('versionIds' in args) {
const ids = args.versionIds as string[]
expect(ids).toHaveLength(3)
expect(ids).toContain('soulVersions:1')
expect(ids).toContain('soulVersions:2')
expect(ids).toContain('soulVersions:3')
return [
{ _id: 'soulVersions:1', version: '2.0.0', softDeletedAt: undefined },
{ _id: 'soulVersions:2', version: '1.0.0', softDeletedAt: undefined },
{ _id: 'soulVersions:3', version: '1.0.0', softDeletedAt: undefined },
]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.listSoulsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/souls'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.items[0].tags.latest).toBe('2.0.0')
expect(json.items[0].tags.stable).toBe('1.0.0')
expect(json.items[1].tags.latest).toBe('1.0.0')
const batchCalls = runQuery.mock.calls.filter(
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
)
expect(batchCalls).toHaveLength(1)
})
it('souls get resolves tags using batch query', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
soul: {
_id: 'souls:1',
slug: 'demo-soul',
displayName: 'Demo Soul',
summary: 's',
tags: { latest: 'soulVersions:1' },
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
owner: null,
}
}
if ('versionIds' in args) {
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.soulsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request('https://example.com/api/v1/souls/demo-soul'),
)
expect(response.status).toBe(200)
const json = await response.json()
expect(json.soul.tags.latest).toBe('1.0.0')
})
it('lists skills supports sort aliases', async () => {
const checks: Array<[string, string]> = [
['rating', 'stars'],
@@ -218,6 +549,52 @@ describe('httpApiV1 handlers', () => {
expect(response.status).toBe(404)
})
it('get skill returns pending-scan message for owner api token', async () => {
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
_id: 'skills:1',
slug: 'demo',
ownerUserId: 'users:1',
moderationStatus: 'hidden',
moderationReason: 'pending.scan',
}
}
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'),
)
expect(response.status).toBe(423)
expect(await response.text()).toContain('security scan is pending')
})
it('get skill returns undelete hint for owner soft-deleted skill', async () => {
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
_id: 'skills:1',
slug: 'demo',
ownerUserId: 'users:1',
softDeletedAt: 1,
moderationStatus: 'hidden',
}
}
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'),
)
expect(response.status).toBe(410)
expect(await response.text()).toContain('clawhub undelete demo')
})
it('get skill returns payload', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
@@ -241,7 +618,10 @@ describe('httpApiV1 handlers', () => {
owner: { handle: 'p', displayName: 'Peter', image: null },
}
}
if ('versionId' in args) return { version: '1.0.0' }
// Batch query for tag resolution
if ('versionIds' in args) {
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
@@ -708,4 +1088,61 @@ describe('httpApiV1 handlers', () => {
expect(json.ok).toBe(true)
expect(json.unstarred).toBe(true)
})
it('delete/undelete map forbidden/not-found/unknown to 403/404/500', async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runMutationForbidden = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('key' in args) return okRate()
throw new Error('Forbidden')
})
const forbidden = await __handlers.skillsDeleteRouterV1Handler(
makeCtx({ runMutation: runMutationForbidden }),
new Request('https://example.com/api/v1/skills/demo', {
method: 'DELETE',
headers: { Authorization: 'Bearer clh_test' },
}),
)
expect(forbidden.status).toBe(403)
expect(await forbidden.text()).toBe('Forbidden')
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runMutationNotFound = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('key' in args) return okRate()
throw new Error('Skill not found')
})
const notFound = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation: runMutationNotFound }),
new Request('https://example.com/api/v1/skills/demo/undelete', {
method: 'POST',
headers: { Authorization: 'Bearer clh_test' },
}),
)
expect(notFound.status).toBe(404)
expect(await notFound.text()).toBe('Skill not found')
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: 'users:1',
user: { handle: 'p' },
} as never)
const runMutationUnknown = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('key' in args) return okRate()
throw new Error('boom')
})
const unknown = await __handlers.soulsDeleteRouterV1Handler(
makeCtx({ runMutation: runMutationUnknown }),
new Request('https://example.com/api/v1/souls/demo-soul', {
method: 'DELETE',
headers: { Authorization: 'Bearer clh_test' },
}),
)
expect(unknown.status).toBe(500)
expect(await unknown.text()).toBe('Internal Server Error')
})
})
+24 -1348
View File
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
import { CliPublishRequestSchema, parseArk } from 'clawhub-schema'
import { internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { assertAdmin } from '../lib/access'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { corsHeaders, mergeHeaders } from '../lib/httpHeaders'
export const MAX_RAW_FILE_BYTES = 200 * 1024
const SAFE_TEXT_FILE_CSP =
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
function isSvgLike(contentType: string | undefined, path: string) {
return contentType?.toLowerCase().includes('svg') || path.toLowerCase().endsWith('.svg')
}
export function safeTextFileResponse(params: {
textContent: string
path: string
contentType?: string
sha256: string
size: number
headers?: HeadersInit
}) {
const isSvg = isSvgLike(params.contentType, params.path)
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from reading
// localStorage tokens on this origin.
const headers = mergeHeaders(
params.headers,
{
'Content-Type': params.contentType
? `${params.contentType}; charset=utf-8`
: 'text/plain; charset=utf-8',
'Cache-Control': 'private, max-age=60',
ETag: params.sha256,
'X-Content-SHA256': params.sha256,
'X-Content-Size': String(params.size),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': SAFE_TEXT_FILE_CSP,
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
},
corsHeaders(),
)
return new Response(params.textContent, { status: 200, headers })
}
export function json(value: unknown, status = 200, headers?: HeadersInit) {
return new Response(JSON.stringify(value), {
status,
headers: mergeHeaders(
{
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
headers,
corsHeaders(),
),
})
}
export function text(value: string, status: number, headers?: HeadersInit) {
return new Response(value, {
status,
headers: mergeHeaders(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
headers,
corsHeaders(),
),
})
}
export async function parseJsonPayload(request: Request, headers: HeadersInit) {
try {
const payload = (await request.json()) as Record<string, unknown>
return { ok: true as const, payload }
} catch {
return { ok: false as const, response: text('Invalid JSON', 400, headers) }
}
}
export async function requireApiTokenUserOrResponse(
ctx: ActionCtx,
request: Request,
headers: HeadersInit,
) {
try {
const auth = await requireApiTokenUser(ctx, request)
return { ok: true as const, userId: auth.userId, user: auth.user as Doc<'users'> }
} catch {
return { ok: false as const, response: text('Unauthorized', 401, headers) }
}
}
export function requireAdminOrResponse(user: Doc<'users'>, headers: HeadersInit) {
try {
assertAdmin(user)
return { ok: true as const }
} catch {
return { ok: false as const, response: text('Forbidden', 403, headers) }
}
}
export function getPathSegments(request: Request, prefix: string) {
const pathname = new URL(request.url).pathname
if (!pathname.startsWith(prefix)) return []
const rest = pathname.slice(prefix.length)
return rest
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => decodeURIComponent(segment))
}
export function toOptionalNumber(value: string | null) {
if (!value) return undefined
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : undefined
}
/**
* Batch resolve soul version tags to version strings.
* Collects all version IDs, fetches them in a single query, then maps back.
* Reduces N sequential queries to 1 batch query.
*/
export async function resolveSoulTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<'soulVersions'>>>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.souls.getVersionsByIdsInternal)
}
export async function resolveTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<'skillVersions'>>>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.skills.getVersionsByIdsInternal)
}
/**
* Batch resolve version tags to version strings.
* Collects all version IDs, fetches them in a single query, then maps back.
*
* Notes:
* - Uses `internal.*` queries to avoid expanding the public Convex API surface.
* - Sorts ids for stable query args (helps caching/log diffs).
*/
export async function resolveVersionTagsBatch<TTable extends 'skillVersions' | 'soulVersions'>(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<TTable>>>,
getVersionsByIdsQuery: unknown,
): Promise<Array<Record<string, string>>> {
const allVersionIds = new Set<Id<TTable>>()
for (const tags of tagsList) {
for (const versionId of Object.values(tags)) allVersionIds.add(versionId)
}
if (allVersionIds.size === 0) return tagsList.map(() => ({}))
const versionIds = [...allVersionIds].sort() as Array<Id<TTable>>
const versions =
((await ctx.runQuery(getVersionsByIdsQuery as never, { versionIds } as never)) as Array<{
_id: Id<TTable>
version: string
softDeletedAt?: unknown
}> | null) ?? []
const versionMap = new Map<Id<TTable>, string>()
for (const v of versions) {
if (!v?.softDeletedAt) versionMap.set(v._id, v.version)
}
return tagsList.map((tags) => {
const resolved: Record<string, string> = {}
for (const [tag, versionId] of Object.entries(tags)) {
const version = versionMap.get(versionId)
if (version) resolved[tag] = version
}
return resolved
})
}
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
function toHex(bytes: Uint8Array) {
let out = ''
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
return out
}
type FileLike = {
name: string
size: number
type: string
arrayBuffer: () => Promise<ArrayBuffer>
}
type FileLikeEntry = FormDataEntryValue & FileLike
function toFileLike(entry: FormDataEntryValue): FileLikeEntry | null {
if (typeof entry === 'string') return null
const candidate = entry as Partial<FileLike>
if (typeof candidate.name !== 'string') return null
if (typeof candidate.size !== 'number') return null
if (typeof candidate.arrayBuffer !== 'function') return null
return entry as FileLikeEntry
}
export async function parseMultipartPublish(
ctx: ActionCtx,
request: Request,
): Promise<{
slug: string
displayName: string
version: string
changelog: string
tags?: string[]
forkOf?: { slug: string; version?: string }
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
}> {
const form = await request.formData()
const payloadRaw = form.get('payload')
if (!payloadRaw || typeof payloadRaw !== 'string') {
throw new Error('Missing payload')
}
let payload: Record<string, unknown>
try {
payload = JSON.parse(payloadRaw) as Record<string, unknown>
} catch {
throw new Error('Invalid JSON payload')
}
const files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}> = []
for (const entry of form.getAll('files')) {
const file = toFileLike(entry)
if (!file) continue
const path = file.name
const size = file.size
const contentType = file.type || undefined
const buffer = new Uint8Array(await file.arrayBuffer())
const sha256 = await sha256Hex(buffer)
const storageId = await ctx.storage.store(file as Blob)
files.push({ path, size, storageId, sha256, contentType })
}
const forkOf = payload.forkOf && typeof payload.forkOf === 'object' ? payload.forkOf : undefined
const body = {
slug: payload.slug,
displayName: payload.displayName,
version: payload.version,
changelog: typeof payload.changelog === 'string' ? payload.changelog : '',
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
...(payload.source ? { source: payload.source } : {}),
files,
...(forkOf ? { forkOf } : {}),
}
return parsePublishBody(body)
}
export function parsePublishBody(body: unknown) {
const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload')
if (parsed.files.length === 0) throw new Error('files required')
const tags = parsed.tags && parsed.tags.length > 0 ? parsed.tags : undefined
return {
slug: parsed.slug,
displayName: parsed.displayName,
version: parsed.version,
changelog: parsed.changelog,
tags,
source: parsed.source ?? undefined,
forkOf: parsed.forkOf
? {
slug: parsed.forkOf.slug,
version: parsed.forkOf.version ?? undefined,
}
: undefined,
files: parsed.files.map((file) => ({
...file,
storageId: file.storageId as Id<'_storage'>,
})),
}
}
export function softDeleteErrorToResponse(
entity: 'skill' | 'soul',
error: unknown,
headers: HeadersInit,
) {
const message = error instanceof Error ? error.message : `${entity} delete failed`
const lower = message.toLowerCase()
if (lower.includes('unauthorized')) return text('Unauthorized', 401, headers)
if (lower.includes('forbidden')) return text('Forbidden', 403, headers)
if (lower.includes('not found')) return text(message, 404, headers)
if (lower.includes('slug required')) return text('Slug required', 400, headers)
// Unknown: server-side failure. Keep body generic.
return text('Internal Server Error', 500, headers)
}
+495
View File
@@ -0,0 +1,495 @@
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 { publishVersionForUser } from '../skills'
import {
MAX_RAW_FILE_BYTES,
getPathSegments,
json,
parseMultipartPublish,
parsePublishBody,
resolveTagsBatch,
safeTextFileResponse,
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from './shared'
type SearchSkillEntry = {
score: number
skill: {
slug?: string
displayName?: string
summary?: string | null
updatedAt?: number
} | null
version: { version?: string; createdAt?: number } | null
}
type ListSkillsResult = {
items: Array<{
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'skillVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'skillVersions'> | null
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
moderationInfo?: {
isPendingScan: boolean
isMalwareBlocked: boolean
isSuspicious: boolean
isHiddenByMod: boolean
isRemoved: boolean
reason?: string
} | null
} | null
type ListVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
if (!query) return json({ results: [] }, 200, rate.headers)
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
return json(
{
results: results.map((result) => ({
score: result.score,
slug: result.skill?.slug,
displayName: result.skill?.displayName,
summary: result.skill?.summary ?? null,
version: result.version?.version ?? null,
updatedAt: result.skill?.updatedAt,
})),
},
200,
rate.headers,
)
}
export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
const hash = url.searchParams.get('hash')?.trim().toLowerCase()
if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers)
if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers)
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash })
if (!resolved) return text('Skill not found', 404, rate.headers)
return json({ slug, match: resolved.match, latestVersion: resolved.latestVersion }, 200, rate.headers)
}
type SkillListSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase()
if (normalized === 'downloads') return 'downloads'
if (normalized === 'stars' || normalized === 'rating') return 'stars'
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current'
) {
return 'installsCurrent'
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return 'installsAllTime'
}
if (normalized === 'trending') return 'trending'
return 'updated'
}
export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'trending' ? undefined : rawCursor
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveTagsBatch(
ctx,
result.items.map((item) => item.skill.tags),
)
const items = result.items.map((item, idx) => ({
slug: item.skill.slug,
displayName: item.skill.displayName,
summary: item.skill.summary ?? null,
tags: resolvedTagsList[idx],
stats: item.skill.stats,
createdAt: item.skill.createdAt,
updatedAt: item.skill.updatedAt,
latestVersion: item.latestVersion
? {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
}
: null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
async function describeOwnerVisibleSkillState(
ctx: ActionCtx,
request: Request,
slug: string,
): Promise<{ status: number; message: string } | null> {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return null
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
if (!isOwner) return null
if (skill.softDeletedAt) {
return {
status: 410,
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
}
}
if (skill.moderationStatus === 'hidden') {
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
return {
status: 423,
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
}
}
if (skill.moderationReason === 'quality.low') {
return {
status: 403,
message:
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
}
}
return {
status: 403,
message: `Skill is hidden by moderation${
skill.moderationReason ? ` (${skill.moderationReason})` : ''
}.`,
}
}
if (skill.moderationStatus === 'removed') {
return { status: 410, message: 'Skill has been removed by moderation.' }
}
return null
}
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
if (segments.length === 1) {
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!result?.skill) {
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
if (hidden) return text(hidden.message, hidden.status, rate.headers)
return text('Skill not found', 404, rate.headers)
}
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags])
return json(
{
skill: {
slug: result.skill.slug,
displayName: result.skill.displayName,
summary: result.skill.summary ?? null,
tags,
stats: result.skill.stats,
createdAt: result.skill.createdAt,
updatedAt: result.skill.updatedAt,
},
latestVersion: result.latestVersion
? {
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
}
: null,
owner: result.owner
? {
handle: result.owner.handle ?? null,
userId: result.owner._id,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
: null,
moderation: result.moderationInfo
? {
isSuspicious: result.moderationInfo.isSuspicious ?? false,
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
}
: null,
},
200,
rate.headers,
)
}
if (second === 'versions' && segments.length === 2) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.skills.listVersionsPage, {
skillId: skill._id,
limit,
cursor,
})) as ListVersionsResult
const items = result.items
.filter((version) => !version.softDeletedAt)
.map((version) => ({
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
if (second === 'versions' && third && segments.length === 3) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skill._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
return json(
{
skill: { slug: skill.slug, displayName: skill.displayName },
version: {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SkillFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
},
200,
rate.headers,
)
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
let version = skillResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skillResult.skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skillResult.skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
return safeTextFileResponse({
textContent,
path: file.path,
contentType: file.contentType ?? undefined,
sha256: file.sha256,
size: file.size,
headers: rate.headers,
})
}
return text('Not found', 404, rate.headers)
}
export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
const { userId } = await requireApiTokenUser(ctx, request)
const contentType = request.headers.get('content-type') ?? ''
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
}
return text('Unsupported content type', 415, rate.headers)
}
export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
}
}
export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
}
}
+340
View File
@@ -0,0 +1,340 @@
import { api, internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
import { publishSoulVersionForUser } from '../souls'
import {
MAX_RAW_FILE_BYTES,
getPathSegments,
json,
parseMultipartPublish,
parsePublishBody,
resolveSoulTagsBatch,
safeTextFileResponse,
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from './shared'
type ListSoulsResult = {
items: Array<{
soul: {
_id: Id<'souls'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'soulVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'soulVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
type GetSoulBySlugResult = {
soul: {
_id: Id<'souls'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'soulVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'soulVersions'> | null
owner: { handle?: string; displayName?: string; image?: string } | null
} | null
type ListSoulVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
type SoulFile = Doc<'soulVersions'>['files'][number]
export async function listSoulsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.souls.listPublicPage, {
limit,
cursor,
})) as ListSoulsResult
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveSoulTagsBatch(
ctx,
result.items.map((item) => item.soul.tags),
)
const items = result.items.map((item, idx) => ({
slug: item.soul.slug,
displayName: item.soul.displayName,
summary: item.soul.summary ?? null,
tags: resolvedTagsList[idx],
stats: item.soul.stats,
createdAt: item.soul.createdAt,
updatedAt: item.soul.updatedAt,
latestVersion: item.latestVersion
? {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
}
: null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
export async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
if (segments.length === 1) {
const result = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
if (!result?.soul) return text('Soul not found', 404, rate.headers)
const [tags] = await resolveSoulTagsBatch(ctx, [result.soul.tags])
return json(
{
soul: {
slug: result.soul.slug,
displayName: result.soul.displayName,
summary: result.soul.summary ?? null,
tags,
stats: result.soul.stats,
createdAt: result.soul.createdAt,
updatedAt: result.soul.updatedAt,
},
latestVersion: result.latestVersion
? {
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
}
: null,
owner: result.owner
? {
handle: result.owner.handle ?? null,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
: null,
},
200,
rate.headers,
)
}
if (second === 'versions' && segments.length === 2) {
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.souls.listVersionsPage, {
soulId: soul._id,
limit,
cursor,
})) as ListSoulVersionsResult
const items = result.items
.filter((version) => !version.softDeletedAt)
.map((version) => ({
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
if (second === 'versions' && third && segments.length === 3) {
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
const version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
soulId: soul._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
return json(
{
soul: { slug: soul.slug, displayName: soul.displayName },
version: {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SoulFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
},
200,
rate.headers,
)
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
const soulResult = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
if (!soulResult?.soul) return text('Soul not found', 404, rate.headers)
let version = soulResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
soulId: soulResult.soul._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = soulResult.soul.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.souls.getVersionById, { versionId })
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id })
return safeTextFileResponse({
textContent,
path: file.path,
contentType: file.contentType ?? undefined,
sha256: file.sha256,
size: file.size,
headers: rate.headers,
})
}
return text('Not found', 404, rate.headers)
}
export async function publishSoulV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
const { userId } = await requireApiTokenUser(ctx, request)
const contentType = request.headers.get('content-type') ?? ''
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishSoulVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishSoulVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
}
return text('Unsupported content type', 415, rate.headers)
}
export async function soulsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('soul', error, rate.headers)
}
}
export async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('soul', error, rate.headers)
}
}
+51
View File
@@ -0,0 +1,51 @@
import { internal } from '../_generated/api'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import { getPathSegments, json, text } from './shared'
export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.addStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
export async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
+244
View File
@@ -0,0 +1,244 @@
import { api, internal } from '../_generated/api'
import type { Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import {
getPathSegments,
json,
parseJsonPayload,
requireAdminOrResponse,
requireApiTokenUserOrResponse,
text,
toOptionalNumber,
} from './shared'
export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/users/')
if (segments.length !== 1) {
return text('Not found', 404, rate.headers)
}
const action = segments[0]
if (action !== 'ban' && action !== 'role' && action !== 'restore' && action !== 'reclaim') {
return text('Not found', 404, rate.headers)
}
const payloadResult = await parseJsonPayload(request, rate.headers)
if (!payloadResult.ok) return payloadResult.response
const payload = payloadResult.payload
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers)
if (!authResult.ok) return authResult.response
const actorUserId = authResult.userId
const actorUser = authResult.user
// Restore and reclaim have different parameter shapes, handle them separately
if (action === 'restore') {
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers)
}
if (action === 'reclaim') {
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers)
}
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
const reasonRaw = typeof payload.reason === 'string' ? payload.reason.trim() : ''
if (!handleRaw && !userIdRaw) {
return text('Missing userId or handle', 400, rate.headers)
}
const roleRaw = typeof payload.role === 'string' ? payload.role.trim().toLowerCase() : ''
if (action === 'role' && !roleRaw) {
return text('Missing role', 400, rate.headers)
}
const role = roleRaw === 'user' || roleRaw === 'moderator' || roleRaw === 'admin' ? roleRaw : null
if (action === 'role' && !role) {
return text('Invalid role', 400, rate.headers)
}
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
if (!targetUserId) {
const handle = handleRaw.toLowerCase()
const user = await ctx.runQuery(api.users.getByHandle, { handle })
if (!user?._id) return text('User not found', 404, rate.headers)
targetUserId = user._id
}
if (action === 'ban') {
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.banUserInternal, {
actorUserId,
targetUserId,
reason,
})
return json(result, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Ban 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)
}
try {
const result = await ctx.runMutation(internal.users.setRoleInternal, {
actorUserId,
targetUserId,
role,
})
return json({ ok: true, role: result.role ?? role }, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Role change 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)
}
}
/**
* POST /api/v1/users/restore
* Admin-only: restore skills from GitHub backup for a user.
* Body: { handle: string, slugs: string[], forceOverwriteSquatter?: boolean }
*/
async function handleAdminRestore(
ctx: ActionCtx,
_request: Request,
payload: Record<string, unknown>,
actorUserId: Id<'users'>,
headers: HeadersInit,
) {
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
if (!handle) return text('Missing handle', 400, headers)
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
if (slugs.length > 100) return text('Too many slugs (max 100)', 400, headers)
const forceOverwriteSquatter = Boolean(payload.forceOverwriteSquatter)
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
if (!targetUser?._id) return text('User not found', 404, headers)
try {
const result = await ctx.runAction(internal.githubRestore.restoreUserSkillsFromBackup, {
actorUserId,
ownerHandle: handle,
ownerUserId: targetUser._id,
slugs,
forceOverwriteSquatter,
})
return json(result, 200, headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Restore failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, headers)
}
return text(message, 400, headers)
}
}
/**
* POST /api/v1/users/reclaim
* Admin-only: reclaim squatted slugs and reserve them for the rightful owner.
* Body: { handle: string, slugs: string[], reason?: string }
*/
async function handleAdminReclaim(
ctx: ActionCtx,
_request: Request,
payload: Record<string, unknown>,
actorUserId: Id<'users'>,
headers: HeadersInit,
) {
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
if (!handle) return text('Missing handle', 400, headers)
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
if (slugs.length > 200) return text('Too many slugs (max 200)', 400, headers)
const reason = typeof payload.reason === 'string' ? payload.reason.trim() : undefined
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
if (!targetUser?._id) return text('User not found', 404, headers)
const results: Array<{ slug: string; ok: boolean; error?: string }> = []
for (const slug of slugs) {
try {
await ctx.runMutation(internal.skills.reclaimSlugInternal, {
actorUserId,
slug: slug.trim().toLowerCase(),
rightfulOwnerUserId: targetUser._id,
reason,
})
results.push({ slug, ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Reclaim failed'
results.push({ slug, ok: false, error: message })
}
}
const succeeded = results.filter((r) => r.ok).length
const failed = results.filter((r) => !r.ok).length
return json({ ok: true, results, succeeded, failed }, 200, headers)
}
export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limitRaw = toOptionalNumber(url.searchParams.get('limit'))
const query = url.searchParams.get('q') ?? url.searchParams.get('query') ?? ''
let actorUserId: Id<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
} catch {
return text('Unauthorized', 401, rate.headers)
}
const limit = Math.min(Math.max(limitRaw ?? 20, 1), 200)
try {
const result = await ctx.runQuery(internal.users.searchInternal, {
actorUserId,
query,
limit,
})
return json(result, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'User search failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, rate.headers)
}
if (message.toLowerCase().includes('unauthorized')) {
return text('Unauthorized', 401, rate.headers)
}
return text(message, 400, rate.headers)
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import { json, text } from './shared'
export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
try {
const { user } = await requireApiTokenUser(ctx, request)
return json(
{
user: {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
},
},
200,
rate.headers,
)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
+37
View File
@@ -0,0 +1,37 @@
import { httpAction } from './_generated/server'
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
function getHeader(request: Request, name: string) {
return request.headers.get(name) ?? request.headers.get(name.toLowerCase())
}
export function buildPreflightHeaders(request: Request) {
const requestedHeaders = getHeader(request, 'Access-Control-Request-Headers')?.trim() || null
const requestedMethod = getHeader(request, 'Access-Control-Request-Method')?.trim() || null
const vary = [
...(requestedMethod ? ['Access-Control-Request-Method'] : []),
...(requestedHeaders ? ['Access-Control-Request-Headers'] : []),
].join(', ')
return mergeHeaders(
corsHeaders(),
{
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
'Access-Control-Allow-Headers':
requestedHeaders ?? 'Content-Type, Authorization, Digest, X-Clawhub-Version',
'Access-Control-Max-Age': '86400',
...(vary ? { Vary: vary } : {}),
},
)
}
export const preflightHandler = httpAction(async (_ctx, request) => {
// No cookies/credentials supported; allow any origin for simple browser access.
// If we ever add cookie auth, this must switch to reflecting origin + Allow-Credentials.
return new Response(null, {
status: 204,
headers: buildPreflightHeaders(request),
})
})
+15
View File
@@ -0,0 +1,15 @@
import type { Scheduler } from 'convex/server'
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
scheduler: Scheduler,
fn: unknown,
args: TArgs,
isDone: boolean,
continueCursor: string | null,
) {
if (isDone) return
void scheduler.runAfter(0, fn as never, {
...args,
cursor: continueCursor ?? undefined,
} as never)
}
+18
View File
@@ -0,0 +1,18 @@
const EXT_TO_TYPE: Record<string, string> = {
md: 'text/markdown',
mdx: 'text/markdown',
json: 'application/json',
json5: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
toml: 'application/toml',
svg: 'image/svg+xml',
}
export function guessContentTypeForPath(path: string) {
const trimmed = path.trim().toLowerCase()
if (!trimmed) return 'application/octet-stream'
const ext = trimmed.split('.').at(-1) ?? ''
return EXT_TO_TYPE[ext] ?? 'application/octet-stream'
}
+17
View File
@@ -0,0 +1,17 @@
export type EmbeddingVisibility =
| 'latest'
| 'latest-approved'
| 'archived'
| 'archived-approved'
| 'deleted'
export function embeddingVisibilityFor(isLatest: boolean, isApproved: boolean): Exclude<
EmbeddingVisibility,
'deleted'
> {
if (isLatest && isApproved) return 'latest-approved'
if (isLatest) return 'latest'
if (isApproved) return 'archived-approved'
return 'archived'
}
+95
View File
@@ -0,0 +1,95 @@
/* @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EMBEDDING_DIMENSIONS, generateEmbedding } from './embeddings'
const fetchMock = vi.fn<typeof fetch>()
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const originalFetch = globalThis.fetch
const originalApiKey = process.env.OPENAI_API_KEY
function jsonResponse(payload: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
'content-type': 'application/json',
},
...init,
})
}
beforeEach(() => {
fetchMock.mockReset()
globalThis.fetch = fetchMock as typeof fetch
process.env.OPENAI_API_KEY = 'test-key'
consoleWarnSpy.mockClear()
})
afterEach(() => {
globalThis.fetch = originalFetch
if (originalApiKey === undefined) {
delete process.env.OPENAI_API_KEY
} else {
process.env.OPENAI_API_KEY = originalApiKey
}
vi.useRealTimers()
})
describe('generateEmbedding', () => {
it('returns zero embedding when OPENAI_API_KEY is missing', async () => {
delete process.env.OPENAI_API_KEY
const result = await generateEmbedding('hello world')
expect(result).toHaveLength(EMBEDDING_DIMENSIONS)
expect(result.every((value) => value === 0)).toBe(true)
expect(fetchMock).not.toHaveBeenCalled()
})
it('retries on 429 responses and then succeeds', async () => {
vi.useFakeTimers()
fetchMock.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [0.25, 0.75] }] }))
const promise = generateEmbedding('retry me')
await vi.runAllTimersAsync()
await expect(promise).resolves.toEqual([0.25, 0.75])
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('does not retry non-retryable 4xx responses', async () => {
fetchMock.mockResolvedValueOnce(new Response('bad request', { status: 400 }))
await expect(generateEmbedding('bad')).rejects.toThrow('Embedding failed: bad request')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('retries on network failures and then succeeds', async () => {
vi.useFakeTimers()
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [1, 2, 3] }] }))
const promise = generateEmbedding('network retry')
await vi.runAllTimersAsync()
await expect(promise).resolves.toEqual([1, 2, 3])
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('retries timeouts up to max attempts and preserves timeout error', async () => {
vi.useFakeTimers()
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
const promise = generateEmbedding('always timeout')
const rejection = expect(promise).rejects.toThrow(
'OpenAI API request timed out after 10 seconds',
)
await vi.runAllTimersAsync()
await rejection
expect(fetchMock).toHaveBeenCalledTimes(3)
})
})
+125 -31
View File
@@ -1,10 +1,67 @@
export const EMBEDDING_MODEL = 'text-embedding-3-small'
export const EMBEDDING_DIMENSIONS = 1536
const EMBEDDING_ENDPOINT = 'https://api.openai.com/v1/embeddings'
const REQUEST_TIMEOUT_MS = 10_000
const MAX_ATTEMPTS = 3
const BASE_RETRY_DELAY_MS = 1_000
class RetryableEmbeddingError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'RetryableEmbeddingError'
}
}
function emptyEmbedding() {
return Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0)
}
function parseRetryAfterMs(retryAfterHeader: string | null) {
if (!retryAfterHeader) return null
const seconds = Number(retryAfterHeader)
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.round(seconds * 1000)
}
const dateMs = Date.parse(retryAfterHeader)
if (Number.isFinite(dateMs)) {
return Math.max(0, dateMs - Date.now())
}
return null
}
function getRetryDelayMs(attempt: number, retryAfterMs: number | null) {
const exponentialDelayMs = BASE_RETRY_DELAY_MS * 2 ** attempt
if (retryAfterMs == null) return exponentialDelayMs
return Math.max(exponentialDelayMs, retryAfterMs)
}
function normalizeRetryableNetworkError(error: unknown) {
if (!(error instanceof Error)) return null
if (error.name === 'AbortError') {
return new RetryableEmbeddingError(
`OpenAI API request timed out after ${Math.floor(REQUEST_TIMEOUT_MS / 1000)} seconds`,
{ cause: error },
)
}
if (error instanceof TypeError) {
return new RetryableEmbeddingError(`Embedding request failed: ${error.message}`, { cause: error })
}
return null
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
export async function generateEmbedding(text: string) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
@@ -12,40 +69,77 @@ export async function generateEmbedding(text: string) {
return emptyEmbedding()
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
let lastRetryableError: RetryableEmbeddingError | null = null
try {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: EMBEDDING_MODEL,
input: text,
}),
signal: controller.signal,
})
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
if (!response.ok) {
const message = await response.text()
throw new Error(`Embedding failed: ${message}`)
}
try {
const response = await fetch(EMBEDDING_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: EMBEDDING_MODEL,
input: text,
}),
signal: controller.signal,
})
const payload = (await response.json()) as {
data?: Array<{ embedding: number[] }>
if (!response.ok) {
const message = await response.text()
const isRetryableStatus = response.status === 429 || response.status >= 500
if (isRetryableStatus) {
const retryableError = new RetryableEmbeddingError(
`Embedding failed (${response.status}): ${message}`,
)
lastRetryableError = retryableError
if (attempt < MAX_ATTEMPTS - 1) {
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'))
const delayMs = getRetryDelayMs(attempt, retryAfterMs)
console.warn(
`OpenAI embeddings retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
)
await sleep(delayMs)
continue
}
throw retryableError
}
throw new Error(`Embedding failed: ${message}`)
}
const payload = (await response.json()) as {
data?: Array<{ embedding: number[] }>
}
const embedding = payload.data?.[0]?.embedding
if (!embedding) throw new Error('Embedding missing from response')
return embedding
} catch (error) {
const retryableNetworkError = normalizeRetryableNetworkError(error)
if (retryableNetworkError) {
lastRetryableError = retryableNetworkError
if (attempt < MAX_ATTEMPTS - 1) {
const delayMs = getRetryDelayMs(attempt, null)
console.warn(
`OpenAI embeddings network retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
)
await sleep(delayMs)
continue
}
throw retryableNetworkError
}
throw error
} finally {
clearTimeout(timeoutId)
}
const embedding = payload.data?.[0]?.embedding
if (!embedding) throw new Error('Embedding missing from response')
return embedding
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('OpenAI API request timed out after 10 seconds', { cause: error })
}
throw error
} finally {
clearTimeout(timeoutId)
}
throw lastRetryableError ?? new Error('Embedding failed after retries')
}
+251 -59
View File
@@ -2,13 +2,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { internal } from '../_generated/api'
import { requireGitHubAccountAge } from './githubAccount'
import { requireGitHubAccountAge, syncGitHubProfile } from './githubAccount'
vi.mock('../_generated/api', () => ({
internal: {
githubIdentity: {
getGitHubProviderAccountIdInternal: Symbol('getGitHubProviderAccountIdInternal'),
},
users: {
getByIdInternal: Symbol('getByIdInternal'),
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
setGitHubCreatedAtInternal: Symbol('setGitHubCreatedAtInternal'),
syncGitHubProfileInternal: Symbol('syncGitHubProfileInternal'),
},
},
}))
@@ -19,21 +23,23 @@ describe('requireGitHubAccountAge', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('uses cached githubCreatedAt when fresh', async () => {
it('uses cached githubCreatedAt when present', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
@@ -44,53 +50,55 @@ describe('requireGitHubAccountAge', () => {
expect(fetchMock).not.toHaveBeenCalled()
expect(runMutation).not.toHaveBeenCalled()
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
expect(runQuery).not.toHaveBeenCalledWith(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId: 'users:1' },
)
})
vi.useRealTimers()
it('rejects deactivated users', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
deactivatedAt: Date.now(),
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/User not found/i)
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects accounts younger than 7 days', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'newbie',
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
})
const runMutation = vi.fn()
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
vi.useRealTimers()
})
it('rejects deactivated users', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
deactivatedAt: Date.now(),
})
const runMutation = vi.fn()
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/User not found/i)
})
it('refreshes githubCreatedAt when cache is stale', async () => {
it('fetches githubCreatedAt when missing (by providerAccountId)', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
})
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -103,27 +111,60 @@ describe('requireGitHubAccountAge', () => {
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/users/steipete',
'https://api.github.com/user/12345',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'clawhub' }),
}),
)
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
expect(runMutation).toHaveBeenCalledWith(internal.users.setGitHubCreatedAtInternal, {
userId: 'users:1',
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
githubFetchedAt: now.getTime(),
})
})
vi.useRealTimers()
it('rejects when providerAccountId is missing', async () => {
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce(null)
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account required/i)
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects when providerAccountId is invalid', async () => {
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('abc123')
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account lookup failed/i)
expect(fetchMock).not.toHaveBeenCalled()
})
it('throws when GitHub lookup fails', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404 })
vi.stubGlobal('fetch', fetchMock)
@@ -134,12 +175,12 @@ describe('requireGitHubAccountAge', () => {
})
it('throws rate-limit error on 403', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 403 })
vi.stubGlobal('fetch', fetchMock)
@@ -150,12 +191,12 @@ describe('requireGitHubAccountAge', () => {
})
it('throws rate-limit error on 429', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429 })
vi.stubGlobal('fetch', fetchMock)
@@ -165,6 +206,25 @@ describe('requireGitHubAccountAge', () => {
).rejects.toThrow(/rate limit exceeded/i)
})
it('throws when GitHub returns an invalid payload', async () => {
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
})
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account lookup failed/i)
})
it('includes Authorization header when GITHUB_TOKEN is set', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
@@ -172,12 +232,12 @@ describe('requireGitHubAccountAge', () => {
vi.stubEnv('GITHUB_TOKEN', 'ghp_test123')
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
})
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -190,7 +250,7 @@ describe('requireGitHubAccountAge', () => {
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/users/steipete',
'https://api.github.com/user/12345',
expect.objectContaining({
headers: {
'User-Agent': 'clawhub',
@@ -198,7 +258,139 @@ describe('requireGitHubAccountAge', () => {
},
}),
)
})
})
describe('syncGitHubProfile', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('skips recent syncs (throttle)', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
name: 'oldname',
githubProfileSyncedAt: now.getTime(),
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).not.toHaveBeenCalled()
expect(runMutation).not.toHaveBeenCalled()
})
it('updates profile even when only avatar changes', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
name: 'same',
image: 'https://avatars.githubusercontent.com/u/1?v=3',
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
login: 'same',
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4',
}),
})
vi.stubGlobal('fetch', fetchMock)
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
userId: 'users:1',
name: 'same',
image: 'https://avatars.githubusercontent.com/u/1?v=4',
syncedAt: now.getTime(),
})
})
it('updates name and records sync timestamp', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
name: 'old',
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
login: 'new',
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=1',
}),
})
vi.stubGlobal('fetch', fetchMock)
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
userId: 'users:1',
name: 'new',
image: 'https://avatars.githubusercontent.com/u/1?v=1',
syncedAt: now.getTime(),
})
})
it('forwards GitHub profile name (full name) when present', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn()
.mockResolvedValueOnce({
_id: 'users:1',
name: 'same',
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
login: 'same',
name: 'Real Name',
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=1',
}),
})
vi.stubGlobal('fetch', fetchMock)
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
userId: 'users:1',
name: 'same',
image: 'https://avatars.githubusercontent.com/u/1?v=1',
profileName: 'Real Name',
syncedAt: now.getTime(),
})
})
})
+88 -15
View File
@@ -2,36 +2,54 @@ import { ConvexError } from 'convex/values'
import { internal } from '../_generated/api'
import type { Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { GITHUB_PROFILE_SYNC_WINDOW_MS } from './githubProfileSync'
const GITHUB_API = 'https://api.github.com'
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
type GitHubUser = {
login?: string
name?: string
avatar_url?: string
created_at?: string
}
function assertGitHubNumericId(providerAccountId: string) {
if (!/^[0-9]+$/.test(providerAccountId)) {
throw new ConvexError('GitHub account lookup failed')
}
}
function buildGitHubHeaders() {
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
const token = process.env.GITHUB_TOKEN
if (token) {
headers.Authorization = `Bearer ${token}`
}
return headers
}
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError('User not found')
const handle = user.handle?.trim()
if (!handle) throw new ConvexError('GitHub handle required')
const now = Date.now()
let createdAt = user.githubCreatedAt ?? null
const fetchedAt = user.githubFetchedAt ?? 0
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
if (stale) {
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
const token = process.env.GITHUB_TOKEN
if (token) {
headers.Authorization = `Bearer ${token}`
if (!createdAt) {
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
)
if (!providerAccountId) {
// Invariant: GitHub is our only auth provider, so this should never happen.
throw new ConvexError('GitHub account required')
}
assertGitHubNumericId(providerAccountId)
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
headers,
// Fetch by immutable GitHub numeric ID to avoid username swap attacks entirely.
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
headers: buildGitHubHeaders(),
})
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
@@ -45,10 +63,9 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
createdAt = parsed
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
await ctx.runMutation(internal.users.setGitHubCreatedAtInternal, {
userId,
githubCreatedAt: createdAt,
githubFetchedAt: now,
})
}
@@ -65,3 +82,59 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
)
}
}
/**
* Sync the user's GitHub profile (username, avatar) from the GitHub API.
* This handles the case where a user renames their GitHub account.
* Uses the immutable GitHub numeric ID to fetch the current profile.
*/
export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt || user.deactivatedAt) return
const now = Date.now()
const lastSyncedAt = user.githubProfileSyncedAt ?? null
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
)
if (!providerAccountId) return
assertGitHubNumericId(providerAccountId)
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
headers: buildGitHubHeaders(),
})
if (!response.ok) {
// Silently fail - this is a best-effort sync, not critical path
console.warn(`[syncGitHubProfile] GitHub API error for user ${userId}: ${response.status}`)
return
}
const payload = (await response.json()) as GitHubUser
const newLogin = payload.login?.trim()
const newImage = payload.avatar_url?.trim()
const profileName = payload.name?.trim()
if (!newLogin) return
const args: {
userId: Id<'users'>
name: string
image?: string
syncedAt: number
profileName?: string
} = {
userId,
name: newLogin,
image: newImage,
syncedAt: now,
}
if (profileName && profileName !== newLogin) {
args.profileName = profileName
}
await ctx.runMutation(internal.users.syncGitHubProfileInternal, args)
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { canHealSkillOwnershipByGitHubProviderAccountId } from './githubIdentity'
describe('canHealSkillOwnershipByGitHubProviderAccountId', () => {
it('denies when either providerAccountId is missing', () => {
expect(canHealSkillOwnershipByGitHubProviderAccountId(undefined, undefined)).toBe(false)
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', undefined)).toBe(false)
expect(canHealSkillOwnershipByGitHubProviderAccountId(undefined, '123')).toBe(false)
expect(canHealSkillOwnershipByGitHubProviderAccountId(null, '123')).toBe(false)
})
it('denies when providerAccountId differs', () => {
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', '456')).toBe(false)
})
it('allows when providerAccountId matches', () => {
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', '123')).toBe(true)
})
})
+22
View File
@@ -0,0 +1,22 @@
import type { Id } from '../_generated/dataModel'
import type { QueryCtx } from '../_generated/server'
export function canHealSkillOwnershipByGitHubProviderAccountId(
ownerProviderAccountId: string | null | undefined,
callerProviderAccountId: string | null | undefined,
) {
// Security invariant: missing identity must never grant ownership.
if (!ownerProviderAccountId || !callerProviderAccountId) return false
return ownerProviderAccountId === callerProviderAccountId
}
export async function getGitHubProviderAccountId(
ctx: Pick<QueryCtx, 'db'>,
userId: Id<'users'>,
): Promise<string | null> {
const account = await ctx.db
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId).eq('provider', 'github'))
.unique()
return account?.providerAccountId ?? null
}
+19
View File
@@ -0,0 +1,19 @@
export const GITHUB_PROFILE_SYNC_WINDOW_MS = 6 * 60 * 60 * 1000
export function shouldScheduleGitHubProfileSync(
user:
| {
deletedAt?: number
deactivatedAt?: number
githubProfileSyncedAt?: number
}
| null
| undefined,
now: number,
) {
if (!user || user.deletedAt || user.deactivatedAt) return false
const lastSyncedAt = user.githubProfileSyncedAt ?? null
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return false
return true
}
+54
View File
@@ -0,0 +1,54 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitHubBackupContext } from './githubBackup'
import { readGitHubBackupFile } from './githubRestoreHelpers'
function makeContext(): GitHubBackupContext {
return {
token: 'token',
repo: 'owner/repo',
repoOwner: 'owner',
repoName: 'repo',
branch: 'main',
root: 'skills',
}
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('githubRestoreHelpers', () => {
it('decodes base64 payloads (including newlines) into bytes', async () => {
const content = 'SGVs\n bG8h' // "Hello!" with whitespace/newline
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ content, encoding: 'base64' }),
text: async () => '',
})),
)
const bytes = await readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')
expect(bytes).not.toBeNull()
expect(Buffer.from(bytes!).toString('utf8')).toBe('Hello!')
})
it('throws on unsupported GitHub content encoding', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ content: 'eA==', encoding: 'utf-16' }),
text: async () => '',
})),
)
await expect(readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')).rejects.toThrow(
/Unsupported GitHub content encoding/i,
)
})
})
+159
View File
@@ -0,0 +1,159 @@
'use node'
import type { GitHubBackupContext } from './githubBackup'
const GITHUB_API = 'https://api.github.com'
const META_FILENAME = '_meta.json'
const USER_AGENT = 'clawhub/skills-restore'
type GitHubContentsEntry = {
name?: string
path?: string
type?: string // 'file' | 'dir'
size?: number
}
type GitHubBlobResponse = {
content?: string
encoding?: string
size?: number
}
/**
* List all files in a skill's backup directory (excluding _meta.json).
* Uses the Contents API scoped to the target directory instead of fetching
* the entire repository tree, which is critical for bulk restore performance.
* Returns relative file paths (e.g. "SKILL.md", "lib/helper.ts").
*/
export async function listGitHubBackupFiles(
context: GitHubBackupContext,
ownerHandle: string,
slug: string,
): Promise<string[]> {
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
return listFilesRecursive(context, skillRoot, '')
}
/**
* Recursively list files under a directory using the GitHub Contents API.
* Each call is scoped to one directory, avoiding full-repo tree downloads.
*/
async function listFilesRecursive(
context: GitHubBackupContext,
basePath: string,
relativePath: string,
): Promise<string[]> {
const dirPath = relativePath ? `${basePath}/${relativePath}` : basePath
try {
const entries = await githubGet<GitHubContentsEntry[]>(
context.token,
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(dirPath)}?ref=${context.branch}`,
)
if (!Array.isArray(entries)) return []
const files: string[] = []
for (const entry of entries) {
if (!entry.name || !entry.type) continue
const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name
if (entry.type === 'file') {
// Skip the meta file
if (entry.name === META_FILENAME) continue
files.push(entryRelative)
} else if (entry.type === 'dir') {
// Recurse into subdirectories
const subFiles = await listFilesRecursive(context, basePath, entryRelative)
files.push(...subFiles)
}
}
return files
} catch (error) {
if (isNotFoundError(error)) return []
throw error
}
}
/**
* Read a single file from the GitHub backup repository.
* Returns the file content as a Uint8Array, or null if not found.
*/
export async function readGitHubBackupFile(
context: GitHubBackupContext,
ownerHandle: string,
slug: string,
filePath: string,
): Promise<Uint8Array | null> {
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
const fullPath = `${skillRoot}/${filePath}`
try {
const response = await githubGet<GitHubBlobResponse>(
context.token,
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(fullPath)}?ref=${context.branch}`,
)
if (!response.content) return null
if (response.encoding && response.encoding !== 'base64') {
throw new Error(`Unsupported GitHub content encoding: ${response.encoding}`)
}
return fromBase64Bytes(response.content)
} catch (error) {
if (isNotFoundError(error)) return null
throw error
}
}
function buildSkillRoot(root: string, ownerHandle: string, slug: string) {
const ownerSegment = normalizeOwner(ownerHandle)
return `${root}/${ownerSegment}/${slug}`
}
function normalizeOwner(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || 'unknown'
}
function encodePath(path: string) {
return path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
}
function fromBase64Bytes(value: string) {
// GitHub may include newlines in the base64 payload.
const normalized = value.replace(/\s/g, '')
return new Uint8Array(Buffer.from(normalized, 'base64'))
}
async function githubGet<T>(token: string, path: string): Promise<T> {
const response = await fetch(`${GITHUB_API}${path}`, {
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
})
if (!response.ok) {
const message = await response.text()
throw new Error(`GitHub GET ${path} failed: ${message}`)
}
return (await response.json()) as T
}
function isNotFoundError(error: unknown) {
return (
error instanceof Error && (error.message.includes('404') || error.message.includes('Not Found'))
)
}
+19
View File
@@ -0,0 +1,19 @@
function toHeaderRecord(init?: HeadersInit): Record<string, string> {
if (!init) return {}
if (init instanceof Headers) return Object.fromEntries(init.entries())
if (Array.isArray(init)) return Object.fromEntries(init)
return { ...(init as Record<string, string>) }
}
export function mergeHeaders(...inits: Array<HeadersInit | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const init of inits) {
Object.assign(out, toHeaderRecord(init))
}
return out
}
export function corsHeaders(origin: string = '*'): Record<string, string> {
return { 'Access-Control-Allow-Origin': origin }
}
+16 -6
View File
@@ -1,19 +1,31 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getClientIp } from './httpRateLimit'
describe('getClientIp', () => {
it('uses forwarded headers by default when cf-connecting-ip is missing', () => {
let prev: string | undefined
beforeEach(() => {
prev = process.env.TRUST_FORWARDED_IPS
})
afterEach(() => {
if (prev === undefined) {
delete process.env.TRUST_FORWARDED_IPS
} else {
process.env.TRUST_FORWARDED_IPS = prev
}
})
it('returns null when cf-connecting-ip is missing (CF-only default)', () => {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-for': '203.0.113.9',
},
})
delete process.env.TRUST_FORWARDED_IPS
expect(getClientIp(request)).toBe('203.0.113.9')
expect(getClientIp(request)).toBeNull()
})
it('can disable forwarded headers explicitly', () => {
it('keeps forwarded headers disabled when TRUST_FORWARDED_IPS=false', () => {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-for': '203.0.113.9',
@@ -21,7 +33,6 @@ describe('getClientIp', () => {
})
process.env.TRUST_FORWARDED_IPS = 'false'
expect(getClientIp(request)).toBeNull()
delete process.env.TRUST_FORWARDED_IPS
})
it('returns first ip from cf-connecting-ip', () => {
@@ -41,6 +52,5 @@ describe('getClientIp', () => {
})
process.env.TRUST_FORWARDED_IPS = 'true'
expect(getClientIp(request)).toBe('203.0.113.9')
delete process.env.TRUST_FORWARDED_IPS
})
})
+5 -6
View File
@@ -1,5 +1,6 @@
import { internal } from '../_generated/api'
import type { ActionCtx } from '../_generated/server'
import { corsHeaders, mergeHeaders } from './httpHeaders'
import { hashToken } from './tokens'
const RATE_LIMIT_WINDOW_MS = 60_000
@@ -42,6 +43,7 @@ export async function applyRateLimit(
'Cache-Control': 'no-store',
},
headers,
corsHeaders(),
),
}),
}
@@ -141,17 +143,14 @@ function splitFirstIp(header: string | null) {
return trimmed || null
}
function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
}
function shouldTrustForwardedIps() {
const value = String(process.env.TRUST_FORWARDED_IPS ?? '')
.trim()
.toLowerCase()
if (!value) return true
// Hardening default: CF-only. Forwarded headers are trivial to spoof unless you
// control the trusted proxy layer.
if (!value) return false
if (value === '1' || value === 'true' || value === 'yes') return true
if (value === '0' || value === 'false' || value === 'no') return false
return false
}
+128
View File
@@ -0,0 +1,128 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
type ReservedSlug = Doc<'reservedSlugs'>
const DEFAULT_ACTIVE_LIMIT = 25
function reservedSlugQuery(ctx: QueryCtx | MutationCtx, slug: string) {
return ctx.db
.query('reservedSlugs')
.withIndex('by_slug_active_deletedAt', (q) => q.eq('slug', slug).eq('releasedAt', undefined))
.order('desc')
}
export async function listActiveReservedSlugsForSlug(
ctx: QueryCtx | MutationCtx,
slug: string,
limit = DEFAULT_ACTIVE_LIMIT,
) {
return reservedSlugQuery(ctx, slug).take(limit)
}
export async function getLatestActiveReservedSlug(ctx: QueryCtx | MutationCtx, slug: string) {
return (await reservedSlugQuery(ctx, slug).take(1))[0] ?? null
}
export async function releaseDuplicateActiveReservations(
ctx: MutationCtx,
active: ReservedSlug[],
keepId: Id<'reservedSlugs'> | null | undefined,
releasedAt: number,
) {
for (const stale of active) {
if (keepId && stale._id === keepId) continue
await ctx.db.patch(stale._id, { releasedAt })
}
}
export async function reserveSlugForHardDeleteFinalize(
ctx: MutationCtx,
params: {
slug: string
originalOwnerUserId: Id<'users'>
deletedAt: number
expiresAt: number
},
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
if (latest) {
// Only extend reservation if it matches the owner being deleted.
// If it points elsewhere, it likely came from a reclaim flow; do not overwrite.
if (latest.originalOwnerUserId === params.originalOwnerUserId) {
await ctx.db.patch(latest._id, {
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
releasedAt: undefined,
})
}
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.deletedAt)
return
}
const inserted = await ctx.db.insert('reservedSlugs', {
slug: params.slug,
originalOwnerUserId: params.originalOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
})
await releaseDuplicateActiveReservations(ctx, active, inserted, params.deletedAt)
}
export async function upsertReservedSlugForRightfulOwner(
ctx: MutationCtx,
params: {
slug: string
rightfulOwnerUserId: Id<'users'>
deletedAt: number
expiresAt: number
reason?: string
},
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
let keepId: Id<'reservedSlugs'>
if (latest) {
keepId = latest._id
await ctx.db.patch(latest._id, {
originalOwnerUserId: params.rightfulOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
reason: params.reason ?? latest.reason,
releasedAt: undefined,
})
} else {
keepId = await ctx.db.insert('reservedSlugs', {
slug: params.slug,
originalOwnerUserId: params.rightfulOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
reason: params.reason,
})
}
await releaseDuplicateActiveReservations(ctx, active, keepId, params.deletedAt)
}
export async function enforceReservedSlugCooldownForNewSkill(
ctx: MutationCtx,
params: { slug: string; userId: Id<'users'>; now: number },
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
if (!latest) return
if (latest.expiresAt > params.now && latest.originalOwnerUserId !== params.userId) {
throw new Error(
`Slug "${params.slug}" is reserved for its previous owner until ${new Date(latest.expiresAt).toISOString()}. ` +
'Please choose a different slug.',
)
}
await ctx.db.patch(latest._id, { releasedAt: params.now })
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
}
+25
View File
@@ -77,4 +77,29 @@ description: Expert guidance for sushi-rolls.
expect(quality.decision).toBe('reject')
expect(quality.reason).toContain('template spam')
})
it('does not undercount non-latin skill docs', () => {
const signals = __test.computeQualitySignals({
readmeText: `# 飞书图片助手
##
- image_key
-
- 便
## 使
便
便
`,
summary: '上传并发送图片到飞书,支持缓存、重试和错误诊断。',
})
const quality = __test.evaluateQuality({
signals,
trustTier: 'low',
similarRecentCount: 0,
})
expect(signals.bodyWords).toBeGreaterThanOrEqual(45)
expect(quality.decision).toBe('pass')
})
})
+39 -17
View File
@@ -19,6 +19,7 @@ import { generateSkillSummary } from './skillSummary'
import {
buildEmbeddingText,
getFrontmatterMetadata,
getFrontmatterValue,
hashSkillFiles,
isTextFile,
parseClawdisMetadata,
@@ -63,10 +64,19 @@ export type PublishVersionArgs = {
}>
}
export type PublishOptions = {
bypassGitHubAccountAge?: boolean
bypassNewSkillRateLimit?: boolean
bypassQualityGate?: boolean
skipBackup?: boolean
skipWebhook?: boolean
}
export async function publishVersionForUser(
ctx: ActionCtx,
userId: Id<'users'>,
args: PublishVersionArgs,
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim()
const slug = args.slug.trim().toLowerCase()
@@ -79,7 +89,9 @@ export async function publishVersionForUser(
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
if (!options.bypassGitHubAccountAge) {
await requireGitHubAccountAge(ctx, userId)
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
})) as Doc<'skills'> | null
@@ -122,13 +134,18 @@ export async function publishVersionForUser(
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now()
const now = Date.now()
const frontmatterMetadata = getFrontmatterMetadata(frontmatter)
const summaryFromFrontmatter =
// Check for description in metadata.description (nested) or description (direct frontmatter field)
const metadataDescription =
frontmatterMetadata &&
typeof frontmatterMetadata === 'object' &&
!Array.isArray(frontmatterMetadata) &&
typeof (frontmatterMetadata as Record<string, unknown>).description === 'string'
? ((frontmatterMetadata as Record<string, unknown>).description as string)
: undefined
const directDescription = getFrontmatterValue(frontmatter, 'description')
// Prioritize the new description from frontmatter over the existing skill summary
// This ensures updates to the description are reflected on subsequent publishes (#301)
const summaryFromFrontmatter = metadataDescription ?? directDescription
const summary = await generateSkillSummary({
slug,
displayName,
@@ -137,7 +154,7 @@ export async function publishVersionForUser(
})
let qualityAssessment: QualityAssessment | null = null
if (isNewSkill) {
if (isNewSkill && !options.bypassQualityGate) {
const ownerActivity = (await ctx.runQuery(internal.skills.getOwnerSkillActivityInternal, {
ownerUserId: userId,
limit: QUALITY_ACTIVITY_LIMIT,
@@ -240,6 +257,7 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
bypassNewSkillRateLimit: options.bypassNewSkillRateLimit || undefined,
files: safeFiles.map((file) => ({
...file,
path: file.path,
@@ -273,24 +291,28 @@ export async function publishVersionForUser(
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
void ctx.scheduler
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
if (!options.skipBackup) {
void ctx.scheduler
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
slug,
version,
displayName,
ownerHandle,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
console.error('GitHub backup scheduling failed', error)
})
}
if (!options.skipWebhook) {
void schedulePublishWebhook(ctx, {
slug,
version,
displayName,
ownerHandle,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
console.error('GitHub backup scheduling failed', error)
})
void schedulePublishWebhook(ctx, {
slug,
version,
displayName,
})
}
return publishResult
}
+37 -2
View File
@@ -23,6 +23,7 @@ export type QualitySignals = {
bulletCount: number
templateMarkerHits: number
genericSummary: boolean
cjkChars: number
structuralFingerprint: string
}
@@ -40,6 +41,29 @@ function stripFrontmatter(raw: string) {
}
function tokenizeWords(text: string) {
const segmenterCtor = (Intl as typeof Intl & {
Segmenter?: new (
locale?: string | string[],
options?: { granularity?: 'grapheme' | 'word' | 'sentence' },
) => {
segment: (
input: string,
) => Iterable<{ segment: string; isWordLike?: boolean }>
}
}).Segmenter
if (segmenterCtor) {
const segmenter = new segmenterCtor(undefined, { granularity: 'word' })
const tokens: string[] = []
for (const entry of segmenter.segment(text)) {
if (!entry.isWordLike) continue
const token = entry.segment.trim().toLowerCase()
if (!token) continue
tokens.push(token)
}
if (tokens.length > 0) return tokens
}
return (text.toLowerCase().match(/[a-z0-9][a-z0-9'-]*/g) ?? []).filter((word) => word.length > 1)
}
@@ -95,6 +119,7 @@ export function computeQualitySignals(args: {
const templateMarkerHits = TEMPLATE_MARKERS.filter((marker) => bodyLower.includes(marker)).length
const summary = (args.summary ?? '').trim().toLowerCase()
const genericSummary = /^expert guidance for [a-z0-9-]+\.?$/.test(summary)
const cjkChars = (body.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? []).length
return {
bodyChars,
@@ -104,6 +129,7 @@ export function computeQualitySignals(args: {
bulletCount,
templateMarkerHits,
genericSummary,
cjkChars,
structuralFingerprint: toStructuralFingerprint(args.readmeText),
}
}
@@ -127,8 +153,14 @@ export function evaluateQuality(args: {
}): QualityAssessment {
const { signals, trustTier, similarRecentCount } = args
const score = scoreQuality(signals)
const rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
const rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
const cjkHeavy =
signals.cjkChars >= 40 || (signals.bodyChars > 0 && signals.cjkChars / signals.bodyChars >= 0.15)
let rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
let rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
if (cjkHeavy) {
rejectWordsThreshold = Math.max(24, rejectWordsThreshold - 16)
rejectCharsThreshold = Math.max(140, rejectCharsThreshold - 120)
}
const quarantineScoreThreshold = trustTier === 'low' ? 72 : trustTier === 'medium' ? 60 : 50
const similarityRejectThreshold = trustTier === 'low' ? 5 : trustTier === 'medium' ? 8 : 12
@@ -157,6 +189,7 @@ export function evaluateQuality(args: {
bulletCount: signals.bulletCount,
templateMarkerHits: signals.templateMarkerHits,
genericSummary: signals.genericSummary,
cjkChars: signals.cjkChars,
},
}
}
@@ -176,6 +209,7 @@ export function evaluateQuality(args: {
bulletCount: signals.bulletCount,
templateMarkerHits: signals.templateMarkerHits,
genericSummary: signals.genericSummary,
cjkChars: signals.cjkChars,
},
}
}
@@ -194,6 +228,7 @@ export function evaluateQuality(args: {
bulletCount: signals.bulletCount,
templateMarkerHits: signals.templateMarkerHits,
genericSummary: signals.genericSummary,
cjkChars: signals.cjkChars,
},
}
}
+2 -17
View File
@@ -251,23 +251,8 @@ export const evaluateWithLlm = internalAction({
`[llmEval] Evaluated ${skill.slug}@${version.version}: ${result.verdict} (${result.confidence} confidence)`,
)
// 10. Update moderation flags — re-read version to get the sha256hash
// that VT may have stored while we were evaluating (both run concurrently).
const freshVersion = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: args.versionId,
})) as Doc<'skillVersions'> | null
const sha256hash = freshVersion?.sha256hash ?? version.sha256hash
if (sha256hash) {
const status = verdictToStatus(result.verdict)
if (status === 'malicious' || status === 'suspicious' || status === 'clean') {
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
sha256hash,
scanner: 'llm',
status,
})
}
}
// Moderation visibility is finalized by VT results.
// LLM eval only stores analysis payload on the version.
},
})
+1
View File
@@ -999,6 +999,7 @@ export const applyEmptySkillCleanupInternal = internalMutation({
bulletCount: v.number(),
templateMarkerHits: v.number(),
genericSummary: v.boolean(),
cjkChars: v.optional(v.number()),
}),
}),
},
+18 -3
View File
@@ -3,8 +3,6 @@ import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
const authSchema = authTables as unknown as Record<string, ReturnType<typeof defineTable>>
const users = defineTable({
name: v.optional(v.string()),
image: v.optional(v.string()),
@@ -19,6 +17,8 @@ const users = defineTable({
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
githubCreatedAt: v.optional(v.number()),
githubFetchedAt: v.optional(v.number()),
githubProfileSyncedAt: v.optional(v.number()),
trustedPublisher: v.optional(v.boolean()),
deactivatedAt: v.optional(v.number()),
purgedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
@@ -96,6 +96,7 @@ const skills = defineTable({
bulletCount: v.number(),
templateMarkerHits: v.number(),
genericSummary: v.boolean(),
cjkChars: v.optional(v.number()),
}),
evaluatedAt: v.number(),
}),
@@ -501,6 +502,19 @@ const downloadDedupes = defineTable({
.index('by_skill_identity_hour', ['skillId', 'identityHash', 'hourStart'])
.index('by_hour', ['hourStart'])
const reservedSlugs = defineTable({
slug: v.string(),
originalOwnerUserId: v.id('users'),
deletedAt: v.number(),
expiresAt: v.number(),
reason: v.optional(v.string()),
releasedAt: v.optional(v.number()),
})
.index('by_slug', ['slug'])
.index('by_slug_active_deletedAt', ['slug', 'releasedAt', 'deletedAt'])
.index('by_owner', ['originalOwnerUserId'])
.index('by_expiry', ['expiresAt'])
const githubBackupSyncState = defineTable({
key: v.string(),
cursor: v.optional(v.string()),
@@ -546,7 +560,7 @@ const userSkillRootInstalls = defineTable({
.index('by_skill', ['skillId'])
export default defineSchema({
...authSchema,
...authTables,
users,
skills,
souls,
@@ -572,6 +586,7 @@ export default defineSchema({
apiTokens,
rateLimits,
downloadDedupes,
reservedSlugs,
githubBackupSyncState,
userSyncRoots,
userSkillInstalls,
+5
View File
@@ -45,6 +45,7 @@ describe('search helpers', () => {
skill: makePublicSkill({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' }),
version: null,
ownerHandle: 'steipete',
owner: null,
},
]
const runQuery = vi
@@ -154,6 +155,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'one',
owner: null,
},
{
embeddingId: 'skillEmbeddings:b',
@@ -165,6 +167,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'two',
owner: null,
},
]
const fallbackEntries = [
@@ -177,6 +180,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'one',
owner: null,
},
{
skill: makePublicSkill({
@@ -187,6 +191,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'three',
owner: null,
},
]
+38 -28
View File
@@ -1,18 +1,36 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { QueryCtx } from './_generated/server'
import { action, internalQuery } from './_generated/server'
import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
import { generateEmbedding } from './lib/embeddings'
import { toPublicSkill, toPublicSoul } from './lib/public'
import { toPublicSkill, toPublicSoul, toPublicUser } from './lib/public'
import { matchesExactTokens, tokenize } from './lib/searchText'
import { isSkillSuspicious } from './lib/skillSafety'
type OwnerInfo = { handle: string | null; owner: ReturnType<typeof toPublicUser> | null }
function makeOwnerInfoGetter(ctx: Pick<QueryCtx, 'db'>) {
const ownerCache = new Map<Id<'users'>, Promise<OwnerInfo>>()
return (ownerUserId: Id<'users'>) => {
const cached = ownerCache.get(ownerUserId)
if (cached) return cached
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => ({
handle: ownerDoc?.handle ?? (ownerDoc?._id ? String(ownerDoc._id) : null),
owner: toPublicUser(ownerDoc),
}))
ownerCache.set(ownerUserId, ownerPromise)
return ownerPromise
}
}
type SkillSearchEntry = {
embeddingId?: Id<'skillEmbeddings'>
skill: NonNullable<ReturnType<typeof toPublicSkill>>
version: Doc<'skillVersions'> | null
ownerHandle: string | null
owner: ReturnType<typeof toPublicUser> | null
}
type SearchResult = SkillSearchEntry & { score: number }
@@ -211,17 +229,7 @@ export const hydrateResults = internalQuery({
nonSuspiciousOnly: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db
.get(ownerUserId)
.then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
const getOwnerInfo = makeOwnerInfoGetter(ctx)
const entries: Array<SkillSearchEntry | null> = await Promise.all(
args.embeddingIds.map(async (embeddingId) => {
@@ -230,13 +238,19 @@ export const hydrateResults = internalQuery({
const skill = await ctx.db.get(embedding.skillId)
if (!skill || skill.softDeletedAt) return null
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null
const [version, ownerHandle] = await Promise.all([
const [version, ownerInfo] = await Promise.all([
ctx.db.get(embedding.versionId),
getOwnerHandle(skill.ownerUserId),
getOwnerInfo(skill.ownerUserId),
])
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { embeddingId, skill: publicSkill, version, ownerHandle }
return {
embeddingId,
skill: publicSkill,
version,
ownerHandle: ownerInfo.handle,
owner: ownerInfo.owner,
}
}),
)
@@ -291,26 +305,22 @@ export const lexicalFallbackSkills = internalQuery({
)
if (matched.length === 0) return []
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db
.get(ownerUserId)
.then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
const getOwnerInfo = makeOwnerInfoGetter(ctx)
const entries = await Promise.all(
matched.map(async (skill) => {
const [version, ownerHandle] = await Promise.all([
const [version, ownerInfo] = await Promise.all([
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : Promise.resolve(null),
getOwnerHandle(skill.ownerUserId),
getOwnerInfo(skill.ownerUserId),
])
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { skill: publicSkill, version, ownerHandle }
return {
skill: publicSkill,
version,
ownerHandle: ownerInfo.handle,
owner: ownerInfo.owner,
}
}),
)
const validEntries = entries.filter((entry): entry is SkillSearchEntry => entry !== null)
+15 -4
View File
@@ -84,6 +84,16 @@ describe('skills anti-spam guards', () => {
},
}
}
if (table === 'reservedSlugs') {
return {
withIndex: (name: string) => {
if (name === 'by_slug_active_deletedAt') {
return { order: () => ({ take: async () => [] }) }
}
throw new Error(`unexpected index ${name}`)
},
}
}
throw new Error(`unexpected table ${table}`)
}),
}
@@ -93,7 +103,7 @@ describe('skills anti-spam guards', () => {
).rejects.toThrow(/max 5 new skills per hour/i)
})
it('auto-hides suspicious skills from low-trust publishers', async () => {
it('keeps suspicious skills visible for low-trust publishers', async () => {
const patch = vi.fn(async () => {})
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
const skill = {
@@ -147,7 +157,7 @@ describe('skills anti-spam guards', () => {
{ db, scheduler: { runAfter: vi.fn() } } as never,
{
sha256hash: 'h'.repeat(64),
scanner: 'llm',
scanner: 'vt',
status: 'suspicious',
} as never,
)
@@ -155,8 +165,9 @@ describe('skills anti-spam guards', () => {
expect(patch).toHaveBeenCalledWith(
'skills:1',
expect.objectContaining({
moderationStatus: 'hidden',
moderationReason: 'scanner.llm.suspicious',
moderationStatus: 'active',
moderationReason: 'scanner.vt.suspicious',
moderationFlags: ['flagged.suspicious'],
}),
)
})
+432 -120
View File
@@ -1,7 +1,6 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { paginationOptsValidator } from 'convex/server'
import { ConvexError, v } from 'convex/values'
import { paginator } from 'convex-helpers/server/pagination'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx, QueryCtx } from './_generated/server'
@@ -21,9 +20,21 @@ import {
type SkillBadgeMap,
} from './lib/badges'
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
import {
canHealSkillOwnershipByGitHubProviderAccountId,
getGitHubProviderAccountId,
} from './lib/githubIdentity'
import { buildTrendingLeaderboard } from './lib/leaderboards'
import { deriveModerationFlags } from './lib/moderation'
import { toPublicSkill, toPublicUser } from './lib/public'
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import { scheduleNextBatchIfNeeded } from './lib/batching'
import {
enforceReservedSlugCooldownForNewSkill,
getLatestActiveReservedSlug,
reserveSlugForHardDeleteFinalize,
upsertReservedSlugForRightfulOwner,
} from './lib/reservedSlugs'
import {
fetchText,
type PublishResult,
@@ -32,7 +43,6 @@ import {
} from './lib/skillPublish'
import { isSkillSuspicious } from './lib/skillSafety'
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
import schema from './schema'
export { publishVersionForUser } from './lib/skillPublish'
@@ -48,11 +58,14 @@ const MAX_BADGE_LOOKUP_SKILLS = 200
const HARD_DELETE_BATCH_SIZE = 100
const HARD_DELETE_VERSION_BATCH_SIZE = 10
const HARD_DELETE_LEADERBOARD_BATCH_SIZE = 25
const BAN_USER_SKILLS_BATCH_SIZE = 25
const MAX_ACTIVE_REPORTS_PER_USER = 20
const AUTO_HIDE_REPORT_THRESHOLD = 3
const MAX_REPORT_REASON_SAMPLE = 5
const RATE_LIMIT_HOUR_MS = 60 * 60 * 1000
const RATE_LIMIT_DAY_MS = 24 * RATE_LIMIT_HOUR_MS
const SLUG_RESERVATION_DAYS = 90
const SLUG_RESERVATION_MS = SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS
const LOW_TRUST_ACCOUNT_AGE_MS = 30 * RATE_LIMIT_DAY_MS
const TRUSTED_PUBLISHER_SKILL_THRESHOLD = 10
const LOW_TRUST_BURST_THRESHOLD_PER_HOUR = 8
@@ -153,12 +166,6 @@ function enforceNewSkillRateLimit(signals: OwnerTrustSignals) {
}
}
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
const owner = await ctx.db.get(ownerUserId)
if (!owner || owner.deletedAt || owner.deactivatedAt) return null
return owner?.handle ?? owner?._id ?? null
}
const HARD_DELETE_PHASES = [
'versions',
'fingerprints',
@@ -435,6 +442,13 @@ async function hardDeleteSkillStep(
return
}
case 'finalize': {
await reserveSlugForHardDeleteFinalize(ctx, {
slug: skill.slug,
originalOwnerUserId: skill.ownerUserId,
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
})
await ctx.db.delete(skill._id)
await ctx.db.insert('auditLogs', {
actorUserId,
@@ -453,6 +467,7 @@ type PublicSkillEntry = {
skill: NonNullable<ReturnType<typeof toPublicSkill>>
latestVersion: PublicSkillListVersion | null
ownerHandle: string | null
owner: ReturnType<typeof toPublicUser> | null
}
type PublicSkillListVersion = Pick<
@@ -477,7 +492,10 @@ type ManagementSkillEntry = {
type BadgeKind = Doc<'skillBadges'>['kind']
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const ownerInfoCache = new Map<
Id<'users'>,
Promise<{ ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null }>
>()
const badgeMapBySkillId: Map<Id<'skills'>, SkillBadgeMap> = skills.length <=
MAX_BADGE_LOOKUP_SKILLS
? await getSkillBadgeMaps(
@@ -486,25 +504,38 @@ async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
)
: new Map()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
const getOwnerInfo = (ownerUserId: Id<'users'>) => {
const cached = ownerInfoCache.get(ownerUserId)
if (cached) return cached
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => {
if (!ownerDoc || ownerDoc.deletedAt || ownerDoc.deactivatedAt) {
return { ownerHandle: null, owner: null }
}
return {
ownerHandle: ownerDoc.handle ?? (ownerDoc._id ? String(ownerDoc._id) : null),
owner: toPublicUser(ownerDoc),
}
})
ownerInfoCache.set(ownerUserId, ownerPromise)
return ownerPromise
}
const entries = await Promise.all(
skills.map(async (skill) => {
const [latestVersionDoc, ownerHandle] = await Promise.all([
const [latestVersionDoc, ownerInfo] = await Promise.all([
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
getOwnerHandle(skill.ownerUserId),
getOwnerInfo(skill.ownerUserId),
])
const badges = badgeMapBySkillId.get(skill._id) ?? {}
const publicSkill = toPublicSkill({ ...skill, badges })
if (!publicSkill) return null
const latestVersion = toPublicSkillListVersion(latestVersionDoc)
return { skill: publicSkill, latestVersion, ownerHandle }
return {
skill: publicSkill,
latestVersion,
ownerHandle: ownerInfo.ownerHandle,
owner: ownerInfo.owner,
}
}),
)
@@ -774,6 +805,13 @@ export const getBySlugForStaff = query({
},
})
export const getReservedSlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
return getLatestActiveReservedSlug(ctx, args.slug)
},
})
export const getSkillBySlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -1185,6 +1223,15 @@ export const listWithLatest = query({
},
})
export const listHighlightedPublic = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 12, 1, MAX_PUBLIC_LIST_LIMIT)
const skills = await loadHighlightedSkills(ctx, limit)
return buildPublicSkillEntries(ctx, skills)
},
})
export const listForManagement = query({
args: {
limit: v.optional(v.number()),
@@ -1418,16 +1465,7 @@ export const report = mutation({
await ctx.db.patch(skill._id, updates)
if (shouldAutoHide) {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: 'deleted',
updatedAt: now,
})
}
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now)
await ctx.db.insert('auditLogs', {
actorUserId: userId,
@@ -1505,10 +1543,9 @@ export const listPublicPage = query({
})
/**
* V2 of listPublicPage using convex-helpers paginator for better cache behavior.
* V2 of listPublicPage using standard Convex pagination (paginate + usePaginatedQuery).
*
* Key differences from V1:
* - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching)
* - Uses `by_active_updated` index to filter soft-deleted skills at query level
* - Returns standard pagination shape compatible with usePaginatedQuery
*/
@@ -1530,15 +1567,15 @@ export const listPublicPageV2 = query({
},
handler: async (ctx, args) => {
const sort = args.sort ?? 'newest'
const dir = args.dir ?? 'desc'
const paginationOpts = {
const dir = args.dir ?? (sort === 'name' ? 'asc' : 'desc')
const paginationOpts: { cursor: string | null; numItems: number; id?: number } = {
...args.paginationOpts,
numItems: clampInt(args.paginationOpts.numItems, 1, MAX_PUBLIC_LIST_LIMIT),
}
// Use the index to filter out soft-deleted skills at query time.
// softDeletedAt === undefined means active (non-deleted) skills only.
const result = await paginator(ctx.db, schema)
const result = await ctx.db
.query('skills')
.withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
.order(dir)
@@ -1550,11 +1587,7 @@ export const listPublicPageV2 = query({
// Build the public skill entries (fetch latestVersion + ownerHandle)
const items = await buildPublicSkillEntries(ctx, filteredPage)
return {
...result,
page: items,
}
return { ...result, page: items }
},
})
@@ -1630,6 +1663,14 @@ export const getVersionById = query({
handler: async (ctx, args) => ctx.db.get(args.versionId),
})
export const getVersionsByIdsInternal = internalQuery({
args: { versionIds: v.array(v.id('skillVersions')) },
handler: async (ctx, args) => {
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
},
})
export const getVersionByIdInternal = internalQuery({
args: { versionId: v.id('skillVersions') },
handler: async (ctx, args) => ctx.db.get(args.versionId),
@@ -1659,6 +1700,7 @@ export const getPendingScanSkillsInternal = internalQuery({
const reason = skill.moderationReason
if (skill.moderationStatus === 'hidden' && reason === 'pending.scan') return true
if (skill.moderationStatus === 'hidden' && reason === 'quality.low') return true
if (skill.moderationStatus === 'active' && reason === 'pending.scan') return true
if (skill.moderationStatus === 'active' && reason === 'scanner.vt.pending') return true
return (
reason === 'scanner.llm.clean' ||
@@ -2094,6 +2136,174 @@ export const setSkillModerationStatusActiveInternal = internalMutation({
},
})
async function listSkillEmbeddingsForSkill(ctx: MutationCtx, skillId: Id<'skills'>) {
return ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skillId))
.collect()
}
async function markSkillEmbeddingsDeleted(ctx: MutationCtx, skillId: Id<'skills'>, now: number) {
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
for (const embedding of embeddings) {
if (embedding.visibility === 'deleted') continue
await ctx.db.patch(embedding._id, { visibility: 'deleted', updatedAt: now })
}
}
async function restoreSkillEmbeddingsVisibility(ctx: MutationCtx, skillId: Id<'skills'>, now: number) {
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
for (const embedding of embeddings) {
const visibility = embeddingVisibilityFor(embedding.isLatest, embedding.isApproved)
await ctx.db.patch(embedding._id, { visibility, updatedAt: now })
}
}
async function setSkillEmbeddingsSoftDeleted(
ctx: MutationCtx,
skillId: Id<'skills'>,
deleted: boolean,
now: number,
) {
if (deleted) {
await markSkillEmbeddingsDeleted(ctx, skillId, now)
return
}
await restoreSkillEmbeddingsVisibility(ctx, skillId, now)
}
async function setSkillEmbeddingsLatestVersion(
ctx: MutationCtx,
skillId: Id<'skills'>,
latestVersionId: Id<'skillVersions'>,
now: number,
) {
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
for (const embedding of embeddings) {
const isLatest = embedding.versionId === latestVersionId
await ctx.db.patch(embedding._id, {
isLatest,
visibility: embeddingVisibilityFor(isLatest, embedding.isApproved),
updatedAt: now,
})
}
}
async function setSkillEmbeddingsApproved(
ctx: MutationCtx,
skillId: Id<'skills'>,
approved: boolean,
now: number,
) {
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
isApproved: approved,
visibility: embeddingVisibilityFor(embedding.isLatest, approved),
updatedAt: now,
})
}
}
export const applyBanToOwnedSkillsBatchInternal = internalMutation({
args: {
ownerUserId: v.id('users'),
bannedAt: v.number(),
hiddenBy: v.optional(v.id('users')),
cursor: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_SKILLS_BATCH_SIZE })
let hiddenCount = 0
for (const skill of page) {
if (skill.softDeletedAt) continue
// Only overwrite moderation fields for active skills. Keep existing hidden/removed
// moderation reasons intact.
const shouldMarkModeration = (skill.moderationStatus ?? 'active') === 'active'
const patch: Partial<Doc<'skills'>> = { softDeletedAt: args.bannedAt, updatedAt: args.bannedAt }
if (shouldMarkModeration) {
patch.moderationStatus = 'hidden'
patch.moderationReason = 'user.banned'
patch.hiddenAt = args.bannedAt
patch.hiddenBy = args.hiddenBy
patch.lastReviewedAt = args.bannedAt
hiddenCount += 1
}
await ctx.db.patch(skill._id, patch)
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt)
}
scheduleNextBatchIfNeeded(
ctx.scheduler,
internal.skills.applyBanToOwnedSkillsBatchInternal,
args,
isDone,
continueCursor,
)
return { ok: true as const, hiddenCount, scheduled: !isDone }
},
})
export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
args: {
ownerUserId: v.id('users'),
bannedAt: v.number(),
cursor: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now()
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_SKILLS_BATCH_SIZE })
let restoredCount = 0
for (const skill of page) {
if (
!skill.softDeletedAt ||
skill.softDeletedAt !== args.bannedAt ||
skill.moderationReason !== 'user.banned'
) {
continue
}
await ctx.db.patch(skill._id, {
softDeletedAt: undefined,
moderationStatus: 'active',
moderationReason: 'restored.unban',
hiddenAt: undefined,
hiddenBy: undefined,
lastReviewedAt: now,
updatedAt: now,
})
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now)
restoredCount += 1
}
scheduleNextBatchIfNeeded(
ctx.scheduler,
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
args,
isDone,
continueCursor,
)
return { ok: true as const, restoredCount, scheduled: !isDone }
},
})
/**
* Get legacy skills that are active but still have "pending.scan" reason.
* These need to be scanned through VT to get proper verdicts.
@@ -2332,20 +2542,8 @@ export const approveSkillByHashInternal = internalMutation({
}
const now = Date.now()
let shouldHideSuspicious = false
if (isSuspicious && !alreadyBlocked && !bypassSuspicious) {
if (owner && !owner.deletedAt && !owner.deactivatedAt) {
const trustSignals = await getOwnerTrustSignals(ctx, owner, now)
shouldHideSuspicious = trustSignals.isLowTrust
}
}
const qualityLocked = skill.moderationReason === 'quality.low' && !isMalicious
const nextModerationStatus = qualityLocked
? 'hidden'
: shouldHideSuspicious
? 'hidden'
: 'active'
const nextModerationStatus = qualityLocked ? 'hidden' : 'active'
const nextModerationReason = qualityLocked
? 'quality.low'
: bypassSuspicious
@@ -2354,9 +2552,7 @@ export const approveSkillByHashInternal = internalMutation({
const nextModerationNotes = qualityLocked
? (skill.moderationNotes ??
'Quality gate quarantine is still active. Manual moderation review required.')
: shouldHideSuspicious
? 'Auto-hidden: suspicious result from low-trust publisher.'
: undefined
: undefined
await ctx.db.patch(skill._id, {
moderationStatus: nextModerationStatus,
@@ -2631,25 +2827,15 @@ export const updateTags = mutation({
}
const latestEntry = args.tags.find((entry) => entry.tag === 'latest')
const now = Date.now()
await ctx.db.patch(skill._id, {
tags: nextTags,
latestVersionId: latestEntry ? latestEntry.versionId : skill.latestVersionId,
updatedAt: Date.now(),
updatedAt: now,
})
if (latestEntry) {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
const isLatest = embedding.versionId === latestEntry.versionId
await ctx.db.patch(embedding._id, {
isLatest,
visibility: visibilityFor(isLatest, embedding.isApproved),
updatedAt: Date.now(),
})
}
await setSkillEmbeddingsLatestVersion(ctx, skill._id, latestEntry.versionId, now)
}
},
})
@@ -2675,17 +2861,7 @@ export const setRedactionApproved = mutation({
updatedAt: now,
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
isApproved: args.approved,
visibility: visibilityFor(embedding.isLatest, args.approved),
updatedAt: now,
})
}
await setSkillEmbeddingsApproved(ctx, skill._id, args.approved, now)
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
@@ -2754,18 +2930,7 @@ export const setSoftDeleted = mutation({
updatedAt: now,
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
@@ -2799,10 +2964,7 @@ export const changeOwner = mutation({
updatedAt: now,
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id)
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
ownerId: args.ownerUserId,
@@ -2821,6 +2983,133 @@ export const changeOwner = mutation({
},
})
/**
* Admin-only: reclaim a squatted slug by hard-deleting the squatter's skill
* and reserving the slug for the rightful owner.
*/
export const reclaimSlug = mutation({
args: {
slug: v.string(),
rightfulOwnerUserId: v.id('users'),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertAdmin(user)
const slug = args.slug.trim().toLowerCase()
if (!slug) throw new Error('Slug required')
const rightfulOwner = await ctx.db.get(args.rightfulOwnerUserId)
if (!rightfulOwner) throw new Error('Rightful owner not found')
const now = Date.now()
// Check if slug is currently occupied by someone else
const existingSkill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (existingSkill) {
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
return { ok: true as const, action: 'already_owned' }
}
// Hard-delete the squatter's skill
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: existingSkill._id,
actorUserId: user._id,
})
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: 'slug.reclaim',
targetType: 'skill',
targetId: existingSkill._id,
metadata: {
slug,
squatterUserId: existingSkill.ownerUserId,
rightfulOwnerUserId: args.rightfulOwnerUserId,
reason: args.reason || undefined,
},
createdAt: now,
})
}
await upsertReservedSlugForRightfulOwner(ctx, {
slug,
rightfulOwnerUserId: args.rightfulOwnerUserId,
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
reason: args.reason || 'slug.reclaimed',
})
return {
ok: true as const,
action: existingSkill ? 'reclaimed_from_squatter' : 'reserved',
}
},
})
/**
* Admin-only: reclaim slugs in bulk. Useful for recovering multiple squatted slugs at once.
*/
export const reclaimSlugInternal = internalMutation({
args: {
actorUserId: v.id('users'),
slug: v.string(),
rightfulOwnerUserId: v.id('users'),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
assertAdmin(actor)
const slug = args.slug.trim().toLowerCase()
if (!slug) throw new Error('Slug required')
const now = Date.now()
const existingSkill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (existingSkill && existingSkill.ownerUserId !== args.rightfulOwnerUserId) {
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: existingSkill._id,
actorUserId: args.actorUserId,
})
}
await upsertReservedSlugForRightfulOwner(ctx, {
slug,
rightfulOwnerUserId: args.rightfulOwnerUserId,
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
reason: args.reason || 'slug.reclaimed',
})
await ctx.db.insert('auditLogs', {
actorUserId: args.actorUserId,
action: 'slug.reclaim',
targetType: 'slug',
targetId: slug,
metadata: {
slug,
rightfulOwnerUserId: args.rightfulOwnerUserId,
hadSquatter: Boolean(existingSkill && existingSkill.ownerUserId !== args.rightfulOwnerUserId),
reason: args.reason || undefined,
},
createdAt: now,
})
return { ok: true as const }
},
})
export const setDuplicate = mutation({
args: { skillId: v.id('skills'), canonicalSlug: v.optional(v.string()) },
handler: async (ctx, args) => {
@@ -2980,6 +3269,7 @@ export const insertVersion = internalMutation({
changelogSource: v.optional(v.union(v.literal('auto'), v.literal('user'))),
tags: v.optional(v.array(v.string())),
fingerprint: v.string(),
bypassNewSkillRateLimit: v.optional(v.boolean()),
forkOf: v.optional(
v.object({
slug: v.string(),
@@ -3016,6 +3306,7 @@ export const insertVersion = internalMutation({
bulletCount: v.number(),
templateMarkerHits: v.number(),
genericSummary: v.boolean(),
cjkChars: v.optional(v.number()),
}),
}),
),
@@ -3026,22 +3317,56 @@ export const insertVersion = internalMutation({
const user = await ctx.db.get(userId)
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
const now = Date.now()
let skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (skill && skill.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
// Fallback: Convex Auth can create duplicate `users` records. Heal ownership ONLY
// when the underlying GitHub identity matches (authAccounts.providerAccountId).
const owner = await ctx.db.get(skill.ownerUserId)
if (!owner || owner.deletedAt || owner.deactivatedAt) {
throw new Error('Only the owner can publish updates')
}
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
getGitHubProviderAccountId(ctx, skill.ownerUserId),
getGitHubProviderAccountId(ctx, userId),
])
// Deny healing when GitHub identity isn't present/consistent.
if (
!canHealSkillOwnershipByGitHubProviderAccountId(
ownerProviderAccountId,
callerProviderAccountId,
)
) {
throw new Error('Only the owner can publish updates')
}
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
skill = { ...skill, ownerUserId: userId }
}
const now = Date.now()
const qualityAssessment = args.qualityAssessment
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
// Trusted publishers (and moderators/admins) bypass auto-hide for pending scans.
// Keep moderationReason as pending.scan so the VT poller keeps working.
const isTrustedPublisher = Boolean(
user.trustedPublisher || user.role === 'admin' || user.role === 'moderator',
)
const initialModerationStatus =
isTrustedPublisher && !isQualityQuarantine ? 'active' : 'hidden'
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
const moderationNotes = isQualityQuarantine
? `Auto-quarantined by quality gate (score=${qualityAssessment.score}, tier=${qualityAssessment.trustTier}, similar=${qualityAssessment.similarRecentCount}).`
: undefined
const qualityRecord = qualityAssessment
? {
score: qualityAssessment.score,
@@ -3055,8 +3380,13 @@ export const insertVersion = internalMutation({
: undefined
if (!skill) {
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
enforceNewSkillRateLimit(ownerTrustSignals)
// Anti-squatting: enforce reserved slug cooldown.
await enforceReservedSlugCooldownForNewSkill(ctx, { slug: args.slug, userId, now })
if (!args.bypassNewSkillRateLimit) {
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
enforceNewSkillRateLimit(ownerTrustSignals)
}
const forkOfSlug = args.forkOf?.slug.trim().toLowerCase() || ''
const forkOfVersion = args.forkOf?.version?.trim() || undefined
@@ -3119,7 +3449,7 @@ export const insertVersion = internalMutation({
official: undefined,
deprecated: undefined,
},
moderationStatus: 'hidden',
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
quality: qualityRecord,
@@ -3190,7 +3520,7 @@ export const insertVersion = internalMutation({
tags: nextTags,
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
softDeletedAt: undefined,
moderationStatus: 'hidden',
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
quality: qualityRecord ?? skill.quality,
@@ -3208,7 +3538,7 @@ export const insertVersion = internalMutation({
embedding: args.embedding,
isLatest: true,
isApproved,
visibility: visibilityFor(true, isApproved),
visibility: embeddingVisibilityFor(true, isApproved),
updatedAt: now,
})
@@ -3220,7 +3550,7 @@ export const insertVersion = internalMutation({
if (previousEmbedding) {
await ctx.db.patch(previousEmbedding._id, {
isLatest: false,
visibility: visibilityFor(false, previousEmbedding.isApproved),
visibility: embeddingVisibilityFor(false, previousEmbedding.isApproved),
updatedAt: now,
})
}
@@ -3270,18 +3600,7 @@ export const setSkillSoftDeletedInternal = internalMutation({
updatedAt: now,
})
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
await ctx.db.insert('auditLogs', {
actorUserId: args.userId,
@@ -3296,13 +3615,6 @@ export const setSkillSoftDeletedInternal = internalMutation({
},
})
function visibilityFor(isLatest: boolean, isApproved: boolean) {
if (isLatest && isApproved) return 'latest-approved'
if (isLatest) return 'latest'
if (isApproved) return 'archived-approved'
return 'archived'
}
function clampInt(value: number, min: number, max: number) {
const rounded = Number.isFinite(value) ? Math.round(value) : min
return Math.min(max, Math.max(min, rounded))
+13 -11
View File
@@ -3,6 +3,7 @@ import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertModerator, requireUser, requireUserFromAction } from './lib/access'
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import { toPublicSoul, toPublicUser } from './lib/public'
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
import { generateSoulChangelogPreview } from './lib/soulChangelog'
@@ -145,6 +146,14 @@ export const getVersionById = query({
handler: async (ctx, args) => ctx.db.get(args.versionId),
})
export const getVersionsByIdsInternal = internalQuery({
args: { versionIds: v.array(v.id('soulVersions')) },
handler: async (ctx, args) => {
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
},
})
export const getVersionByIdInternal = internalQuery({
args: { versionId: v.id('soulVersions') },
handler: async (ctx, args) => ctx.db.get(args.versionId),
@@ -349,7 +358,7 @@ export const updateTags = mutation({
const isLatest = embedding.versionId === latestEntry.versionId
await ctx.db.patch(embedding._id, {
isLatest,
visibility: visibilityFor(isLatest, embedding.isApproved),
visibility: embeddingVisibilityFor(isLatest, embedding.isApproved),
updatedAt: Date.now(),
})
}
@@ -471,7 +480,7 @@ export const insertVersion = internalMutation({
embedding: args.embedding,
isLatest: true,
isApproved: true,
visibility: visibilityFor(true, true),
visibility: embeddingVisibilityFor(true, true),
updatedAt: now,
})
@@ -483,7 +492,7 @@ export const insertVersion = internalMutation({
if (previousEmbedding) {
await ctx.db.patch(previousEmbedding._id, {
isLatest: false,
visibility: visibilityFor(false, previousEmbedding.isApproved),
visibility: embeddingVisibilityFor(false, previousEmbedding.isApproved),
updatedAt: now,
})
}
@@ -539,7 +548,7 @@ export const setSoulSoftDeletedInternal = internalMutation({
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
: embeddingVisibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
@@ -557,13 +566,6 @@ export const setSoulSoftDeletedInternal = internalMutation({
},
})
function visibilityFor(isLatest: boolean, isApproved: boolean) {
if (isLatest && isApproved) return 'latest-approved'
if (isLatest) return 'latest'
if (isApproved) return 'archived-approved'
return 'archived'
}
function clampInt(value: number, min: number, max: number) {
const rounded = Number.isFinite(value) ? Math.round(value) : min
return Math.min(max, Math.max(min, rounded))
+21 -3
View File
@@ -1,7 +1,25 @@
{
"extends": "../tsconfig.json",
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"skipLibCheck": true
}
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2022", "dom", "dom.iterable"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('./lib/access', async () => {
const actual = await vi.importActual<typeof import('./lib/access')>('./lib/access')
return { ...actual, requireUser: vi.fn() }
})
const { requireUser } = await import('./lib/access')
const { ensureHandler } = await import('./users')
function makeCtx() {
const patch = vi.fn()
const get = vi.fn()
return { ctx: { db: { patch, get } } as never, patch, get }
}
describe('ensureHandler', () => {
afterEach(() => {
vi.mocked(requireUser).mockReset()
})
it('updates handle and display name when GitHub login changes', async () => {
const { ctx, patch } = makeCtx()
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:1',
user: {
_creationTime: 1,
handle: 'old-handle',
displayName: 'old-handle',
name: 'new-handle',
email: 'old@example.com',
role: 'user',
createdAt: 1,
},
} as never)
await ensureHandler(ctx)
expect(patch).toHaveBeenCalledWith('users:1', {
handle: 'new-handle',
displayName: 'new-handle',
updatedAt: expect.any(Number),
})
})
it('does not override a custom display name when syncing handle', async () => {
const { ctx, patch } = makeCtx()
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:2',
user: {
_creationTime: 1,
handle: 'old-handle',
displayName: 'Custom Name',
name: 'new-handle',
role: 'user',
createdAt: 1,
},
} as never)
await ensureHandler(ctx)
expect(patch).toHaveBeenCalledWith('users:2', {
handle: 'new-handle',
updatedAt: expect.any(Number),
})
})
it('fills display name from existing handle when missing', async () => {
const { ctx, patch } = makeCtx()
vi.mocked(requireUser).mockResolvedValue({
userId: 'users:3',
user: {
_creationTime: 1,
handle: 'steady-handle',
displayName: undefined,
name: undefined,
email: undefined,
role: 'user',
createdAt: 1,
},
} as never)
await ensureHandler(ctx)
expect(patch).toHaveBeenCalledWith('users:3', {
displayName: 'steady-handle',
updatedAt: expect.any(Number),
})
})
})
+312 -54
View File
@@ -2,9 +2,10 @@ import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import type { ActionCtx, MutationCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { syncGitHubProfile } from './lib/githubAccount'
import { toPublicUser } from './lib/public'
import { buildUserSearchResults } from './lib/userSearch'
@@ -46,21 +47,96 @@ export const searchInternal = internalQuery({
},
})
export const updateGithubMetaInternal = internalMutation({
export const setGitHubCreatedAtInternal = internalMutation({
args: {
userId: v.id('users'),
githubCreatedAt: v.number(),
githubFetchedAt: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
githubCreatedAt: args.githubCreatedAt,
githubFetchedAt: args.githubFetchedAt,
updatedAt: args.githubFetchedAt,
updatedAt: Date.now(),
})
},
})
/**
* Sync the user's GitHub profile (username, avatar) when it changes.
* This handles the case where a user renames their GitHub account.
*/
export const syncGitHubProfileInternal = internalMutation({
args: {
userId: v.id('users'),
name: v.string(),
image: v.optional(v.string()),
profileName: v.optional(v.string()),
syncedAt: v.number(),
},
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId)
if (!user || user.deletedAt || user.deactivatedAt) return
const updates: Partial<Doc<'users'>> = { githubProfileSyncedAt: args.syncedAt }
let didChangeProfile = false
if (user.name !== args.name) {
updates.name = args.name
didChangeProfile = true
}
// Update handle if it was derived from the old username
if (user.handle === user.name && user.name !== args.name) {
updates.handle = args.name
didChangeProfile = true
}
// Update displayName if it was derived from the old username
if (
(user.displayName === user.name || user.displayName === user.handle) &&
user.name !== args.name
) {
updates.displayName = args.name
didChangeProfile = true
}
// If displayName is derived/missing, prefer the GitHub profile "name" (full name).
const profileName = args.profileName?.trim()
if (profileName && profileName !== args.name) {
const currentDisplay = user.displayName?.trim()
const currentHandle = user.handle?.trim()
const currentLogin = user.name?.trim()
const isDerivedOrMissing =
!currentDisplay || currentDisplay === currentHandle || currentDisplay === currentLogin
if (isDerivedOrMissing && currentDisplay !== profileName) {
updates.displayName = profileName
didChangeProfile = true
}
}
// Update avatar if provided
if (args.image && args.image !== user.image) {
updates.image = args.image
didChangeProfile = true
}
if (didChangeProfile) {
updates.updatedAt = Date.now()
}
await ctx.db.patch(args.userId, updates)
},
})
/**
* Internal action to sync GitHub profile from the GitHub API.
* This is called after login to ensure the username is up-to-date.
*/
export const syncGitHubProfileAction = internalAction({
args: { userId: v.id('users') },
handler: async (ctx: ActionCtx, args) => {
await syncGitHubProfile(ctx, args.userId)
},
})
export const me = query({
args: {},
handler: async (ctx) => {
@@ -74,27 +150,65 @@ export const me = query({
export const ensure = mutation({
args: {},
handler: async (ctx) => {
const { userId, user } = await requireUser(ctx)
const updates: Record<string, unknown> = {}
const handle = user.handle || user.name || user.email?.split('@')[0]
if (!user.handle && handle) updates.handle = handle
if (!user.displayName) updates.displayName = handle
if (!user.role) {
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
}
if (!user.createdAt) updates.createdAt = user._creationTime
if (Object.keys(updates).length > 0) {
updates.updatedAt = Date.now()
await ctx.db.patch(userId, updates)
}
return ctx.db.get(userId)
},
handler: ensureHandler,
})
function normalizeHandle(handle: string | undefined) {
const normalized = handle?.trim()
return normalized ? normalized : undefined
}
function deriveHandle(args: { existingHandle?: string; githubLogin?: string; email?: string }) {
// Prefer the GitHub login; only fall back to email-derived handle when we don't already have one.
if (args.githubLogin) return args.githubLogin
if (!args.existingHandle && args.email) return args.email.split('@')[0]?.trim() || undefined
return undefined
}
function computeEnsureUpdates(user: Doc<'users'>) {
const updates: Record<string, unknown> = {}
const existingHandle = normalizeHandle(user.handle)
const githubLogin = normalizeHandle(user.name)
const derivedHandle = deriveHandle({
existingHandle,
githubLogin,
email: user.email,
})
const baseHandle = derivedHandle ?? existingHandle
if (derivedHandle && existingHandle !== derivedHandle) {
updates.handle = derivedHandle
}
const displayName = normalizeHandle(user.displayName)
if (!displayName && baseHandle) {
updates.displayName = baseHandle
} else if (derivedHandle && displayName === existingHandle) {
updates.displayName = derivedHandle
}
if (!user.role) {
updates.role = baseHandle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
}
if (!user.createdAt) updates.createdAt = user._creationTime
return updates
}
export async function ensureHandler(ctx: MutationCtx) {
const { userId, user } = await requireUser(ctx)
const updates = computeEnsureUpdates(user)
if (Object.keys(updates).length > 0) {
updates.updatedAt = Date.now()
await ctx.db.patch(userId, updates)
}
return ctx.db.get(userId)
}
export const updateProfile = mutation({
args: {
displayName: v.string(),
@@ -143,7 +257,6 @@ export const deleteAccount = mutation({
isAnonymous: undefined,
bio: undefined,
githubCreatedAt: undefined,
githubFetchedAt: undefined,
updatedAt: now,
})
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId })
@@ -241,6 +354,27 @@ export const banUserInternal = internalMutation({
},
})
export const unbanUser = mutation({
args: { userId: v.id('users'), reason: v.optional(v.string()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
return unbanUserWithActor(ctx, user, args.userId, args.reason)
},
})
export const unbanUserInternal = internalMutation({
args: {
actorUserId: v.id('users'),
targetUserId: v.id('users'),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
return unbanUserWithActor(ctx, actor, args.targetUserId, args.reason)
},
})
async function banUserWithActor(
ctx: MutationCtx,
actor: Doc<'users'>,
@@ -266,24 +400,26 @@ async function banUserWithActor(
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
}
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
.collect()
for (const skill of skills) {
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: skill._id,
actorUserId: actor._id,
})
}
const banSkillsResult = (await ctx.runMutation(
internal.skills.applyBanToOwnedSkillsBatchInternal,
{
ownerUserId: targetUserId,
bannedAt: now,
hiddenBy: actor._id,
cursor: undefined,
},
)) as { hiddenCount?: number; scheduled?: boolean }
const hiddenCount = banSkillsResult.hiddenCount ?? 0
const scheduledSkills = banSkillsResult.scheduled ?? false
const tokens = await ctx.db
.query('apiTokens')
.withIndex('by_user', (q) => q.eq('userId', targetUserId))
.collect()
for (const token of tokens) {
await ctx.db.patch(token._id, { revokedAt: now })
if (!token.revokedAt) {
await ctx.db.patch(token._id, { revokedAt: now })
}
}
await ctx.db.patch(targetUserId, {
@@ -300,13 +436,136 @@ async function banUserWithActor(
action: 'user.ban',
targetType: 'user',
targetId: targetUserId,
metadata: { deletedSkills: skills.length, reason: reason || undefined },
metadata: { hiddenSkills: hiddenCount, reason: reason || undefined },
createdAt: now,
})
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
return { ok: true as const, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
}
async function unbanUserWithActor(
ctx: MutationCtx,
actor: Doc<'users'>,
targetUserId: Id<'users'>,
reasonRaw?: string,
) {
assertAdmin(actor)
if (targetUserId === actor._id) throw new Error('Cannot unban yourself')
const target = await ctx.db.get(targetUserId)
if (!target) throw new Error('User not found')
if (target.deactivatedAt) {
throw new Error('Cannot unban a permanently deleted account')
}
if (!target.deletedAt) {
return { ok: true as const, alreadyUnbanned: true }
}
const reason = reasonRaw?.trim()
if (reason && reason.length > 500) {
throw new Error('Reason too long (max 500 chars)')
}
const now = Date.now()
const bannedAt = target.deletedAt
await ctx.db.patch(targetUserId, {
deletedAt: undefined,
banReason: undefined,
role: 'user',
updatedAt: now,
})
const restoreSkillsResult = (await ctx.runMutation(
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
{
ownerUserId: targetUserId,
bannedAt,
cursor: undefined,
},
)) as { restoredCount?: number; scheduled?: boolean }
const restoredCount = restoreSkillsResult.restoredCount ?? 0
const scheduledSkills = restoreSkillsResult.scheduled ?? false
await ctx.db.insert('auditLogs', {
actorUserId: actor._id,
action: 'user.unban',
targetType: 'user',
targetId: targetUserId,
metadata: { reason: reason || undefined, restoredSkills: restoredCount },
createdAt: now,
})
return { ok: true as const, alreadyUnbanned: false, restoredSkills: restoredCount, scheduledSkills }
}
/**
* Admin-only: set or unset the trustedPublisher flag for a user.
* Trusted publishers bypass the pending.scan auto-hide for new skill publishes.
*/
export const setTrustedPublisher = mutation({
args: {
userId: v.id('users'),
trusted: v.boolean(),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertAdmin(user)
const target = await ctx.db.get(args.userId)
if (!target) throw new Error('User not found')
const now = Date.now()
await ctx.db.patch(args.userId, {
trustedPublisher: args.trusted || undefined,
updatedAt: now,
})
await ctx.db.insert('auditLogs', {
actorUserId: user._id,
action: args.trusted ? 'user.trusted.set' : 'user.trusted.unset',
targetType: 'user',
targetId: args.userId,
metadata: { trusted: args.trusted },
createdAt: now,
})
return { ok: true as const, trusted: args.trusted }
},
})
export const setTrustedPublisherInternal = internalMutation({
args: {
actorUserId: v.id('users'),
targetUserId: v.id('users'),
trusted: v.boolean(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
assertAdmin(actor)
const target = await ctx.db.get(args.targetUserId)
if (!target) throw new Error('User not found')
const now = Date.now()
await ctx.db.patch(args.targetUserId, {
trustedPublisher: args.trusted || undefined,
updatedAt: now,
})
await ctx.db.insert('auditLogs', {
actorUserId: args.actorUserId,
action: args.trusted ? 'user.trusted.set' : 'user.trusted.unset',
targetType: 'user',
targetId: args.targetUserId,
metadata: { trusted: args.trusted },
createdAt: now,
})
return { ok: true as const, trusted: args.trusted }
},
})
/**
* Auto-ban a user whose skill was flagged malicious by VT.
* Skips moderators/admins. No actor required this is a system-level action.
@@ -330,17 +589,16 @@ export const autobanMalwareAuthorInternal = internalMutation({
const now = Date.now()
// Soft-delete all their skills
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.collect()
for (const skill of skills) {
if (!skill.softDeletedAt) {
await ctx.db.patch(skill._id, { softDeletedAt: now, updatedAt: now })
}
}
const banSkillsResult = (await ctx.runMutation(
internal.skills.applyBanToOwnedSkillsBatchInternal,
{
ownerUserId: args.ownerUserId,
bannedAt: now,
cursor: undefined,
},
)) as { hiddenCount?: number; scheduled?: boolean }
const hiddenCount = banSkillsResult.hiddenCount ?? 0
const scheduledSkills = banSkillsResult.scheduled ?? false
// Revoke all API tokens
const tokens = await ctx.db
@@ -365,7 +623,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
userId: args.ownerUserId,
})
// Audit log use the target as actor since there's no human actor
// Audit log -- use the target as actor since there's no human actor
await ctx.db.insert('auditLogs', {
actorUserId: args.ownerUserId,
action: 'user.autoban.malware',
@@ -375,7 +633,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
trigger: 'vt.malicious',
sha256hash: args.sha256hash,
slug: args.slug,
deletedSkills: skills.length,
hiddenSkills: hiddenCount,
},
createdAt: now,
})
@@ -384,6 +642,6 @@ export const autobanMalwareAuthorInternal = internalMutation({
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
)
return { ok: true, alreadyBanned: false, deletedSkills: skills.length }
return { ok: true, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
},
})
+12 -17
View File
@@ -375,8 +375,6 @@ export const scanWithVirusTotal = internalAction({
// File exists and has AI analysis - use the verdict
const verdict = normalizeVerdict(aiResult.verdict)
const status = verdictToStatus(verdict)
const isSafe = status === 'clean'
console.log(
`Version ${args.versionId} found in VT with AI analysis. Hash: ${sha256hash}. Verdict: ${verdict}`,
)
@@ -393,14 +391,12 @@ export const scanWithVirusTotal = internalAction({
},
})
// VT is supplementary — only escalate (never override LLM verdict)
if (!isSafe && (status === 'malicious' || status === 'suspicious')) {
await ctx.runMutation(internal.skills.escalateByVtInternal, {
sha256hash,
status,
})
}
// Clean VT result: vtAnalysis already written above — don't touch moderation
// VT finalizes moderation visibility for newly published versions.
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
sha256hash,
scanner: 'vt',
status,
})
return
}
@@ -578,13 +574,12 @@ export const pollPendingScans = internalAction({
},
})
// VT is supplementary — only escalate for malicious/suspicious
if (status === 'malicious' || status === 'suspicious') {
await ctx.runMutation(internal.skills.escalateByVtInternal, {
sha256hash,
status,
})
}
// VT finalizes moderation visibility for newly published versions.
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
sha256hash,
scanner: 'vt',
status,
})
updated++
} catch (error) {
console.error(`[vt:pollPendingScans] Error checking hash ${sha256hash}:`, error)
+1
View File
@@ -10,6 +10,7 @@ read_when:
## Web auth (GitHub OAuth)
- Convex Auth + GitHub OAuth App.
- GitHub is the only supported login provider.
- Env vars:
- `AUTH_GITHUB_ID`
- `AUTH_GITHUB_SECRET`
+10 -2
View File
@@ -27,8 +27,8 @@ Headers:
IP source:
- Uses `cf-connecting-ip` first, then falls back to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip`.
- Set `TRUST_FORWARDED_IPS=false` to disable forwarded-header fallback.
- Uses `cf-connecting-ip` (Cloudflare) for client IP by default.
- Set `TRUST_FORWARDED_IPS=true` to opt in to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip` (non-Cloudflare deployments).
## Public endpoints (no auth)
@@ -156,6 +156,14 @@ Publishes a new version.
Soft-delete / restore a skill (moderator/admin only).
Status codes:
- `200`: ok
- `401`: unauthorized
- `403`: forbidden
- `404`: skill/user not found
- `500`: internal server error
### `POST /api/v1/users/ban`
Ban a user and hard-delete owned skills (moderator/admin only).
+11 -3
View File
@@ -38,6 +38,8 @@ read_when:
- hard-deletes all owned skills
- revokes API tokens
- sets `deletedAt` on the user
- Admins can manually unban (`deletedAt` + `banReason` cleared); revoked API tokens
stay revoked and should be recreated by the user.
- Optional ban reason is stored in `users.banReason` and audit logs.
- Moderators cannot ban admins; nobody can ban themselves.
- Report counters effectively reset because deleted/banned skills are no longer
@@ -57,12 +59,18 @@ read_when:
## Upload gate (GitHub account age)
- Skill + soul publish actions require GitHub account age ≥ 7 days.
- Lookup uses GitHub `created_at` and caches on the user:
- Lookup uses GitHub `created_at` fetched by the immutable GitHub numeric ID (`providerAccountId`)
and caches on the user:
- `githubCreatedAt` (source of truth)
- `githubFetchedAt` (fetch timestamp)
- Cache TTL: 24 hours.
- Gate applies to web uploads, CLI publish, and GitHub import.
- If GitHub responds `403` or `429`, publish fails with:
- `GitHub API rate limit exceeded — please try again in a few minutes`
- To reduce rate-limit failures, set `GITHUB_TOKEN` in Convex env for authenticated
GitHub API requests.
## Empty-skill cleanup (backfill)
- Cleanup uses quality heuristics plus trust tier to identify very thin/templated
skills.
- Word counting is language-aware (`Intl.Segmenter` with fallback), reducing
false positives for non-space-separated languages.
+46 -1
View File
@@ -60,7 +60,7 @@ async function makeTempConfig(registry: string, token: string | null) {
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
try {
return await fetch(input, { ...init, signal: controller.signal })
} finally {
@@ -504,4 +504,49 @@ describe('clawhub e2e', () => {
await rm(cfg.dir, { recursive: true, force: true })
}
}, 180_000)
it('delete returns proper error for non-existent skill', async () => {
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
const site = process.env.CLAWDHUB_SITE?.trim() || 'https://clawdhub.com'
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login')
}
const cfg = await makeTempConfig(registry, token)
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-delete-'))
const nonExistentSlug = `non-existent-skill-${Date.now()}`
try {
const del = spawnSync(
'bun',
[
'clawdhub',
'delete',
nonExistentSlug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
// Should fail with non-zero exit code
expect(del.status).not.toBe(0)
// Error should mention "not found" - not generic "Unauthorized"
const output = (del.stdout + del.stderr).toLowerCase()
expect(output).toMatch(/not found|404|does not exist/i)
expect(output).not.toMatch(/unauthorized/i)
} finally {
await rm(workdir, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
}, 30_000)
})
-1
View File
@@ -44,7 +44,6 @@
"clawhub-schema": "workspace:*",
"clsx": "^2.1.1",
"convex": "^1.31.7",
"convex-helpers": "^0.1.111",
"fflate": "^0.8.2",
"h3": "2.0.1-rc.11",
"lucide-react": "^0.563.0",
+14
View File
@@ -0,0 +1,14 @@
import { readGlobalConfig } from '../config.js'
import { fail } from './ui.js'
export async function getOptionalAuthToken(): Promise<string | undefined> {
const cfg = await readGlobalConfig()
return cfg?.token ?? undefined
}
export async function requireAuthToken(): Promise<string> {
const token = await getOptionalAuthToken()
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
@@ -0,0 +1,65 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
const mockReadGlobalConfig = vi.fn(async () => null as { registry?: string; token?: string } | null)
const mockWriteGlobalConfig = vi.fn(async (_cfg: unknown) => {})
vi.mock('../../config.js', () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
writeGlobalConfig: (cfg: unknown) => mockWriteGlobalConfig(cfg),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const { cmdLogout } = await import('./auth')
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
function makeOpts(): GlobalOpts {
return {
workdir: '/work',
dir: '/work/skills',
site: 'https://clawhub.ai',
registry: 'https://clawhub.ai',
registrySource: 'default',
}
}
afterEach(() => {
vi.clearAllMocks()
mockLog.mockClear()
})
describe('cmdLogout', () => {
it('removes token and logs a clear message', async () => {
mockReadGlobalConfig.mockResolvedValueOnce({ registry: 'https://clawhub.ai', token: 'tkn' })
await cmdLogout(makeOpts())
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
registry: 'https://clawhub.ai',
token: undefined,
})
expect(mockGetRegistry).not.toHaveBeenCalled()
expect(mockLog).toHaveBeenCalledWith(
'OK. Logged out locally. Token still valid until revoked (Settings -> API tokens).',
)
})
it('falls back to resolved registry when config has no registry', async () => {
mockReadGlobalConfig.mockResolvedValueOnce({ token: 'tkn' })
mockGetRegistry.mockResolvedValueOnce('https://registry.example')
await cmdLogout(makeOpts())
expect(mockGetRegistry).toHaveBeenCalled()
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
registry: 'https://registry.example',
token: undefined,
})
})
})
+3 -4
View File
@@ -3,6 +3,7 @@ import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
import { discoverRegistryFromSite } from '../../discovery.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1WhoamiResponseSchema } from '../../schema/index.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
@@ -74,13 +75,11 @@ export async function cmdLogout(opts: GlobalOpts) {
const cfg = await readGlobalConfig()
const registry = cfg?.registry || (await getRegistry(opts, { cache: true }))
await writeGlobalConfig({ registry, token: undefined })
console.log('OK. Logged out.')
console.log('OK. Logged out locally. Token still valid until revoked (Settings -> API tokens).')
}
export async function cmdWhoami(opts: GlobalOpts) {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Checking token')
@@ -3,8 +3,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
vi.mock('../authToken.js', () => ({
requireAuthToken: vi.fn(async () => 'tkn'),
}))
vi.mock('../registry.js', () => ({
+3 -10
View File
@@ -1,6 +1,6 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1DeleteResponseSchema, parseArk } from '../../schema/index.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
@@ -40,13 +40,6 @@ const unhideLabels: SkillActionLabels = {
promptSuffix: 'requires moderator/admin',
}
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
export async function cmdDeleteSkill(
opts: GlobalOpts,
slugArg: string,
@@ -64,7 +57,7 @@ export async function cmdDeleteSkill(
if (!ok) return
}
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`${labels.progress} ${slug}`)
try {
@@ -98,7 +91,7 @@ export async function cmdUndeleteSkill(
if (!ok) return
}
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`${labels.progress} ${slug}`)
try {
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined)
vi.mock('../authToken.js', () => ({
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
}))
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
@@ -5,6 +5,7 @@ import {
ApiV1SkillVersionListResponseSchema,
ApiV1SkillVersionResponseSchema,
} from '../../schema/index.js'
import { getOptionalAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError } from '../ui.js'
@@ -31,12 +32,13 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
if (!trimmed) fail('Slug required')
if (options.version && options.tag) fail('Use either --version or --tag')
const token = await getOptionalAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching skill')
try {
const skillResult = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
ApiV1SkillResponseSchema,
)
@@ -67,6 +69,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
targetVersion,
)}`,
token,
},
ApiV1SkillVersionResponseSchema,
)
@@ -80,7 +83,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
spinner.text = `Fetching versions (${limit})`
versionsList = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: 'GET', url: url.toString(), token },
ApiV1SkillVersionListResponseSchema,
)
}
@@ -97,7 +100,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
url.searchParams.set('version', latestVersion)
}
spinner.text = `Fetching ${options.file}`
fileContent = await fetchText(registry, { url: url.toString() })
fileContent = await fetchText(registry, { url: url.toString(), token })
}
spinner.stop()
@@ -3,8 +3,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
vi.mock('../authToken.js', () => ({
requireAuthToken: vi.fn(async () => 'tkn'),
}))
vi.mock('../registry.js', () => ({
@@ -1,5 +1,4 @@
import { isCancel, select } from '@clack/prompts'
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import {
ApiRoutes,
@@ -8,17 +7,11 @@ import {
ApiV1UserSearchResponseSchema,
parseArk,
} from '../../schema/index.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
export async function cmdBanUser(
opts: GlobalOpts,
identifierArg: string,
@@ -30,7 +23,7 @@ export async function cmdBanUser(
const reason = options.reason?.trim() || undefined
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const allowPrompt = isInteractive() && inputAllowed !== false
const resolved = await resolveUserIdentifier(
@@ -87,7 +80,7 @@ export async function cmdSetRole(
if (!raw) fail('Handle or user id required')
const role = normalizeRole(roleArg)
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const allowPrompt = isInteractive() && inputAllowed !== false
const resolved = await resolveUserIdentifier(
@@ -6,8 +6,8 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GlobalOpts } from '../types'
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
vi.mock('../authToken.js', () => ({
requireAuthToken: vi.fn(async () => 'tkn'),
}))
const mockGetRegistry = vi.fn(async (_opts: unknown, _params?: unknown) => 'https://clawhub.ai')
@@ -1,10 +1,10 @@
import { stat } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import semver from 'semver'
import { readGlobalConfig } from '../../config.js'
import { apiRequestForm } from '../../http.js'
import { ApiRoutes, ApiV1PublishResponseSchema } from '../../schema/index.js'
import { listTextFiles } from '../../skills.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import { sanitizeSlug, titleCase } from '../slug.js'
import type { GlobalOpts } from '../types.js'
@@ -27,9 +27,7 @@ export async function cmdPublish(
const folderStat = await stat(folder).catch(() => null)
if (!folderStat || !folderStat.isDirectory()) fail('Path must be a folder')
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const slug = options.slug ?? sanitizeSlug(basename(folder))
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined)
vi.mock('../authToken.js', () => ({
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
}))
const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
@@ -50,7 +55,7 @@ vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
}))
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
const {
extractZipToDir,
hashSkillFiles,
@@ -189,3 +194,29 @@ describe('cmdUpdate', () => {
expect(args?.url).toBeUndefined()
})
})
describe('cmdInstall', () => {
it('passes optional auth token to API + download requests', async () => {
mockGetOptionalAuthToken.mockResolvedValue('tkn')
mockApiRequest.mockResolvedValue({
skill: { slug: 'demo', displayName: 'Demo', summary: null, tags: {}, stats: {}, createdAt: 0, updatedAt: 0 },
latestVersion: { version: '1.0.0' },
owner: null,
moderation: null,
})
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
vi.mocked(writeLockfile).mockResolvedValue()
vi.mocked(writeSkillOrigin).mockResolvedValue()
vi.mocked(extractZipToDir).mockResolvedValue()
vi.mocked(stat).mockRejectedValue(new Error('missing'))
vi.mocked(rm).mockResolvedValue()
await cmdInstall(makeOpts(), 'demo')
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
expect(requestArgs?.token).toBe('tkn')
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? []
expect(zipArgs?.token).toBe('tkn')
})
})
+12 -7
View File
@@ -21,6 +21,7 @@ import {
import { getRegistry } from '../registry.js'
import type { GlobalOpts, ResolveResult } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
import { getOptionalAuthToken } from '../authToken.js'
export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) {
if (!query) fail('Query required')
@@ -61,6 +62,8 @@ export async function cmdInstall(
const trimmed = slug.trim()
if (!trimmed) fail('Slug required')
const token = await getOptionalAuthToken()
const registry = await getRegistry(opts, { cache: true })
await mkdir(opts.dir, { recursive: true })
const target = join(opts.dir, trimmed)
@@ -76,7 +79,7 @@ export async function cmdInstall(
// Fetch skill metadata including moderation status
const skillMeta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
ApiV1SkillResponseSchema,
)
@@ -106,7 +109,7 @@ export async function cmdInstall(
if (!resolvedVersion) fail('Could not resolve latest version')
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion })
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token })
await extractZipToDir(zip, target)
await writeSkillOrigin(target, {
@@ -144,6 +147,8 @@ export async function cmdUpdate(
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
const allowPrompt = isInteractive() && inputAllowed !== false
const token = await getOptionalAuthToken()
const registry = await getRegistry(opts, { cache: true })
const lock = await readLockfile(opts.workdir)
const slugs = slug ? [slug] : Object.keys(lock.skills)
@@ -161,7 +166,7 @@ export async function cmdUpdate(
// Always fetch skill metadata to check moderation status
const skillMeta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
ApiV1SkillResponseSchema,
)
@@ -202,7 +207,7 @@ export async function cmdUpdate(
let resolveResult: ResolveResult
if (localFingerprint) {
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token)
} else {
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
}
@@ -255,7 +260,7 @@ export async function cmdUpdate(
spinner.start(`Updating ${entry} -> ${targetVersion}`)
}
await rm(target, { recursive: true, force: true })
const zip = await downloadZip(registry, { slug: entry, version: targetVersion })
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token })
await extractZipToDir(zip, target)
const existingOrigin = await readSkillOrigin(target)
@@ -407,13 +412,13 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
)
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
url.searchParams.set('hash', hash)
return apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: 'GET', url: url.toString(), token },
ApiV1SkillResolveResponseSchema,
)
}
+2 -9
View File
@@ -1,17 +1,10 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1StarResponseSchema } from '../../schema/index.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
export async function cmdStarSkill(
opts: GlobalOpts,
slugArg: string,
@@ -28,7 +21,7 @@ export async function cmdStarSkill(
if (!ok) return
}
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Starring ${slug}`)
try {
@@ -26,8 +26,8 @@ vi.mock('@clack/prompts', () => ({
isCancel: () => false,
}))
vi.mock('../../config.js', () => ({
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
vi.mock('../authToken.js', () => ({
requireAuthToken: vi.fn(async () => 'tkn'),
}))
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
+3 -5
View File
@@ -1,7 +1,7 @@
import { intro, outro } from '@clack/prompts'
import { readGlobalConfig } from '../../config.js'
import { hashSkillFiles, listTextFiles, readSkillOrigin } from '../../skills.js'
import { resolveClawdbotSkillRoots } from '../clawdbotConfig.js'
import { requireAuthToken } from '../authToken.js'
import { getFallbackSkillRoots } from '../scanSkills.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
@@ -32,9 +32,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
const allowPrompt = isInteractive() && inputAllowed !== false
intro('ClawHub sync')
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
const token = await requireAuthToken()
const registry = await getRegistryWithAuth(opts, token)
const selectedRoots = buildScanRoots(opts, options.root)
@@ -109,7 +107,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
let done = 0
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
try {
return await checkRegistrySyncState(registry, skill, resolveSupport)
return await checkRegistrySyncState(registry, skill, resolveSupport, token)
} finally {
done += 1
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`
@@ -100,6 +100,7 @@ export async function checkRegistrySyncState(
registry: string,
skill: LocalSkill,
resolveSupport: { value: boolean | null },
token?: string,
): Promise<Candidate> {
if (resolveSupport.value !== false) {
try {
@@ -108,6 +109,7 @@ export async function checkRegistrySyncState(
{
method: 'GET',
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
token,
},
ApiV1SkillResolveResponseSchema,
)
@@ -149,7 +151,7 @@ export async function checkRegistrySyncState(
const meta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}`, token },
ApiV1SkillResponseSchema,
).catch(() => null)
@@ -163,7 +165,7 @@ export async function checkRegistrySyncState(
}
}
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion, token })
const remote = hashSkillZip(zip).fingerprint
const matchVersion = remote === skill.fingerprint ? latestVersion : null
+2 -9
View File
@@ -1,17 +1,10 @@
import { readGlobalConfig } from '../../config.js'
import { apiRequest } from '../../http.js'
import { ApiRoutes, ApiV1UnstarResponseSchema } from '../../schema/index.js'
import { requireAuthToken } from '../authToken.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
async function requireToken() {
const cfg = await readGlobalConfig()
const token = cfg?.token
if (!token) fail('Not logged in. Run: clawhub login')
return token
}
export async function cmdUnstarSkill(
opts: GlobalOpts,
slugArg: string,
@@ -28,7 +21,7 @@ export async function cmdUnstarSkill(
if (!ok) return
}
const token = await requireToken()
const token = await requireAuthToken()
const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner(`Unstarring ${slug}`)
try {
+80 -3
View File
@@ -1,9 +1,41 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from 'vitest'
import { apiRequest, apiRequestForm, downloadZip } from './http'
import { apiRequest, apiRequestForm, downloadZip, fetchText } from './http'
import { ApiV1WhoamiResponseSchema } from './schema/index.js'
function mockImmediateTimeouts() {
const setTimeoutMock = vi.fn((callback: () => void) => {
callback()
return 1 as unknown as ReturnType<typeof setTimeout>
})
const clearTimeoutMock = vi.fn()
vi.stubGlobal('setTimeout', setTimeoutMock as unknown as typeof setTimeout)
vi.stubGlobal('clearTimeout', clearTimeoutMock as typeof clearTimeout)
return { setTimeoutMock, clearTimeoutMock }
}
function createAbortingFetchMock() {
return vi.fn(async (_url: string, init?: RequestInit) => {
const signal = init?.signal
if (!signal || !(signal instanceof AbortSignal)) {
throw new Error('Missing abort signal')
}
if (signal.aborted) {
throw signal.reason
}
return await new Promise<Response>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
reject(signal.reason)
},
{ once: true },
)
})
})
}
describe('apiRequest', () => {
it('adds bearer token and parses json', async () => {
const fetchMock = vi.fn().mockResolvedValue({
@@ -73,11 +105,16 @@ describe('apiRequest', () => {
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
})
vi.stubGlobal('fetch', fetchMock)
const bytes = await downloadZip('https://example.com', { slug: 'demo', version: '1.0.0' })
const bytes = await downloadZip('https://example.com', {
slug: 'demo',
version: '1.0.0',
token: 'clh_token',
})
expect(Array.from(bytes)).toEqual([1, 2, 3])
const [url] = fetchMock.mock.calls[0] as [string]
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('slug=demo')
expect(url).toContain('version=1.0.0')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
vi.unstubAllGlobals()
})
@@ -92,6 +129,25 @@ describe('apiRequest', () => {
expect(fetchMock).toHaveBeenCalledTimes(1)
vi.unstubAllGlobals()
})
it('aborts with Error timeouts and retries', async () => {
const { clearTimeoutMock } = mockImmediateTimeouts()
const fetchMock = createAbortingFetchMock()
vi.stubGlobal('fetch', fetchMock)
let caught: unknown
try {
await apiRequest('https://example.com', { method: 'GET', path: '/x' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toBe('Timeout')
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
vi.unstubAllGlobals()
})
})
describe('apiRequestForm', () => {
@@ -154,3 +210,24 @@ describe('apiRequestForm', () => {
vi.unstubAllGlobals()
})
})
describe('fetchText', () => {
it('aborts with Error timeouts and retries', async () => {
const { clearTimeoutMock } = mockImmediateTimeouts()
const fetchMock = createAbortingFetchMock()
vi.stubGlobal('fetch', fetchMock)
let caught: unknown
try {
await fetchText('https://example.com', { path: '/x' })
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toBe('Timeout')
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
vi.unstubAllGlobals()
})
})
+48 -56
View File
@@ -52,22 +52,13 @@ export async function apiRequest<T>(
headers['Content-Type'] = 'application/json'
body = JSON.stringify(args.body ?? {})
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
method: args.method,
headers,
body,
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
const text = await response.text().catch(() => '')
const message = text || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
throwHttpStatusError(response.status, await readResponseTextSafe(response))
}
return (await response.json()) as unknown
},
@@ -101,22 +92,13 @@ export async function apiRequestForm<T>(
const headers: Record<string, string> = { Accept: 'application/json' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
method: args.method,
headers,
body: args.form,
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
const text = await response.text().catch(() => '')
const message = text || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
throwHttpStatusError(response.status, await readResponseTextSafe(response))
}
return (await response.json()) as unknown
},
@@ -138,17 +120,10 @@ export async function fetchText(registry: string, args: TextRequestArgs): Promis
const headers: Record<string, string> = { Accept: 'text/plain' }
if (args.token) headers.Authorization = `Bearer ${args.token}`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal })
clearTimeout(timeout)
const response = await fetchWithTimeout(url, { method: 'GET', headers })
const text = await response.text()
if (!response.ok) {
const message = text || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
throwHttpStatusError(response.status, text)
}
return text
},
@@ -156,26 +131,25 @@ export async function fetchText(registry: string, args: TextRequestArgs): Promis
)
}
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
export async function downloadZip(
registry: string,
args: { slug: string; version?: string; token?: string },
) {
const url = new URL(ApiRoutes.download, registry)
url.searchParams.set('slug', args.slug)
if (args.version) url.searchParams.set('version', args.version)
return pRetry(
async () => {
if (isBun) {
return await fetchBinaryViaCurl(url.toString())
return await fetchBinaryViaCurl(url.toString(), args.token)
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal })
clearTimeout(timeout)
const headers: Record<string, string> = {}
if (args.token) headers.Authorization = `Bearer ${args.token}`
const response = await fetchWithTimeout(url.toString(), { method: 'GET', headers })
if (!response.ok) {
const message = (await response.text().catch(() => '')) || `HTTP ${response.status}`
if (response.status === 429 || response.status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
throwHttpStatusError(response.status, await readResponseTextSafe(response))
}
return new Uint8Array(await response.arrayBuffer())
},
@@ -183,6 +157,28 @@ export async function downloadZip(registry: string, args: { slug: string; versio
)
}
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
try {
return await fetch(url, { ...init, signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
async function readResponseTextSafe(response: Response): Promise<string> {
return await response.text().catch(() => '')
}
function throwHttpStatusError(status: number, text: string): never {
const message = text || `HTTP ${status}`
if (status === 429 || status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
}
async function fetchJsonViaCurl(url: string, args: RequestArgs) {
const headers = ['-H', 'Accept: application/json']
if (args.token) {
@@ -217,10 +213,7 @@ async function fetchJsonViaCurl(url: string, args: RequestArgs) {
const status = Number(output.slice(splitAt + 1).trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
if (status === 429 || status >= 500) {
throw new Error(body || `HTTP ${status}`)
}
throw new AbortError(body || `HTTP ${status}`)
throwHttpStatusError(status, body)
}
return JSON.parse(body || 'null') as unknown
}
@@ -272,10 +265,7 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
const status = Number(output.slice(splitAt + 1).trim())
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
if (status === 429 || status >= 500) {
throw new Error(body || `HTTP ${status}`)
}
throw new AbortError(body || `HTTP ${status}`)
throwHttpStatusError(status, body)
}
return JSON.parse(body || 'null') as unknown
} finally {
@@ -320,16 +310,22 @@ async function fetchTextViaCurl(url: string, args: { token?: string }) {
return body
}
async function fetchBinaryViaCurl(url: string) {
async function fetchBinaryViaCurl(url: string, token?: string) {
const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-'))
const filePath = join(tempDir, 'payload.bin')
try {
const headers: string[] = []
if (token) {
headers.push('-H', `Authorization: Bearer ${token}`)
}
const curlArgs = [
'--silent',
'--show-error',
'--location',
'--max-time',
String(REQUEST_TIMEOUT_SECONDS),
...headers,
'-o',
filePath,
'--write-out',
@@ -344,11 +340,7 @@ async function fetchBinaryViaCurl(url: string) {
if (!Number.isFinite(status)) throw new Error('curl response missing status')
if (status < 200 || status >= 300) {
const body = await readFileSafe(filePath)
const message = body ? new TextDecoder().decode(body) : `HTTP ${status}`
if (status === 429 || status >= 500) {
throw new Error(message)
}
throw new AbortError(message)
throwHttpStatusError(status, body ? new TextDecoder().decode(body) : '')
}
const bytes = await readFileSafe(filePath)
return bytes ? new Uint8Array(bytes) : new Uint8Array()
+6 -3
View File
@@ -20,15 +20,18 @@ import {
describe('skills', () => {
it('extracts zip into directory and skips traversal', async () => {
const dir = await mkdtemp(join(tmpdir(), 'clawhub-'))
const parent = await mkdtemp(join(tmpdir(), 'clawhub-zip-'))
const dir = join(parent, 'dir')
await mkdir(dir)
const evilName = `evil-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`
const zip = zipSync({
'SKILL.md': strToU8('hello'),
'../evil.txt': strToU8('nope'),
[`../${evilName}`]: strToU8('nope'),
})
await extractZipToDir(new Uint8Array(zip), dir)
expect((await readFile(join(dir, 'SKILL.md'), 'utf8')).trim()).toBe('hello')
await expect(stat(join(dir, '..', 'evil.txt'))).rejects.toBeTruthy()
await expect(stat(join(parent, evilName))).rejects.toBeTruthy()
})
it('writes and reads lockfile', async () => {
@@ -21,9 +21,6 @@ vi.mock('@tanstack/react-router', () => ({
vi.mock('convex/react', () => ({
useAction: (...args: unknown[]) => useActionMock(...args),
}))
vi.mock('convex-helpers/react', () => ({
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
}))
+55 -3
View File
@@ -21,9 +21,6 @@ vi.mock('@tanstack/react-router', () => ({
vi.mock('convex/react', () => ({
useAction: (...args: unknown[]) => useActionMock(...args),
}))
vi.mock('convex-helpers/react', () => ({
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
}))
@@ -123,6 +120,32 @@ describe('SkillsIndex', () => {
})
})
it('sorts search results by stars and breaks ties by updatedAt', async () => {
searchMock = { q: 'remind', sort: 'stars', dir: 'desc' }
const actionFn = vi
.fn()
.mockResolvedValue([
makeSearchEntry({ slug: 'skill-a', displayName: 'Skill A', stars: 5, updatedAt: 100 }),
makeSearchEntry({ slug: 'skill-b', displayName: 'Skill B', stars: 5, updatedAt: 200 }),
makeSearchEntry({ slug: 'skill-c', displayName: 'Skill C', stars: 4, updatedAt: 999 }),
])
useActionMock.mockReturnValue(actionFn)
vi.useFakeTimers()
render(<SkillsIndex />)
await act(async () => {
await vi.runAllTimersAsync()
})
await act(async () => {
await vi.runAllTimersAsync()
})
const links = screen.getAllByRole('link')
expect(links[0]?.textContent).toContain('Skill B')
expect(links[1]?.textContent).toContain('Skill A')
expect(links[2]?.textContent).toContain('Skill C')
})
it('uses relevance as default sort when searching', async () => {
searchMock = { q: 'notion' }
const actionFn = vi
@@ -206,3 +229,32 @@ function makeSearchResult(slug: string, displayName: string, score: number, crea
version: null,
}
}
function makeSearchEntry(params: {
slug: string
displayName: string
stars: number
updatedAt: number
}) {
return {
score: 0.9,
skill: {
_id: `skill_${params.slug}`,
slug: params.slug,
displayName: params.displayName,
summary: `Summary ${params.slug}`,
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: params.stars,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: params.updatedAt,
},
version: null,
}
}
+16 -6
View File
@@ -10,6 +10,7 @@ import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicUser } from '../lib/publicUser'
import { canManageSkill, isModerator } from '../lib/roles'
import { useAuthStatus } from '../lib/useAuthStatus'
import { UserBadge } from './UserBadge'
const SkillDiffCard = lazy(() =>
import('./SkillDiffCard').then((m) => ({ default: m.SkillDiffCard })),
@@ -138,7 +139,12 @@ function LlmAnalysisDetail({ analysis }: { analysis: LlmAnalysis }) {
<button
type="button"
className="analysis-detail-header"
onClick={() => setIsOpen((prev) => !prev)}
onClick={() => {
// Drag-select to copy summary text should not toggle open/closed.
const selection = window.getSelection()
if (selection && !selection.isCollapsed) return
setIsOpen((prev) => !prev)
}}
aria-expanded={isOpen}
>
<span className="analysis-summary-text">{analysis.summary}</span>
@@ -646,11 +652,15 @@ export function SkillDetailPage({
{skill.stats.installsCurrent ?? 0} current · {skill.stats.installsAllTime ?? 0}{' '}
all-time
</div>
{owner?.handle ? (
<div className="stat">
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
</div>
) : null}
<div className="stat">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
</div>
{forkOf && forkOfHref ? (
<div className="stat">
{forkOfLabel}{' '}
+1 -1
View File
@@ -423,7 +423,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
const ink = styles.getPropertyValue('--ink').trim() || '#1d1a17'
const inkSoft = styles.getPropertyValue('--ink-soft').trim() || '#4c463f'
const line = styles.getPropertyValue('--line').trim() || 'rgba(29, 26, 23, 0.12)'
const accent = styles.getPropertyValue('--accent').trim() || '#4f9dff'
const accent = styles.getPropertyValue('--accent').trim() || '#e65c46'
const seafoam = styles.getPropertyValue('--seafoam').trim() || '#2bc6a4'
const diffAdded = styles.getPropertyValue('--diff-added').trim() || seafoam
const diffRemoved = styles.getPropertyValue('--diff-removed').trim() || accent
+56
View File
@@ -0,0 +1,56 @@
import type { PublicUser } from '../lib/publicUser'
type UserBadgeProps = {
user: PublicUser | null | undefined
fallbackHandle?: string | null
prefix?: string
size?: 'sm' | 'md'
link?: boolean
showName?: boolean
}
export function UserBadge({
user,
fallbackHandle,
prefix = 'by',
size = 'sm',
link = true,
showName = false,
}: UserBadgeProps) {
const handle = user?.handle ?? user?.name ?? fallbackHandle ?? null
const href = user?.handle ? `/u/${encodeURIComponent(user.handle)}` : null
const label = handle ? `@${handle}` : 'user'
const image = user?.image ?? null
const displayName = user?.displayName?.trim() || null
const hasUsefulName =
showName && Boolean(displayName) && Boolean(handle) && displayName!.toLowerCase() !== handle!.toLowerCase()
const initial = (user?.displayName ?? user?.name ?? handle ?? 'u').charAt(0).toUpperCase()
return (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
{image ? (
<img className="user-avatar-img" src={image} alt="" loading="lazy" />
) : (
<span className="user-avatar-fallback">{initial}</span>
)}
</span>
{hasUsefulName ? (
<>
<span className="user-name">{displayName}</span>
<span className="user-name-sep" aria-hidden="true">
·
</span>
</>
) : null}
{link && href ? (
<a className="user-handle" href={href}>
{label}
</a>
) : (
<span className="user-handle">{label}</span>
)}
</span>
)
}
+1 -1
View File
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:rgba(93,167,255,0.12)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
+1 -1
View File
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:rgba(93,167,255,0.4)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white',
'inline-flex h-9 w-9 items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white',
className,
)}
{...props}
+34 -20
View File
@@ -5,10 +5,10 @@ import { api } from '../../convex/_generated/api'
import { InstallSwitcher } from '../components/InstallSwitcher'
import { SkillCard } from '../components/SkillCard'
import { SoulCard } from '../components/SoulCard'
import { UserBadge } from '../components/UserBadge'
import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicSoul } from '../lib/publicUser'
import type { PublicSkill, PublicSoul, PublicUser } from '../lib/publicUser'
import { getSiteMode } from '../lib/site'
import { mapPublicSkillPageEntries } from '../lib/skillPageEntries'
export const Route = createFileRoute('/')({
component: Home,
@@ -23,21 +23,19 @@ function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill
ownerHandle?: string | null
owner?: PublicUser | null
latestVersion?: unknown
}
const highlighted =
(useQuery(api.skills.list, {
batch: 'highlighted',
limit: 6,
}) as PublicSkill[]) ?? []
(useQuery(api.skills.listHighlightedPublic, { limit: 6 }) as SkillPageEntry[]) ?? []
const popularResult = useQuery(api.skills.listPublicPageV2, {
paginationOpts: { cursor: null, numItems: 12 },
sort: 'downloads',
dir: 'desc',
nonSuspiciousOnly: true,
}) as { page: SkillPageEntry[] } | undefined
const popular = mapPublicSkillPageEntries(popularResult?.page)
const popular = popularResult?.page ?? []
return (
<main>
@@ -87,16 +85,24 @@ function SkillsHome() {
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
highlighted.map((entry) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
{entry.skill.stats.stars} · {entry.skill.stats.downloads} · {' '}
{entry.skill.stats.installsAllTime ?? 0}
</div>
</div>
}
/>
@@ -112,15 +118,23 @@ function SkillsHome() {
{popular.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
popular.map((skill) => (
popular.map((entry) => (
<SkillCard
key={skill._id}
skill={skill}
key={entry.skill._id}
skill={entry.skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
{entry.skill.stats.stars} · {entry.skill.stats.downloads} · {' '}
{entry.skill.stats.installsAllTime ?? 0}
</div>
</div>
}
/>
+39 -15
View File
@@ -1,12 +1,12 @@
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { useAction } from 'convex/react'
import { usePaginatedQuery } from 'convex-helpers/react'
import { useAction, usePaginatedQuery } from 'convex/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../../../convex/_generated/api'
import type { Doc } from '../../../convex/_generated/dataModel'
import { SkillCard } from '../../components/SkillCard'
import { UserBadge } from '../../components/UserBadge'
import { getSkillBadges, isSkillHighlighted } from '../../lib/badges'
import type { PublicSkill } from '../../lib/publicUser'
import type { PublicSkill, PublicUser } from '../../lib/publicUser'
const sortKeys = [
'relevance',
@@ -53,6 +53,7 @@ type SkillListEntry = {
}
} | null
ownerHandle?: string | null
owner?: PublicUser | null
searchScore?: number
}
@@ -61,6 +62,7 @@ type SkillSearchEntry = {
version: Doc<'skillVersions'> | null
score: number
ownerHandle?: string | null
owner?: PublicUser | null
}
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
@@ -136,7 +138,6 @@ export function SkillsIndex() {
? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}::${nonSuspiciousOnly ? '1' : '0'}`
: ''
// Use convex-helpers usePaginatedQuery for better cache behavior
const {
results: paginatedResults,
status: paginationStatus,
@@ -223,6 +224,7 @@ export function SkillsIndex() {
skill: entry.skill,
latestVersion: entry.version,
ownerHandle: entry.ownerHandle ?? null,
owner: entry.owner ?? null,
searchScore: entry.score,
}))
}
@@ -236,34 +238,48 @@ export function SkillsIndex() {
)
const sorted = useMemo(() => {
if (!hasQuery) {
return filtered
}
const multiplier = dir === 'asc' ? 1 : -1
const results = [...filtered]
results.sort((a, b) => {
const tieBreak = () => {
const updated = (a.skill.updatedAt - b.skill.updatedAt) * multiplier
if (updated !== 0) return updated
return a.skill.slug.localeCompare(b.skill.slug)
}
switch (sort) {
case 'relevance':
return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier
case 'downloads':
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak()
case 'installs':
return (
((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) *
multiplier
multiplier || tieBreak()
)
case 'stars':
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak()
case 'updated':
return (a.skill.updatedAt - b.skill.updatedAt) * multiplier
return (
(a.skill.updatedAt - b.skill.updatedAt) * multiplier ||
a.skill.slug.localeCompare(b.skill.slug)
)
case 'name':
return (
(a.skill.displayName.localeCompare(b.skill.displayName) ||
a.skill.slug.localeCompare(b.skill.slug)) * multiplier
)
default:
return (a.skill.createdAt - b.skill.createdAt) * multiplier
return (
(a.skill.createdAt - b.skill.createdAt) * multiplier ||
a.skill.slug.localeCompare(b.skill.slug)
)
}
})
return results
}, [dir, filtered, sort])
}, [dir, filtered, hasQuery, sort])
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
const canLoadMore = hasQuery
@@ -442,7 +458,8 @@ export function SkillsIndex() {
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
const skillHref = buildSkillHref(skill, entry.ownerHandle)
const ownerHandle = entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null
const skillHref = buildSkillHref(skill, ownerHandle)
return (
<SkillCard
key={skill._id}
@@ -452,9 +469,12 @@ export function SkillsIndex() {
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
<div className="skill-card-footer-rows">
<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
</div>
</div>
}
/>
@@ -466,7 +486,8 @@ export function SkillsIndex() {
{sorted.map((entry) => {
const skill = entry.skill
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
const skillHref = buildSkillHref(skill, entry.ownerHandle)
const ownerHandle = entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null
const skillHref = buildSkillHref(skill, ownerHandle)
return (
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
@@ -485,6 +506,9 @@ export function SkillsIndex() {
<div className="skills-row-summary">
{skill.summary ?? 'No summary provided.'}
</div>
<div className="skills-row-owner">
<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />
</div>
{isPlugin ? (
<div className="skills-row-meta">
Bundle includes SKILL.md, CLI, and config.
+143 -33
View File
@@ -5,24 +5,24 @@
:root {
color-scheme: light dark;
--bg: #f2f7f9;
--bg-soft: #f7fcfd;
--bg: #f8f2ed;
--bg-soft: #fdf7f2;
--bg-glow-1: #ffe1d4;
--bg-glow-2: #d8f0f2;
--bg-glow-2: #ffe8d8;
--surface: #ffffff;
--surface-muted: #f0f8fa;
--nav-bg: rgba(242, 247, 249, 0.88);
--ink: #14242e;
--ink-soft: #4b6677;
--surface-muted: #f7efe9;
--nav-bg: rgba(248, 242, 237, 0.88);
--ink: #2a1f19;
--ink-soft: #6b5549;
--accent: #e65c46;
--accent-deep: #bf3f30;
--seafoam: #209e92;
--gold: #e7bb67;
--line: rgba(20, 36, 46, 0.14);
--line: rgba(42, 31, 25, 0.14);
--border-ui: rgba(191, 63, 48, 0.28);
--border-ui-hover: rgba(191, 63, 48, 0.42);
--border-ui-active: rgba(191, 63, 48, 0.62);
--shadow: 0 22px 52px rgba(16, 34, 44, 0.11);
--shadow: 0 22px 52px rgba(44, 28, 20, 0.11);
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 9px;
@@ -36,20 +36,20 @@
[data-theme="dark"] {
color-scheme: dark;
--bg: #0d1b24;
--bg-soft: #142632;
--bg: #14100d;
--bg-soft: #1d1713;
--bg-glow-1: #4b211b;
--bg-glow-2: #11303b;
--surface: #1a2d39;
--surface-muted: #213744;
--nav-bg: rgba(13, 27, 36, 0.88);
--ink: #edf6f9;
--ink-soft: #acc2cf;
--bg-glow-2: #3a2018;
--surface: #241b16;
--surface-muted: #2f241d;
--nav-bg: rgba(20, 16, 13, 0.88);
--ink: #f7eee8;
--ink-soft: #c8b3a6;
--accent: #ff7357;
--accent-deep: #e25640;
--seafoam: #47c3b8;
--gold: #f3c97a;
--line: rgba(232, 243, 249, 0.16);
--line: rgba(247, 235, 225, 0.16);
--border-ui: rgba(255, 115, 87, 0.4);
--border-ui-hover: rgba(255, 115, 87, 0.58);
--border-ui-active: rgba(255, 115, 87, 0.78);
@@ -1265,6 +1265,14 @@ code {
-webkit-box-orient: vertical;
}
.skills-row-owner {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
color: var(--ink-soft);
}
.skills-row-metrics {
display: flex;
align-items: center;
@@ -1368,6 +1376,94 @@ code {
gap: 12px;
}
.skill-card-footer-rows {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
align-items: start;
}
.skill-card-footer-rows .stat {
justify-self: end;
}
.user-badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-badge-prefix {
color: var(--ink-soft);
}
.user-avatar {
width: 22px;
height: 22px;
border-radius: 999px;
overflow: hidden;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
flex: 0 0 auto;
}
.user-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.user-avatar-fallback {
font-family: var(--font-display);
font-size: 0.82rem;
font-weight: 680;
color: color-mix(in srgb, var(--ink) 86%, transparent);
}
.user-handle {
color: inherit;
text-decoration: none;
font-family: var(--font-mono);
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 18ch;
}
.user-name {
font-family: var(--font-display);
font-size: 0.88rem;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
min-width: 0;
}
.user-name-sep {
color: var(--ink-soft);
flex: 0 0 auto;
}
.user-handle:hover {
text-decoration: underline;
}
.user-badge-md .user-avatar {
width: 26px;
height: 26px;
}
.user-badge-md .user-handle {
font-size: 0.9rem;
}
.hero-install {
display: grid;
gap: 10px;
@@ -1874,6 +1970,11 @@ code {
gap: 10px;
}
.skill-hero-cta .btn {
width: 100%;
justify-content: center;
}
.skill-version-pill {
display: grid;
gap: 4px;
@@ -2898,9 +2999,10 @@ code {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 250, 247, 0.88));
border: 1px solid rgba(255, 107, 74, 0.18);
border-left: 3px solid var(--accent-deep);
background:
radial-gradient(1200px 220px at 12% 0%, rgba(255, 107, 74, 0.08), transparent 55%),
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 247, 0.9));
border: 1px solid rgba(255, 107, 74, 0.2);
border-radius: 12px;
padding: 14px 16px;
font-family: var(--font-mono);
@@ -2908,7 +3010,9 @@ code {
line-height: 1.55;
tab-size: 2;
color: var(--ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
box-shadow:
inset 4px 0 0 rgba(255, 107, 74, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.markdown pre code {
@@ -2921,11 +3025,14 @@ code {
}
[data-theme="dark"] .markdown pre {
background: linear-gradient(180deg, rgba(20, 36, 47, 0.9), rgba(15, 29, 40, 0.86));
border: 1px solid rgba(232, 106, 71, 0.28);
border-left-color: rgba(232, 106, 71, 0.8);
color: #f5e9e3;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
background:
radial-gradient(900px 260px at 12% 0%, rgba(255, 168, 142, 0.08), transparent 55%),
linear-gradient(180deg, rgba(14, 22, 32, 0.95), rgba(10, 16, 24, 0.92));
border: 1px solid rgba(255, 168, 142, 0.22);
color: rgba(244, 238, 234, 0.96);
box-shadow:
inset 4px 0 0 rgba(255, 140, 110, 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .markdown :not(pre) > code {
@@ -3123,13 +3230,13 @@ html.theme-transition::view-transition-new(theme) {
.btn-ghost {
background: transparent;
border: 1px solid var(--card-border);
color: var(--color-text);
border-color: var(--border-ui);
color: var(--ink);
}
.btn-ghost:hover {
background: var(--card-bg);
border-color: var(--color-accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-color: var(--border-ui-hover);
}
@media (max-width: 640px) {
@@ -3300,7 +3407,7 @@ html.theme-transition::view-transition-new(theme) {
}
.scan-result-icon-vt {
color: #0030ff;
color: var(--accent-deep);
}
.scan-result-icon-oc {
@@ -3449,7 +3556,7 @@ html.theme-transition::view-transition-new(theme) {
}
.version-scan-icon-vt {
color: #0030ff;
color: var(--accent-deep);
}
.version-scan-icon-oc {
@@ -3518,6 +3625,7 @@ html.theme-transition::view-transition-new(theme) {
align-items: center;
gap: 4px;
white-space: nowrap;
user-select: none;
}
.analysis-detail-toggle .chevron {
@@ -3533,6 +3641,8 @@ html.theme-transition::view-transition-new(theme) {
color: var(--ink);
line-height: 1.45;
flex: 1;
user-select: text;
cursor: text;
}
.analysis-body {