mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-17 02:12:11 +00:00
Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa9a6db13 | ||
|
|
f5944c529e | ||
|
|
531dcc8d26 | ||
|
|
b1c710e1ea | ||
|
|
a4a9fc62bc | ||
|
|
e58fbc8d1f | ||
|
|
b4b4f266a8 | ||
|
|
f9d35cc5e6 | ||
|
|
1892f72a13 | ||
|
|
fdbc184e0a | ||
|
|
18cbfc6788 | ||
|
|
c821b0ffc4 | ||
|
|
9833a5038d | ||
|
|
40a89e02d5 | ||
|
|
bc06dbffd0 | ||
|
|
f5fa23e0c1 | ||
|
|
e8c3947b21 | ||
|
|
ef26ee0d1f | ||
|
|
3d006ec663 | ||
|
|
52590e84dd | ||
|
|
e3a1c95851 | ||
|
|
db4540743f | ||
|
|
0cb0963c2b | ||
|
|
beae065794 | ||
|
|
46c5637dae | ||
|
|
2217a327e7 | ||
|
|
45d8f0d217 | ||
|
|
add5d83014 | ||
|
|
9751199231 | ||
|
|
883221f8ec | ||
|
|
3e45d67e0d | ||
|
|
df346aeea9 | ||
|
|
4317369480 | ||
|
|
e04d16bdae | ||
|
|
cb66d8d6f3 | ||
|
|
14a2fa80f6 | ||
|
|
ee788b7af3 | ||
|
|
3956ca7e55 | ||
|
|
bc37ec7156 | ||
|
|
f07408eb81 | ||
|
|
cdf5baef7f | ||
|
|
65b154f36c | ||
|
|
6ea7a0792d | ||
|
|
ed961e459f | ||
|
|
8b5f242f73 | ||
|
|
311bf1a88a | ||
|
|
8343f0bb23 | ||
|
|
3b73a09d36 | ||
|
|
412249d2d1 | ||
|
|
480125d859 | ||
|
|
0ab1d1e051 | ||
|
|
85374fa44b | ||
|
|
c319e46c8b | ||
|
|
f17087d1f4 | ||
|
|
da4469e1e0 | ||
|
|
42a4648475 | ||
|
|
edc8ec274b | ||
|
|
15b1a05fee | ||
|
|
1e216c03c6 | ||
|
|
0a0b2e6cb1 | ||
|
|
ac6770acff | ||
|
|
bfc87e5932 | ||
|
|
3e1bd19a45 | ||
|
|
d71b747d1c | ||
|
|
4d211fcf73 | ||
|
|
bc5ab8f3e1 | ||
|
|
bb528ea4b9 | ||
|
|
464a04c1e5 | ||
|
|
f4f8e7276f | ||
|
|
3ccf2e05f5 | ||
|
|
0ee2872f5b | ||
|
|
5112d1b215 | ||
|
|
275a170f15 | ||
|
|
17aa24baf9 | ||
|
|
f01476757a | ||
|
|
fe011d00fd | ||
|
|
30ae099825 | ||
|
|
812641342d | ||
|
|
88848c224c | ||
|
|
c107adabac | ||
|
|
38c4a673da | ||
|
|
89933951f5 | ||
|
|
e3523093b1 | ||
|
|
1faf3ee5ed | ||
|
|
286c76a05f | ||
|
|
5745b5a096 | ||
|
|
a060ae3b15 | ||
|
|
77982c5d8e | ||
|
|
4cb84df36a | ||
|
|
d82c8f66c2 | ||
|
|
43fd834d23 | ||
|
|
9b2fc48a55 | ||
|
|
a4dad5dc9d | ||
|
|
84830a268a | ||
|
|
37ef3eb7c5 | ||
|
|
7f987fcc26 | ||
|
|
a0ea45c9a6 | ||
|
|
1f5a782ecd | ||
|
|
c300d4b447 | ||
|
|
c3a6cd7356 | ||
|
|
b75e25c4d6 | ||
|
|
54383665d8 | ||
|
+22 |
697cc1a08f | ||
|
|
652beef9c1 | ||
|
|
146df7b166 | ||
|
|
8f23eb5ee8 | ||
|
|
30b263c27c |
@@ -0,0 +1,35 @@
|
||||
name: "Security Gate: Secret Scanning"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
trufflehog:
|
||||
name: Scan for Verified Secrets
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # Required to scan the code in the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # necessary to support the scoping requirements below
|
||||
|
||||
- name: TruffleHog OSS
|
||||
id: trufflehog
|
||||
# Use a concrete released ref that resolves in upstream action registry.
|
||||
# v3 (major tag) is not published by trufflesecurity/trufflehog.
|
||||
uses: trufflesecurity/trufflehog@v3.93.6
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha }} # scope it to the committed files
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
extra_args: --only-verified --debug
|
||||
|
||||
- name: Notify on Failure
|
||||
if: steps.trufflehog.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Verified secrets found! This PR contains live credentials that must be rotated immediately."
|
||||
echo "::notice::If these secrets are already in the commit history, they cannot be removed via a simple removal commit/push. A repository owner can contact GitHub Support to purge the cached data: https://support.github.com/contact/private-information"
|
||||
exit 1
|
||||
@@ -33,6 +33,7 @@
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
|
||||
|
||||
## Git Notes
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
|
||||
@@ -3,28 +3,63 @@
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Moderation: add comment reporting with per-user active report caps, unique reporter/target enforcement, and auto-hide on the 4th unique report.
|
||||
- Moderation: add AI-driven comment scam backfill (`commentModeration:*`) with persisted verdict/confidence/explainer metadata and strict auto-ban for `certain_scam` + `high` confidence.
|
||||
- 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).
|
||||
- Skills/Web: add file viewer for skill version files on detail page (#44) (thanks @regenrek).
|
||||
- CLI: add `uninstall` command for skills (#241) (thanks @superlowburn).
|
||||
- CI/Security: add TruffleHog pull-request scanning for verified leaked credentials (#505) (thanks @akses0).
|
||||
|
||||
### Changed
|
||||
- Security/docs: document comment reporting/auto-hide behavior alongside existing skill reporting rules.
|
||||
- Security/moderation: add bounded explainable auto-ban reasons for scam comments and protect moderator/admin accounts from automated bans.
|
||||
- Moderation: banning users now also soft-deletes their authored comments (skill + soul), including legacy cleanup on re-ban.
|
||||
- Skill metadata: support env vars, dependency declarations, author, and links in parsed manifest metadata + install UI (#360) (thanks @mahsumaktas).
|
||||
- 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).
|
||||
- LLM helpers: centralize OpenAI Responses text extraction for changelog/summary/eval flows (#502) (thanks @ianalloway).
|
||||
- Rate limiting: apply authenticated quotas by user bucket (vs shared IP), emit delay-based reset headers, and improve CLI 429 guidance/retries (#412) (thanks @lc0rp).
|
||||
- Search/listing performance: cut embedding hydration and badge read bandwidth via `embeddingSkillMap` + denormalized skill badges; shift stat-doc sync to low-frequency cron (#441) (thanks @sethconvex).
|
||||
|
||||
### Fixed
|
||||
- Upload: keep folder-picking enabled after page refresh by reapplying `webkitdirectory`/`directory` on the file input ref (#551) (thanks @MunemHashmi).
|
||||
- Skills hard-delete: delete `commentReports` rows during moderation cleanup to avoid orphaned report records.
|
||||
- Comments: hide entries authored by deleted/deactivated users in `comments:listBySkill`.
|
||||
- Admin API: `POST /api/v1/users/reclaim` now performs non-destructive root-slug owner transfer
|
||||
(preserves existing skill versions/stats/metadata) and clears active slug reservations.
|
||||
- VirusTotal: use shared AV-engine fallback verdict mapping for pending/backfill flows and keep undetected-only results pending (#591) (thanks @Shuai-DaiDai).
|
||||
- CLI publish: use a longer multipart upload timeout and normalize abort rejections into proper Errors (#550) (thanks @MunemHashmi).
|
||||
- CLI: forward optional auth tokens for `search` and `explore` against authenticated registries (#608) (thanks @artdaal).
|
||||
- Skill metadata: parse top-level `requires.*`, `primaryEnv`, and homepage fallbacks for security review accuracy (#548) (thanks @MunemHashmi).
|
||||
- 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).
|
||||
- VT fallback: activate only VT-pending hidden skills when scans are unavailable/stale; keep quality/scanner-blocked skills hidden (#300) (thanks @superlowburn).
|
||||
- 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.
|
||||
- Web: allow copying OpenClaw scan summary text (thanks @borisolver, #322).
|
||||
- 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).
|
||||
- CLI: validate skill slugs used for filesystem operations (prevents path traversal) (#241) (thanks @superlowburn).
|
||||
- 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).
|
||||
- Skills/Web: prevent filtered pagination dead-ends and loading-state flicker on `/skills`; move highlighted browse filtering into server list query (#339) (thanks @Marvae).
|
||||
- Web: align `/skills` total count with public visibility and format header count (thanks @rknoche6, #76).
|
||||
- Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76).
|
||||
- Moderation: clear stale `flagged.suspicious` flags when VirusTotal rescans improve to clean verdicts (#418) (thanks @Phineas1500).
|
||||
- CLI: respect `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars for outbound registry requests, with troubleshooting docs (#363) (thanks @kerrypotter).
|
||||
- CLI: preserve registry base paths when composing API URLs for search/inspect/moderation commands (#486) (thanks @Liknox).
|
||||
- API tests: lock `Retry-After` behavior to relative-delay semantics for v1 search 429s (#421) (thanks @apoorvdarshan).
|
||||
- CLI tests: assert 5xx HTTP responses still perform retry attempts before surfacing final error (#457) (thanks @YonghaoZhao722).
|
||||
- GitHub import: improve storage/publish failure errors with actionable context; add regression tests for error formatting (#512) (thanks @vassiliylakhonin).
|
||||
- CLI: show manual URL guidance when automatic browser opening is unavailable; add regression tests for opener errors (#163) (thanks @aronchick).
|
||||
- API/CLI: expose skill security status in version inspect output, with schema wiring and CLI regression coverage (#362) (thanks @abutbul).
|
||||
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# Contributing to ClawHub
|
||||
|
||||
Welcome! ClawHub is the public skill registry for [OpenClaw](https://github.com/openclaw/openclaw). We appreciate bug fixes, documentation improvements, and feature contributions.
|
||||
|
||||
- **Questions?** Ask in [#clawhub on Discord](https://discord.gg/clawd).
|
||||
- **Bug fixes** — PRs are welcome.
|
||||
- **New features or architectural changes** — please start with a Discord conversation in #clawhub first so we can align on scope.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (Convex CLI runs via `bunx`, no global install needed)
|
||||
|
||||
### Install and configure
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` with the following values for **local Convex**:
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
VITE_CONVEX_URL=http://127.0.0.1:3210
|
||||
VITE_CONVEX_SITE_URL=http://127.0.0.1:3210
|
||||
SITE_URL=http://localhost:3000
|
||||
CONVEX_SITE_URL=http://127.0.0.1:3210
|
||||
|
||||
# Deployment used by `bunx convex dev`
|
||||
CONVEX_DEPLOYMENT=anonymous:anonymous-clawhub
|
||||
```
|
||||
|
||||
### GitHub OAuth App (for login)
|
||||
|
||||
1. Go to [github.com/settings/developers](https://github.com/settings/developers) and create a new OAuth App.
|
||||
2. Set **Homepage URL** to `http://localhost:3000`.
|
||||
3. Set **Authorization callback URL** to `http://127.0.0.1:3210/api/auth/callback/github`.
|
||||
4. Copy the Client ID and generate a Client Secret, then add them to `.env.local`:
|
||||
|
||||
```bash
|
||||
AUTH_GITHUB_ID=<your-client-id>
|
||||
AUTH_GITHUB_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
### JWT keys (for Convex Auth)
|
||||
|
||||
Generate the signing keys:
|
||||
|
||||
```bash
|
||||
bunx @convex-dev/auth
|
||||
```
|
||||
|
||||
This outputs `JWT_PRIVATE_KEY` and `JWKS` values — paste them into `.env.local`.
|
||||
|
||||
### Run the app
|
||||
|
||||
```bash
|
||||
# Terminal A: local Convex backend
|
||||
bunx convex dev
|
||||
|
||||
# Terminal B: frontend (port 3000)
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Seed the database
|
||||
|
||||
Populate sample data so the UI isn't empty:
|
||||
|
||||
```bash
|
||||
# 3 sample skills (padel, gohome, xuezh)
|
||||
bunx convex run --no-push devSeed:seedNixSkills
|
||||
|
||||
# 50 extra skills for pagination testing (optional)
|
||||
bunx convex run --no-push devSeedExtra:seedExtraSkillsInternal
|
||||
```
|
||||
|
||||
To reset and re-seed:
|
||||
|
||||
```bash
|
||||
bunx convex run --no-push devSeed:seedNixSkills '{"reset": true}'
|
||||
```
|
||||
|
||||
### Optional environment variables
|
||||
|
||||
These features degrade gracefully without their keys:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `OPENAI_API_KEY` | Embeddings and vector search (falls back to zero vectors) |
|
||||
| `VT_API_KEY` | VirusTotal malware scanning |
|
||||
| `DISCORD_WEBHOOK_URL` | Discord notifications |
|
||||
| `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` / `GITHUB_APP_INSTALLATION_ID` | GitHub backup sync |
|
||||
|
||||
## CLI Development
|
||||
|
||||
The CLI source lives in [`packages/clawdhub/`](packages/clawdhub/). Both `clawhub` and `clawdhub` are registered as bin aliases.
|
||||
|
||||
To test the CLI against your local instance:
|
||||
|
||||
```bash
|
||||
CLAWHUB_REGISTRY=http://127.0.0.1:3210 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
|
||||
```
|
||||
|
||||
Manual smoke tests are documented in [`docs/manual-testing.md`](docs/manual-testing.md).
|
||||
|
||||
## Skill & Soul Publishing
|
||||
|
||||
- Skill format reference: [`docs/skill-format.md`](docs/skill-format.md)
|
||||
- Soul format reference: [`docs/soul-format.md`](docs/soul-format.md)
|
||||
- End-to-end walkthrough (search, install, publish, sync): [`docs/quickstart.md`](docs/quickstart.md)
|
||||
|
||||
Quick publish:
|
||||
|
||||
```bash
|
||||
clawhub publish <path-to-skill-directory>
|
||||
```
|
||||
|
||||
## Before Submitting a PR
|
||||
|
||||
```bash
|
||||
bun run lint # oxlint
|
||||
bun run test # Vitest (80% coverage threshold)
|
||||
bun run build # Vite + Nitro
|
||||
```
|
||||
|
||||
These are the same checks that run in CI (`.github/workflows/ci.yml`).
|
||||
|
||||
**PR guidelines:**
|
||||
|
||||
- Keep PRs focused — one concern per PR.
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, etc.
|
||||
- Include test commands and screenshots for UI changes.
|
||||
- Write a clear description of what changed and why.
|
||||
|
||||
## AI-Generated Code
|
||||
|
||||
AI-assisted contributions are welcome. When submitting AI-generated or AI-assisted code:
|
||||
|
||||
- Note it in the PR description.
|
||||
- Describe the level of testing you applied.
|
||||
- Include prompts if useful for reviewers.
|
||||
- Confirm that you understand and can maintain the code.
|
||||
|
||||
## Security Reporting
|
||||
|
||||
Report vulnerabilities to **security@openclaw.ai** with:
|
||||
|
||||
- Severity assessment
|
||||
- Technical reproduction steps
|
||||
- Suggested remediation
|
||||
|
||||
See [`docs/security.md`](docs/security.md) for moderation and upload gating details.
|
||||
|
||||
## Reading Order for New Contributors
|
||||
|
||||
1. This file (local setup)
|
||||
2. [`docs/quickstart.md`](docs/quickstart.md) — end-to-end workflows
|
||||
3. [`docs/architecture.md`](docs/architecture.md) — system design
|
||||
4. [`docs/skill-format.md`](docs/skill-format.md) — skill structure
|
||||
5. [`docs/cli.md`](docs/cli.md) — CLI reference
|
||||
6. [`docs/http-api.md`](docs/http-api.md) — HTTP endpoints
|
||||
7. [`docs/auth.md`](docs/auth.md) — authentication
|
||||
8. [`docs/deploy.md`](docs/deploy.md) — deployment
|
||||
9. [`docs/troubleshooting.md`](docs/troubleshooting.md) — common issues
|
||||
@@ -1,4 +1,8 @@
|
||||
# ClawHub
|
||||
<p align="center">
|
||||
<img src="public/clawd-logo.png" alt="ClawHub" width="120">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ClawHub</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/clawhub/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/clawhub/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
@@ -7,12 +11,18 @@
|
||||
</p>
|
||||
|
||||
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
It’s designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
|
||||
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
|
||||
|
||||
Live: `https://clawhub.ai`
|
||||
onlycrabs.ai: `https://onlycrabs.ai`
|
||||
<p align="center">
|
||||
<a href="https://clawhub.ai">ClawHub</a> ·
|
||||
<a href="https://onlycrabs.ai">onlycrabs.ai</a> ·
|
||||
<a href="VISION.md">Vision</a> ·
|
||||
<a href="docs/README.md">Docs</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="https://discord.gg/clawd">Discord</a>
|
||||
</p>
|
||||
|
||||
## What you can do with it
|
||||
|
||||
@@ -37,6 +47,25 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
|
||||
- API schema + routes: `packages/schema` (`clawhub-schema`).
|
||||
|
||||
## CLI
|
||||
|
||||
Common CLI flows:
|
||||
|
||||
- Auth: `clawhub login`, `clawhub whoami`
|
||||
- Discover: `clawhub search ...`, `clawhub explore`
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync: `clawhub publish <path>`, `clawhub sync`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
### Removal permissions
|
||||
|
||||
- `clawhub uninstall <slug>` only removes a local install on your machine.
|
||||
- Uploaded registry skills use soft-delete/restore (`clawhub delete <slug>` / `clawhub undelete <slug>` or API equivalents).
|
||||
- Soft-delete/restore is allowed for the skill owner, moderators, and admins.
|
||||
- Hard delete is admin-only (management tools / ban flows).
|
||||
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -47,39 +76,36 @@ Disable via:
|
||||
export CLAWHUB_DISABLE_TELEMETRY=1
|
||||
```
|
||||
|
||||
Details: `docs/telemetry.md`.
|
||||
Details: [`docs/telemetry.md`](docs/telemetry.md).
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `src/` — TanStack Start app (routes, components, styles).
|
||||
- `convex/` — schema + queries/mutations/actions + HTTP API routes.
|
||||
- `packages/schema/` — shared API types/routes for the CLI and app.
|
||||
- `docs/spec.md` — product + implementation spec (good first read).
|
||||
- [`docs/`](docs/README.md) — project documentation (architecture, CLI, auth, deployment, and more).
|
||||
- [`docs/spec.md`](docs/spec.md) — product + implementation spec (good first read).
|
||||
|
||||
## Local dev
|
||||
|
||||
Prereqs: Bun + Convex CLI.
|
||||
Prereqs: [Bun](https://bun.sh/) (Convex runs via `bunx`, no global install needed).
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.local.example .env.local
|
||||
# edit .env.local — see CONTRIBUTING.md for local Convex values
|
||||
|
||||
# terminal A: web app
|
||||
# terminal A: local Convex backend
|
||||
bunx convex dev
|
||||
|
||||
# terminal B: web app (port 3000)
|
||||
bun run dev
|
||||
|
||||
# terminal B: Convex dev deployment
|
||||
bunx convex dev
|
||||
# seed sample data
|
||||
bunx convex run --no-push devSeed:seedNixSkills
|
||||
```
|
||||
|
||||
## Auth (GitHub OAuth) setup
|
||||
|
||||
Create a GitHub OAuth App, set `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`, then:
|
||||
|
||||
```bash
|
||||
bunx auth --deployment-name <deployment> --web-server-url http://localhost:3000
|
||||
```
|
||||
|
||||
This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for your local `.env.local`.
|
||||
For full setup instructions (env vars, GitHub OAuth, JWT keys, database seeding), see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
## OpenClaw Vision
|
||||
|
||||
OpenClaw is the AI that actually does things.
|
||||
It runs on your devices, in your channels, with your rules.
|
||||
|
||||
This document explains the current state and direction of the project.
|
||||
We are still early, so iteration is fast.
|
||||
Project overview and developer docs: [`README.md`](README.md)
|
||||
|
||||
OpenClaw started as my personal playground to learn AI and build something genuinely useful:
|
||||
an assistant that can run real tasks on my computer.
|
||||
It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw.
|
||||
|
||||
The goal? A personal assistant that's easy to use, supports a wide range of platforms, and respects your privacy and security.
|
||||
|
||||
The current focus is:
|
||||
|
||||
Priority:
|
||||
- Security and safe defaults
|
||||
- Bug fixes and stability
|
||||
- Setup reliability and first-run UX
|
||||
|
||||
Next priorities:
|
||||
- Supporting all major model providers
|
||||
- Improving support for major messaging channels (and adding a few high-demand ones)
|
||||
- Performance and test infrastructure
|
||||
- Better computer-use and agent harness capabilities
|
||||
- Ergonomics across CLI and web frontend
|
||||
- Companion apps on macOS, iOS, Android, Windows, and Linux
|
||||
|
||||
## Security
|
||||
|
||||
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
|
||||
The goal is to stay powerful for real work while making risky paths explicit and operator-controlled.
|
||||
|
||||
Canonical security policy and reporting:
|
||||
- https://github.com/openclaw/openclaw/blob/main/SECURITY.md
|
||||
|
||||
We prioritize secure defaults, but we also expose clear knobs for trusted high-power workflows.
|
||||
|
||||
## Plugins & Memory
|
||||
|
||||
OpenClaw has an extensive plugin API.
|
||||
Core stays lean; optional capability should usually ship as plugins.
|
||||
|
||||
Preferred plugin path is npm package distribution plus local extension loading for development.
|
||||
If you build a plugin, please host and maintain it in your own repository.
|
||||
The bar for adding optional plugins to core is intentionally high.
|
||||
|
||||
Memory is a special plugin slot where only one memory plugin can be active at a time.
|
||||
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
|
||||
|
||||
### Skills
|
||||
|
||||
We still ship some bundled skills for baseline UX.
|
||||
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
|
||||
Core skill additions should be rare and require a strong product or security reason.
|
||||
|
||||
### MCP Support
|
||||
|
||||
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
|
||||
|
||||
This keeps MCP integration flexible and decoupled from core runtime:
|
||||
- add or change MCP servers without restarting the gateway
|
||||
- keep core tool/context surface lean
|
||||
- reduce MCP churn impact on core stability and security
|
||||
|
||||
For now, we prefer this bridge model over building first-class MCP runtime into core.
|
||||
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
|
||||
|
||||
### Setup
|
||||
|
||||
OpenClaw is currently terminal-first by design.
|
||||
This keeps setup explicit: users see docs, auth, permissions, and security posture up front.
|
||||
|
||||
Long term, we want easier onboarding flows as hardening matures.
|
||||
We do not want convenience wrappers that hide critical security decisions from users.
|
||||
|
||||
### Why TypeScript?
|
||||
|
||||
OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations.
|
||||
TypeScript was chosen to keep OpenClaw hackable by default.
|
||||
It is widely known, fast to iterate in, and easy to read, modify, and extend.
|
||||
|
||||
## What We Will Not Merge (For Now)
|
||||
|
||||
- New core skills when they can live on ClawHub
|
||||
- Commercial service integrations that do not clearly fit the model-provider category
|
||||
- Wrapper channels around already supported channels without a clear capability or security gap
|
||||
- First-class MCP runtime in core when `mcporter` already provides the integration path
|
||||
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
|
||||
|
||||
This list is a roadmap guardrail, not a law of physics.
|
||||
Strong user demand and strong technical rationale can change it.
|
||||
@@ -63,7 +63,7 @@
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"bin": {
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
|
||||
Vendored
+10
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as commentModeration from "../commentModeration.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as devSeed from "../devSeed.js";
|
||||
@@ -38,6 +39,7 @@ 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_commentScamPrompt from "../lib/commentScamPrompt.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";
|
||||
@@ -48,11 +50,14 @@ 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_globalStats from "../lib/globalStats.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_reporting from "../lib/reporting.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";
|
||||
@@ -98,6 +103,7 @@ import type {
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
commentModeration: typeof commentModeration;
|
||||
comments: typeof comments;
|
||||
crons: typeof crons;
|
||||
devSeed: typeof devSeed;
|
||||
@@ -127,6 +133,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/badges": typeof lib_badges;
|
||||
"lib/batching": typeof lib_batching;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
"lib/commentScamPrompt": typeof lib_commentScamPrompt;
|
||||
"lib/contentTypes": typeof lib_contentTypes;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
@@ -137,11 +144,14 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/globalStats": typeof lib_globalStats;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/reporting": typeof lib_reporting;
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./_generated/api', () => ({
|
||||
internal: {
|
||||
commentModeration: {
|
||||
getCommentScamBackfillPageInternal: Symbol('commentModeration.getCommentScamBackfillPageInternal'),
|
||||
applyCommentScamResultInternal: Symbol('commentModeration.applyCommentScamResultInternal'),
|
||||
backfillCommentScamModerationInternal: Symbol('commentModeration.backfillCommentScamModerationInternal'),
|
||||
continueCommentScamModerationJobInternal: Symbol(
|
||||
'commentModeration.continueCommentScamModerationJobInternal',
|
||||
),
|
||||
},
|
||||
llmEval: {
|
||||
evaluateCommentForScam: Symbol('llmEval.evaluateCommentForScam'),
|
||||
},
|
||||
users: {
|
||||
banUserInternal: Symbol('users.banUserInternal'),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const {
|
||||
applyCommentScamResultInternalHandler,
|
||||
backfillCommentScamModerationInternalHandler,
|
||||
} = await import('./commentModeration')
|
||||
const { internal } = await import('./_generated/api')
|
||||
|
||||
const previousOpenAiApiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OPENAI_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousOpenAiApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
return
|
||||
}
|
||||
process.env.OPENAI_API_KEY = previousOpenAiApiKey
|
||||
})
|
||||
|
||||
describe('commentModeration backfill', () => {
|
||||
it('evaluates comments and bans on certain/high scams', async () => {
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
body: 'echo "mal" | base64 -D | bash',
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: undefined,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
})
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
model: 'gpt-5-mini',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Obfuscated shell execution payload.',
|
||||
evidence: ['base64 decode piped to bash'],
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
shouldBan: true,
|
||||
banned: true,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
})
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.stats.commentsScanned).toBe(1)
|
||||
expect(result.stats.commentsEvaluated).toBe(1)
|
||||
expect(result.stats.certainScams).toBe(1)
|
||||
expect(result.stats.banCandidates).toBe(1)
|
||||
expect(result.stats.usersBanned).toBe(1)
|
||||
expect(runAction).toHaveBeenCalledWith(internal.llmEval.evaluateCommentForScam, {
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
body: 'echo "mal" | base64 -D | bash',
|
||||
})
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.commentModeration.applyCommentScamResultInternal, {
|
||||
actorUserId: 'users:admin',
|
||||
commentId: 'comments:1',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Obfuscated shell execution payload.',
|
||||
evidence: ['base64 decode piped to bash'],
|
||||
model: 'gpt-5-mini',
|
||||
checkedAt: expect.any(Number),
|
||||
dryRun: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips previously scanned comments unless rescan=true', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
body: 'something',
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: 123,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
})
|
||||
const runAction = vi.fn()
|
||||
const runMutation = vi.fn()
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result.stats.commentsScanned).toBe(1)
|
||||
expect(result.stats.skippedAlreadyScanned).toBe(1)
|
||||
expect(runAction).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks dry-run ban candidates without banning', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
commentId: 'comments:9',
|
||||
skillId: 'skills:7',
|
||||
userId: 'users:5',
|
||||
body: 'run this update installer from random domain',
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: undefined,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
})
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
model: 'gpt-5-mini',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Social-engineering install command.',
|
||||
evidence: ['unknown update domain'],
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
shouldBan: true,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: true,
|
||||
})
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
dryRun: true,
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result.stats.usersBanned).toBe(0)
|
||||
expect(result.stats.usersWouldBeBanned).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCommentScamResultInternalHandler', () => {
|
||||
it('persists scan metadata and triggers ban with bounded reason', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:2',
|
||||
role: 'user',
|
||||
})
|
||||
const patch = vi.fn()
|
||||
const insert = vi.fn()
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
|
||||
const result = await applyCommentScamResultInternalHandler(
|
||||
{ db: { get, patch, insert }, runMutation } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
commentId: 'comments:1',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'X'.repeat(700),
|
||||
evidence: ['Y'.repeat(280), 'Z'.repeat(280)],
|
||||
model: 'gpt-5-mini',
|
||||
checkedAt: 123,
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result.banned).toBe(true)
|
||||
expect(insert).toHaveBeenCalledWith('auditLogs', {
|
||||
actorUserId: 'users:admin',
|
||||
action: 'comment.scam_scan',
|
||||
targetType: 'comment',
|
||||
targetId: 'comments:1',
|
||||
metadata: {
|
||||
skillId: 'skills:1',
|
||||
commentAuthorId: 'users:2',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
shouldBan: true,
|
||||
model: 'gpt-5-mini',
|
||||
},
|
||||
createdAt: 123,
|
||||
})
|
||||
|
||||
const banCall = runMutation.mock.calls.find(
|
||||
(call) => call[0] === internal.users.banUserInternal,
|
||||
)
|
||||
expect(banCall).toBeTruthy()
|
||||
if (!banCall) throw new Error('Expected ban mutation to be called')
|
||||
expect((banCall[1] as { reason: string }).reason.length).toBeLessThanOrEqual(500)
|
||||
expect(patch).toHaveBeenCalledWith('comments:1', {
|
||||
scamBanTriggeredAt: 123,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips banning moderator/admin accounts', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'comments:2',
|
||||
skillId: 'skills:2',
|
||||
userId: 'users:staff',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:staff',
|
||||
role: 'moderator',
|
||||
})
|
||||
const patch = vi.fn()
|
||||
const insert = vi.fn()
|
||||
const runMutation = vi.fn()
|
||||
|
||||
const result = await applyCommentScamResultInternalHandler(
|
||||
{ db: { get, patch, insert }, runMutation } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
commentId: 'comments:2',
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Malicious command spam.',
|
||||
evidence: ['base64|bash'],
|
||||
model: 'gpt-5-mini',
|
||||
checkedAt: 300,
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result.protectedRole).toBe(true)
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,465 @@
|
||||
import { ConvexError, v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import type { ActionCtx, MutationCtx } from './_generated/server'
|
||||
import { action, internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
import { assertRole, requireUserFromAction } from './lib/access'
|
||||
import {
|
||||
buildCommentScamBanReason,
|
||||
isCertainScam,
|
||||
type CommentScamConfidence,
|
||||
type CommentScamVerdict,
|
||||
} from './lib/commentScamPrompt'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 25
|
||||
const MAX_BATCH_SIZE = 100
|
||||
const DEFAULT_MAX_BATCHES = 10
|
||||
const MAX_MAX_BATCHES = 200
|
||||
|
||||
type CommentBackfillPageItem = {
|
||||
commentId: Id<'comments'>
|
||||
skillId: Id<'skills'>
|
||||
userId: Id<'users'>
|
||||
body: string
|
||||
softDeletedAt?: number
|
||||
scamScanCheckedAt?: number
|
||||
}
|
||||
|
||||
type CommentBackfillPageResult = {
|
||||
items: CommentBackfillPageItem[]
|
||||
cursor: string | null
|
||||
isDone: boolean
|
||||
}
|
||||
|
||||
type ApplyCommentScamResult = {
|
||||
ok: true
|
||||
shouldBan: boolean
|
||||
banned: boolean
|
||||
alreadyBanned: boolean
|
||||
protectedRole: boolean
|
||||
wouldBan: boolean
|
||||
}
|
||||
|
||||
export type CommentScamBackfillStats = {
|
||||
commentsScanned: number
|
||||
commentsEvaluated: number
|
||||
certainScams: number
|
||||
banCandidates: number
|
||||
usersBanned: number
|
||||
usersAlreadyBanned: number
|
||||
usersWouldBeBanned: number
|
||||
protectedRoleSkips: number
|
||||
skippedSoftDeleted: number
|
||||
skippedAlreadyScanned: number
|
||||
skippedEmptyBody: number
|
||||
evalErrors: number
|
||||
}
|
||||
|
||||
export type CommentScamBackfillActionArgs = {
|
||||
actorUserId: Id<'users'>
|
||||
dryRun?: boolean
|
||||
batchSize?: number
|
||||
maxBatches?: number
|
||||
cursor?: string
|
||||
rescan?: boolean
|
||||
includeSoftDeleted?: boolean
|
||||
}
|
||||
|
||||
export type CommentScamBackfillActionResult = {
|
||||
ok: true
|
||||
stats: CommentScamBackfillStats
|
||||
isDone: boolean
|
||||
cursor: string | null
|
||||
}
|
||||
|
||||
export const getCommentScamBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CommentBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('comments')
|
||||
.order('asc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
return {
|
||||
items: page.map((comment) => ({
|
||||
commentId: comment._id,
|
||||
skillId: comment.skillId,
|
||||
userId: comment.userId,
|
||||
body: comment.body,
|
||||
softDeletedAt: comment.softDeletedAt,
|
||||
scamScanCheckedAt: comment.scamScanCheckedAt,
|
||||
})),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export async function applyCommentScamResultInternalHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<'users'>
|
||||
commentId: Id<'comments'>
|
||||
verdict: CommentScamVerdict
|
||||
confidence: CommentScamConfidence
|
||||
explanation: string
|
||||
evidence: string[]
|
||||
model: string
|
||||
checkedAt: number
|
||||
dryRun?: boolean
|
||||
},
|
||||
): Promise<ApplyCommentScamResult> {
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) {
|
||||
throw new ConvexError('Comment not found')
|
||||
}
|
||||
|
||||
const user = await ctx.db.get(comment.userId)
|
||||
if (!user) {
|
||||
throw new ConvexError('Comment author not found')
|
||||
}
|
||||
|
||||
const dryRun = Boolean(args.dryRun)
|
||||
const shouldBan = isCertainScam({
|
||||
verdict: args.verdict,
|
||||
confidence: args.confidence,
|
||||
})
|
||||
|
||||
const explanation = args.explanation.trim().slice(0, 1200)
|
||||
const evidence = args.evidence
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
|
||||
if (!dryRun) {
|
||||
await ctx.db.patch(comment._id, {
|
||||
scamScanVerdict: args.verdict,
|
||||
scamScanConfidence: args.confidence,
|
||||
scamScanExplanation: explanation,
|
||||
scamScanEvidence: evidence,
|
||||
scamScanModel: args.model,
|
||||
scamScanCheckedAt: args.checkedAt,
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: 'comment.scam_scan',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: {
|
||||
skillId: comment.skillId,
|
||||
commentAuthorId: comment.userId,
|
||||
verdict: args.verdict,
|
||||
confidence: args.confidence,
|
||||
shouldBan,
|
||||
model: args.model,
|
||||
},
|
||||
createdAt: args.checkedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (!shouldBan) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (user.role === 'admin' || user.role === 'moderator') {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: true,
|
||||
wouldBan: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (user.deletedAt || user.deactivatedAt) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: true,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: true,
|
||||
}
|
||||
}
|
||||
|
||||
const reason = buildCommentScamBanReason({
|
||||
commentId: String(comment._id),
|
||||
skillId: String(comment.skillId),
|
||||
explanation,
|
||||
evidence,
|
||||
})
|
||||
|
||||
const banResult = await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
targetUserId: comment.userId,
|
||||
reason,
|
||||
})
|
||||
|
||||
if (!banResult.alreadyBanned) {
|
||||
await ctx.db.patch(comment._id, {
|
||||
scamBanTriggeredAt: args.checkedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: !banResult.alreadyBanned,
|
||||
alreadyBanned: Boolean(banResult.alreadyBanned),
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const applyCommentScamResultInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
commentId: v.id('comments'),
|
||||
verdict: v.union(v.literal('not_scam'), v.literal('likely_scam'), v.literal('certain_scam')),
|
||||
confidence: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
|
||||
explanation: v.string(),
|
||||
evidence: v.array(v.string()),
|
||||
model: v.string(),
|
||||
checkedAt: v.number(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: applyCommentScamResultInternalHandler,
|
||||
})
|
||||
|
||||
export async function backfillCommentScamModerationInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: CommentScamBackfillActionArgs,
|
||||
): Promise<CommentScamBackfillActionResult> {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new ConvexError('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
const dryRun = Boolean(args.dryRun)
|
||||
const rescan = Boolean(args.rescan)
|
||||
const includeSoftDeleted = Boolean(args.includeSoftDeleted)
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
|
||||
let cursor: string | null = args.cursor ?? null
|
||||
let isDone = false
|
||||
|
||||
const stats: CommentScamBackfillStats = {
|
||||
commentsScanned: 0,
|
||||
commentsEvaluated: 0,
|
||||
certainScams: 0,
|
||||
banCandidates: 0,
|
||||
usersBanned: 0,
|
||||
usersAlreadyBanned: 0,
|
||||
usersWouldBeBanned: 0,
|
||||
protectedRoleSkips: 0,
|
||||
skippedSoftDeleted: 0,
|
||||
skippedAlreadyScanned: 0,
|
||||
skippedEmptyBody: 0,
|
||||
evalErrors: 0,
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const page = (await ctx.runQuery(internal.commentModeration.getCommentScamBackfillPageInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
})) as CommentBackfillPageResult
|
||||
|
||||
cursor = page.cursor
|
||||
isDone = page.isDone
|
||||
|
||||
for (const comment of page.items) {
|
||||
stats.commentsScanned++
|
||||
|
||||
if (!includeSoftDeleted && comment.softDeletedAt) {
|
||||
stats.skippedSoftDeleted++
|
||||
continue
|
||||
}
|
||||
if (!rescan && comment.scamScanCheckedAt) {
|
||||
stats.skippedAlreadyScanned++
|
||||
continue
|
||||
}
|
||||
|
||||
const body = comment.body.trim()
|
||||
if (!body) {
|
||||
stats.skippedEmptyBody++
|
||||
continue
|
||||
}
|
||||
|
||||
const evalResult = (await ctx.runAction(internal.llmEval.evaluateCommentForScam, {
|
||||
commentId: comment.commentId,
|
||||
skillId: comment.skillId,
|
||||
userId: comment.userId,
|
||||
body,
|
||||
})) as
|
||||
| {
|
||||
ok: true
|
||||
model: string
|
||||
verdict: CommentScamVerdict
|
||||
confidence: CommentScamConfidence
|
||||
explanation: string
|
||||
evidence: string[]
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
if (!evalResult.ok) {
|
||||
stats.evalErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
stats.commentsEvaluated++
|
||||
const shouldBan = isCertainScam(evalResult)
|
||||
if (evalResult.verdict === 'certain_scam') {
|
||||
stats.certainScams++
|
||||
}
|
||||
if (shouldBan) {
|
||||
stats.banCandidates++
|
||||
}
|
||||
|
||||
const applyResult = (await ctx.runMutation(internal.commentModeration.applyCommentScamResultInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
commentId: comment.commentId,
|
||||
verdict: evalResult.verdict,
|
||||
confidence: evalResult.confidence,
|
||||
explanation: evalResult.explanation,
|
||||
evidence: evalResult.evidence,
|
||||
model: evalResult.model,
|
||||
checkedAt: Date.now(),
|
||||
dryRun,
|
||||
})) as ApplyCommentScamResult
|
||||
|
||||
if (applyResult.banned) stats.usersBanned++
|
||||
if (applyResult.alreadyBanned) stats.usersAlreadyBanned++
|
||||
if (applyResult.wouldBan) stats.usersWouldBeBanned++
|
||||
if (applyResult.protectedRole) stats.protectedRoleSkips++
|
||||
}
|
||||
|
||||
if (isDone) break
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
stats,
|
||||
isDone,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
export const backfillCommentScamModerationInternal = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: backfillCommentScamModerationInternalHandler,
|
||||
})
|
||||
|
||||
export const backfillCommentScamModeration: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CommentScamBackfillActionResult> => {
|
||||
const { user } = await requireUserFromAction(ctx)
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
|
||||
return ctx.runAction(internal.commentModeration.backfillCommentScamModerationInternal, {
|
||||
actorUserId: user._id,
|
||||
...args,
|
||||
}) as Promise<CommentScamBackfillActionResult>
|
||||
},
|
||||
})
|
||||
|
||||
export const continueCommentScamModerationJobInternal = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const result = await backfillCommentScamModerationInternalHandler(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
dryRun: args.dryRun,
|
||||
batchSize: args.batchSize,
|
||||
cursor: args.cursor,
|
||||
maxBatches: 1,
|
||||
rescan: args.rescan,
|
||||
includeSoftDeleted: args.includeSoftDeleted,
|
||||
})
|
||||
|
||||
if (!result.isDone && result.cursor) {
|
||||
await ctx.scheduler.runAfter(2_000, internal.commentModeration.continueCommentScamModerationJobInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: args.batchSize ?? DEFAULT_BATCH_SIZE,
|
||||
cursor: result.cursor,
|
||||
rescan: Boolean(args.rescan),
|
||||
includeSoftDeleted: Boolean(args.includeSoftDeleted),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
export const scheduleCommentScamModeration: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ ok: true }> => {
|
||||
const { user } = await requireUserFromAction(ctx)
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.commentModeration.continueCommentScamModerationJobInternal, {
|
||||
actorUserId: user._id,
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE),
|
||||
cursor: undefined,
|
||||
rescan: Boolean(args.rescan),
|
||||
includeSoftDeleted: Boolean(args.includeSoftDeleted),
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { assertModerator, requireUser } from './lib/access'
|
||||
import { requireGitHubAccountAge } from './lib/githubAccount'
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
MAX_REPORT_REASON_LENGTH,
|
||||
} from './lib/reporting'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export async function addHandler(ctx: MutationCtx, args: { skillId: Id<'skills'>; body: string }) {
|
||||
const { userId } = await requireUser(ctx)
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
@@ -50,3 +58,94 @@ export async function removeHandler(ctx: MutationCtx, args: { commentId: Id<'com
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<'users'>) {
|
||||
const reports = await ctx.db
|
||||
.query('commentReports')
|
||||
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||
.collect()
|
||||
|
||||
let count = 0
|
||||
for (const report of reports) {
|
||||
const comment = await ctx.db.get(report.commentId)
|
||||
if (!comment || comment.softDeletedAt) continue
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === 'removed') continue
|
||||
const owner = await ctx.db.get(comment.userId)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) continue
|
||||
count += 1
|
||||
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export async function reportHandler(
|
||||
ctx: MutationCtx,
|
||||
args: { commentId: Id<'comments'>; reason: string },
|
||||
) {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment || comment.softDeletedAt) {
|
||||
throw new Error('Comment not found')
|
||||
}
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === 'removed') {
|
||||
throw new Error('Comment not found')
|
||||
}
|
||||
|
||||
const reason = args.reason.trim()
|
||||
if (!reason) {
|
||||
throw new Error('Report reason required.')
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query('commentReports')
|
||||
.withIndex('by_comment_user', (q) => q.eq('commentId', args.commentId).eq('userId', userId))
|
||||
.unique()
|
||||
if (existing) return { ok: true as const, reported: false, alreadyReported: true }
|
||||
|
||||
const activeReports = await countActiveReportsForUser(ctx, userId)
|
||||
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
|
||||
throw new Error('Report limit reached. Please wait for moderation before reporting more.')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.insert('commentReports', {
|
||||
commentId: args.commentId,
|
||||
skillId: comment.skillId,
|
||||
userId,
|
||||
reason: reason.slice(0, MAX_REPORT_REASON_LENGTH),
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
const nextReportCount = (comment.reportCount ?? 0) + 1
|
||||
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !comment.softDeletedAt
|
||||
const updates: {
|
||||
reportCount: number
|
||||
lastReportedAt: number
|
||||
softDeletedAt?: number
|
||||
} = {
|
||||
reportCount: nextReportCount,
|
||||
lastReportedAt: now,
|
||||
}
|
||||
if (shouldAutoHide) {
|
||||
updates.softDeletedAt = now
|
||||
}
|
||||
await ctx.db.patch(comment._id, updates)
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' })
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: userId,
|
||||
action: 'comment.auto_hide',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId, reportCount: nextReportCount },
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true as const, reported: true, alreadyReported: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { listBySkill } from './comments'
|
||||
|
||||
function makeCtx(args: {
|
||||
comments: Array<Record<string, unknown>>
|
||||
usersById: Record<string, Record<string, unknown> | null>
|
||||
}) {
|
||||
const get = async (id: string) => args.usersById[id] ?? null
|
||||
const take = async () => args.comments
|
||||
const order = () => ({ take })
|
||||
const withIndex = () => ({ order })
|
||||
const query = () => ({ withIndex })
|
||||
return { db: { get, query } } as never
|
||||
}
|
||||
|
||||
describe('comments.listBySkill', () => {
|
||||
it('skips soft-deleted comments', async () => {
|
||||
const ctx = makeCtx({
|
||||
comments: [
|
||||
{
|
||||
_id: 'comments:live',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:live',
|
||||
body: 'hello',
|
||||
},
|
||||
{
|
||||
_id: 'comments:deleted',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:live',
|
||||
body: 'bye',
|
||||
softDeletedAt: 123,
|
||||
},
|
||||
],
|
||||
usersById: {
|
||||
'users:live': {
|
||||
_id: 'users:live',
|
||||
_creationTime: 1,
|
||||
handle: 'live',
|
||||
name: 'live',
|
||||
displayName: 'Live',
|
||||
image: null,
|
||||
bio: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await listBySkill._handler(ctx, {
|
||||
skillId: 'skills:1',
|
||||
limit: 50,
|
||||
} as never)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.comment._id).toBe('comments:live')
|
||||
})
|
||||
|
||||
it('skips comments whose author is deleted/deactivated/missing', async () => {
|
||||
const ctx = makeCtx({
|
||||
comments: [
|
||||
{
|
||||
_id: 'comments:ok',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:ok',
|
||||
body: 'ok',
|
||||
},
|
||||
{
|
||||
_id: 'comments:deleted-user',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:deleted',
|
||||
body: 'hidden',
|
||||
},
|
||||
{
|
||||
_id: 'comments:deactivated-user',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:deactivated',
|
||||
body: 'hidden',
|
||||
},
|
||||
{
|
||||
_id: 'comments:missing-user',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:missing',
|
||||
body: 'hidden',
|
||||
},
|
||||
],
|
||||
usersById: {
|
||||
'users:ok': {
|
||||
_id: 'users:ok',
|
||||
_creationTime: 1,
|
||||
handle: 'ok',
|
||||
name: 'ok',
|
||||
displayName: 'Ok',
|
||||
image: null,
|
||||
bio: null,
|
||||
},
|
||||
'users:deleted': {
|
||||
_id: 'users:deleted',
|
||||
_creationTime: 1,
|
||||
handle: 'deleted',
|
||||
name: 'deleted',
|
||||
displayName: 'Deleted',
|
||||
image: null,
|
||||
bio: null,
|
||||
deletedAt: 123,
|
||||
},
|
||||
'users:deactivated': {
|
||||
_id: 'users:deactivated',
|
||||
_creationTime: 1,
|
||||
handle: 'deactivated',
|
||||
name: 'deactivated',
|
||||
displayName: 'Deactivated',
|
||||
image: null,
|
||||
bio: null,
|
||||
deactivatedAt: 456,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await listBySkill._handler(ctx, {
|
||||
skillId: 'skills:1',
|
||||
limit: 50,
|
||||
} as never)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.comment._id).toBe('comments:ok')
|
||||
expect(result[0]?.user._id).toBe('users:ok')
|
||||
})
|
||||
})
|
||||
+460
-1
@@ -10,15 +10,22 @@ vi.mock('./skillStatEvents', () => ({
|
||||
insertStatEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/githubAccount', () => ({
|
||||
requireGitHubAccountAge: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireUser, assertModerator } = await import('./lib/access')
|
||||
const { insertStatEvent } = await import('./skillStatEvents')
|
||||
const { addHandler, removeHandler } = await import('./comments.handlers')
|
||||
const { requireGitHubAccountAge } = await import('./lib/githubAccount')
|
||||
const { addHandler, removeHandler, reportHandler } = await import('./comments.handlers')
|
||||
|
||||
describe('comments mutations', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(assertModerator).mockReset()
|
||||
vi.mocked(requireUser).mockReset()
|
||||
vi.mocked(insertStatEvent).mockReset()
|
||||
vi.mocked(requireGitHubAccountAge).mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('add avoids direct skill patch and records stat event', async () => {
|
||||
@@ -26,6 +33,7 @@ describe('comments mutations', () => {
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
vi.mocked(requireGitHubAccountAge).mockResolvedValue(undefined as never)
|
||||
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
_id: 'skills:1',
|
||||
@@ -36,6 +44,7 @@ describe('comments mutations', () => {
|
||||
|
||||
await addHandler(ctx, { skillId: 'skills:1', body: ' hello ' } as never)
|
||||
|
||||
expect(requireGitHubAccountAge).toHaveBeenCalledWith(ctx, 'users:1')
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, {
|
||||
skillId: 'skills:1',
|
||||
@@ -43,6 +52,30 @@ describe('comments mutations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('add blocks new comments when github account age gate fails', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:new',
|
||||
user: { _id: 'users:new', role: 'user' },
|
||||
} as never)
|
||||
vi.mocked(requireGitHubAccountAge).mockRejectedValue(
|
||||
new Error('GitHub account must be at least 14 days old to upload skills. Try again in 3 days.'),
|
||||
)
|
||||
|
||||
const get = vi.fn()
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const ctx = { db: { get, insert, patch } } as never
|
||||
|
||||
await expect(addHandler(ctx, { skillId: 'skills:1', body: 'hello' } as never)).rejects.toThrow(
|
||||
/at least 14 days old/i,
|
||||
)
|
||||
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(insertStatEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('remove keeps comment soft-delete patch free of updatedAt', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:2',
|
||||
@@ -57,6 +90,9 @@ describe('comments mutations', () => {
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:1') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
@@ -124,4 +160,427 @@ describe('comments mutations', () => {
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(insertStatEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report increments count and stores reason', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 1,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:1') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === 'commentReports') {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') {
|
||||
return { unique: vi.fn().mockResolvedValue(null) }
|
||||
}
|
||||
if (index === 'by_user') {
|
||||
return { collect: vi.fn().mockResolvedValue([]) }
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`)
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
const result = await reportHandler(ctx, { commentId: 'comments:1', reason: ' spam ' } as never)
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false })
|
||||
expect(insert).toHaveBeenCalledWith('commentReports', {
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:1',
|
||||
reason: 'spam',
|
||||
createdAt: 1_700_000_000_000,
|
||||
})
|
||||
expect(patch).toHaveBeenCalledWith('comments:1', {
|
||||
reportCount: 2,
|
||||
lastReportedAt: 1_700_000_000_000,
|
||||
})
|
||||
expect(insertStatEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report returns alreadyReported for duplicate reporter/comment pair', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:dup',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:dup') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== 'commentReports') throw new Error(`Unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') {
|
||||
return { unique: vi.fn().mockResolvedValue({ _id: 'commentReports:existing' }) }
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
const result = await reportHandler(ctx, { commentId: 'comments:dup', reason: 'spam' } as never)
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: false, alreadyReported: true })
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report rejects empty reason', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:empty',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:empty') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn()
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: 'comments:empty', reason: ' ' } as never),
|
||||
).rejects.toThrow('Report reason required.')
|
||||
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report rejects comment when parent skill is hidden/removed', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:hidden-parent',
|
||||
skillId: 'skills:hidden',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:hidden-parent') return comment
|
||||
if (id === 'skills:hidden') {
|
||||
return { _id: 'skills:hidden', softDeletedAt: 123, moderationStatus: 'removed' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn()
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: 'comments:hidden-parent', reason: 'abuse' } as never),
|
||||
).rejects.toThrow('Comment not found')
|
||||
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report truncates long reason to 500 chars', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_050)
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:long',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:long') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== 'commentReports') throw new Error(`Unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') return { unique: vi.fn().mockResolvedValue(null) }
|
||||
if (index === 'by_user') return { collect: vi.fn().mockResolvedValue([]) }
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
await reportHandler(ctx, { commentId: 'comments:long', reason: 'x'.repeat(700) } as never)
|
||||
|
||||
const reportInsert = vi.mocked(insert).mock.calls.find((call) => call[0] === 'commentReports')
|
||||
expect(reportInsert?.[1]).toMatchObject({
|
||||
commentId: 'comments:long',
|
||||
reason: 'x'.repeat(500),
|
||||
})
|
||||
})
|
||||
|
||||
it('report active-count filter ignores stale/non-active report targets', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:target2',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const reports = [
|
||||
{ _id: 'commentReports:1', commentId: 'comments:deleted', userId: 'users:1', skillId: 'skills:1' },
|
||||
{ _id: 'commentReports:2', commentId: 'comments:removed-skill', userId: 'users:1', skillId: 'skills:removed' },
|
||||
{ _id: 'commentReports:3', commentId: 'comments:deleted-owner', userId: 'users:1', skillId: 'skills:active' },
|
||||
]
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:target2') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
if (id === 'comments:deleted') {
|
||||
return { _id: 'comments:deleted', softDeletedAt: 123, skillId: 'skills:1', userId: 'users:2' }
|
||||
}
|
||||
if (id === 'comments:removed-skill') {
|
||||
return {
|
||||
_id: 'comments:removed-skill',
|
||||
softDeletedAt: undefined,
|
||||
skillId: 'skills:removed',
|
||||
userId: 'users:2',
|
||||
}
|
||||
}
|
||||
if (id === 'skills:removed') {
|
||||
return { _id: 'skills:removed', softDeletedAt: undefined, moderationStatus: 'removed' }
|
||||
}
|
||||
if (id === 'comments:deleted-owner') {
|
||||
return {
|
||||
_id: 'comments:deleted-owner',
|
||||
softDeletedAt: undefined,
|
||||
skillId: 'skills:active',
|
||||
userId: 'users:deleted-owner',
|
||||
}
|
||||
}
|
||||
if (id === 'skills:active') {
|
||||
return { _id: 'skills:active', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
if (id === 'users:deleted-owner') {
|
||||
return { _id: 'users:deleted-owner', deletedAt: 1, deactivatedAt: undefined }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== 'commentReports') throw new Error(`Unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') return { unique: vi.fn().mockResolvedValue(null) }
|
||||
if (index === 'by_user') return { collect: vi.fn().mockResolvedValue(reports) }
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
const result = await reportHandler(
|
||||
ctx,
|
||||
{ commentId: 'comments:target2', reason: 'still allowed' } as never,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false })
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
'commentReports',
|
||||
expect.objectContaining({ commentId: 'comments:target2', userId: 'users:1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('report rejects when active report limit is reached', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:target',
|
||||
skillId: 'skills:1',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
}
|
||||
const reportedComment = {
|
||||
_id: 'comments:reported',
|
||||
skillId: 'skills:active',
|
||||
userId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
}
|
||||
const reports = Array.from({ length: 20 }, (_, i) => ({
|
||||
_id: `commentReports:${i + 1}`,
|
||||
commentId: `comments:reported-${i + 1}`,
|
||||
userId: 'users:1',
|
||||
skillId: 'skills:active',
|
||||
createdAt: i + 1,
|
||||
}))
|
||||
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:target') return comment
|
||||
if (id === 'skills:1') {
|
||||
return { _id: 'skills:1', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
if (String(id).startsWith('comments:reported-')) return reportedComment
|
||||
if (id === 'skills:active') {
|
||||
return { _id: 'skills:active', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
if (id === 'users:owner') {
|
||||
return { _id: 'users:owner', deletedAt: undefined, deactivatedAt: undefined }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === 'commentReports') {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') {
|
||||
return { unique: vi.fn().mockResolvedValue(null) }
|
||||
}
|
||||
if (index === 'by_user') {
|
||||
return { collect: vi.fn().mockResolvedValue(reports) }
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`)
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: 'comments:target', reason: 'abuse' } as never),
|
||||
).rejects.toThrow('Report limit reached. Please wait for moderation before reporting more.')
|
||||
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('report auto-hides comment after fourth unique report', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_100)
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:3',
|
||||
user: { _id: 'users:3', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const comment = {
|
||||
_id: 'comments:4',
|
||||
skillId: 'skills:9',
|
||||
userId: 'users:2',
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 3,
|
||||
}
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === 'comments:4') return comment
|
||||
if (id === 'skills:9') {
|
||||
return { _id: 'skills:9', softDeletedAt: undefined, moderationStatus: 'active' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === 'commentReports') {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_comment_user') {
|
||||
return { unique: vi.fn().mockResolvedValue(null) }
|
||||
}
|
||||
if (index === 'by_user') {
|
||||
return { collect: vi.fn().mockResolvedValue([]) }
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`)
|
||||
})
|
||||
const ctx = { db: { get, insert, patch, query } } as never
|
||||
|
||||
const result = await reportHandler(ctx, { commentId: 'comments:4', reason: ' hate ' } as never)
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false })
|
||||
expect(patch).toHaveBeenCalledWith('comments:4', {
|
||||
reportCount: 4,
|
||||
lastReportedAt: 1_700_000_000_100,
|
||||
softDeletedAt: 1_700_000_000_100,
|
||||
})
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, {
|
||||
skillId: 'skills:9',
|
||||
kind: 'uncomment',
|
||||
})
|
||||
expect(insert).toHaveBeenCalledWith('auditLogs', {
|
||||
actorUserId: 'users:3',
|
||||
action: 'comment.auto_hide',
|
||||
targetType: 'comment',
|
||||
targetId: 'comments:4',
|
||||
metadata: { skillId: 'skills:9', reportCount: 4 },
|
||||
createdAt: 1_700_000_000_100,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+14
-9
@@ -1,7 +1,7 @@
|
||||
import { v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { addHandler, removeHandler } from './comments.handlers'
|
||||
import { addHandler, removeHandler, reportHandler } from './comments.handlers'
|
||||
import { type PublicUser, toPublicUser } from './lib/public'
|
||||
|
||||
export const listBySkill = query({
|
||||
@@ -14,15 +14,15 @@ export const listBySkill = query({
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
|
||||
const visible = comments.filter((comment) => !comment.softDeletedAt)
|
||||
return Promise.all(
|
||||
visible.map(
|
||||
async (comment): Promise<{ comment: Doc<'comments'>; user: PublicUser | null }> => ({
|
||||
comment,
|
||||
user: toPublicUser(await ctx.db.get(comment.userId)),
|
||||
}),
|
||||
),
|
||||
const rows = await Promise.all(
|
||||
comments.map(async (comment): Promise<{ comment: Doc<'comments'>; user: PublicUser } | null> => {
|
||||
if (comment.softDeletedAt) return null
|
||||
const user = toPublicUser(await ctx.db.get(comment.userId))
|
||||
if (!user) return null
|
||||
return { comment, user }
|
||||
}),
|
||||
)
|
||||
return rows.filter((row): row is { comment: Doc<'comments'>; user: PublicUser } => row !== null)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -35,3 +35,8 @@ export const remove = mutation({
|
||||
args: { commentId: v.id('comments') },
|
||||
handler: removeHandler,
|
||||
})
|
||||
|
||||
export const report = mutation({
|
||||
args: { commentId: v.id('comments'), reason: v.string() },
|
||||
handler: reportHandler,
|
||||
})
|
||||
|
||||
+21
-2
@@ -19,18 +19,37 @@ crons.interval(
|
||||
|
||||
crons.interval(
|
||||
'skill-stats-backfill',
|
||||
{ minutes: 10 },
|
||||
{ hours: 6 },
|
||||
internal.statsMaintenance.runSkillStatBackfillInternal,
|
||||
{ batchSize: 200, maxBatches: 5 },
|
||||
)
|
||||
|
||||
// Runs frequently to keep dailyStats/trending accurate,
|
||||
// but does NOT patch skill documents (only writes to skillDailyStats).
|
||||
crons.interval(
|
||||
'skill-stat-events',
|
||||
{ minutes: 5 },
|
||||
{ minutes: 15 },
|
||||
internal.skillStatEvents.processSkillStatEventsAction,
|
||||
{},
|
||||
)
|
||||
|
||||
// Syncs accumulated stat deltas to skill documents every 6 hours.
|
||||
// Runs infrequently to avoid thundering-herd reactive query invalidation.
|
||||
// Uses processedAt field to track progress (independent of the action cursor).
|
||||
crons.interval(
|
||||
'skill-doc-stat-sync',
|
||||
{ hours: 6 },
|
||||
internal.skillStatEvents.processSkillStatEventsInternal,
|
||||
{ batchSize: 500 },
|
||||
)
|
||||
|
||||
crons.interval(
|
||||
'global-stats-update',
|
||||
{ minutes: 60 },
|
||||
internal.statsMaintenance.updateGlobalStatsInternal,
|
||||
{},
|
||||
)
|
||||
|
||||
crons.interval('vt-pending-scans', { minutes: 5 }, internal.vt.pollPendingScans, { batchSize: 100 })
|
||||
|
||||
crons.interval('vt-cache-backfill', { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
|
||||
|
||||
@@ -435,6 +435,7 @@ export const seedSkillMutation = internalMutation({
|
||||
visibility: 'latest-approved',
|
||||
updatedAt: now,
|
||||
})
|
||||
await ctx.db.insert('embeddingSkillMap', { embeddingId, skillId })
|
||||
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type SyncGitHubBackupsResult = {
|
||||
skillsScanned: number
|
||||
skillsSkipped: number
|
||||
skillsBackedUp: number
|
||||
skillsDeleted: number
|
||||
skillsMissingVersion: number
|
||||
skillsMissingOwner: number
|
||||
errors: number
|
||||
|
||||
@@ -7,9 +7,12 @@ import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction } from './_generated/server'
|
||||
import {
|
||||
backupSkillToGitHub,
|
||||
deleteGitHubSkillBackup,
|
||||
fetchGitHubSkillMeta,
|
||||
getGitHubBackupContext,
|
||||
isGitHubBackupConfigured,
|
||||
listGitHubSkillBackupEntries,
|
||||
normalizeOwner,
|
||||
} from './lib/githubBackup'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 50
|
||||
@@ -35,6 +38,7 @@ export type GitHubBackupSyncStats = {
|
||||
skillsScanned: number
|
||||
skillsSkipped: number
|
||||
skillsBackedUp: number
|
||||
skillsDeleted: number
|
||||
skillsMissingVersion: number
|
||||
skillsMissingOwner: number
|
||||
errors: number
|
||||
@@ -87,6 +91,7 @@ export async function syncGitHubBackupsInternalHandler(
|
||||
skillsScanned: 0,
|
||||
skillsSkipped: 0,
|
||||
skillsBackedUp: 0,
|
||||
skillsDeleted: 0,
|
||||
skillsMissingVersion: 0,
|
||||
skillsMissingOwner: 0,
|
||||
errors: 0,
|
||||
@@ -166,9 +171,69 @@ export async function syncGitHubBackupsInternalHandler(
|
||||
if (isDone) break
|
||||
}
|
||||
|
||||
await pruneDeletedSkillBackups(ctx, context, dryRun, stats)
|
||||
|
||||
return { stats, cursor, isDone }
|
||||
}
|
||||
|
||||
async function pruneDeletedSkillBackups(
|
||||
ctx: ActionCtx,
|
||||
context: Awaited<ReturnType<typeof getGitHubBackupContext>>,
|
||||
dryRun: boolean,
|
||||
stats: GitHubBackupSyncStats,
|
||||
) {
|
||||
let entries: Awaited<ReturnType<typeof listGitHubSkillBackupEntries>>
|
||||
try {
|
||||
entries = await listGitHubSkillBackupEntries(context)
|
||||
} catch (error) {
|
||||
console.error('GitHub backup cleanup list failed', error)
|
||||
stats.errors += 1
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
|
||||
slug: entry.slug,
|
||||
})) as Doc<'skills'> | null
|
||||
if (!skill || skill.softDeletedAt) {
|
||||
await deleteBackupIfNeeded(context, entry, dryRun, stats)
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
|
||||
userId: skill.ownerUserId,
|
||||
})) as Doc<'users'> | null
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
await deleteBackupIfNeeded(context, entry, dryRun, stats)
|
||||
continue
|
||||
}
|
||||
|
||||
const ownerHandle = normalizeOwner(owner.handle ?? owner._id)
|
||||
if (ownerHandle !== entry.owner) {
|
||||
await deleteBackupIfNeeded(context, entry, dryRun, stats)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GitHub backup cleanup failed', error)
|
||||
stats.errors += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackupIfNeeded(
|
||||
context: Awaited<ReturnType<typeof getGitHubBackupContext>>,
|
||||
entry: Awaited<ReturnType<typeof listGitHubSkillBackupEntries>>[number],
|
||||
dryRun: boolean,
|
||||
stats: GitHubBackupSyncStats,
|
||||
) {
|
||||
const result = dryRun
|
||||
? { deleted: true as const }
|
||||
: await deleteGitHubSkillBackup(context, entry.owner, entry.slug)
|
||||
if (result.deleted) {
|
||||
stats.skillsDeleted += 1
|
||||
}
|
||||
}
|
||||
|
||||
export const syncGitHubBackupsInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { __test } from './githubImport'
|
||||
import { buildGitHubZipForTests } from './lib/githubImport'
|
||||
|
||||
describe('githubImport', () => {
|
||||
it('formats storage failure message with file context', () => {
|
||||
const message = __test.buildStoreFailureMessage('skill/SKILL.md', 123, new Error('disk full'))
|
||||
expect(message).toBe('Failed to store file "skill/SKILL.md" (123 bytes). disk full')
|
||||
})
|
||||
|
||||
it('formats publish failure message with fallback text', () => {
|
||||
expect(__test.buildPublishFailureMessage(new Error('slug exists'))).toBe(
|
||||
'Import failed during publish: slug exists. Check skill format, slug availability, and try again.',
|
||||
)
|
||||
expect(__test.buildPublishFailureMessage('unexpected')).toBe(
|
||||
'Import failed during publish: unexpected. Check skill format, slug availability, and try again.',
|
||||
)
|
||||
})
|
||||
|
||||
it('filters mac junk files while unzipping archive entries', () => {
|
||||
const zip = buildGitHubZipForTests({
|
||||
'demo-repo/skill/SKILL.md': '# Demo',
|
||||
'demo-repo/skill/notes.md': 'notes',
|
||||
'demo-repo/skill/.DS_Store': 'junk',
|
||||
'demo-repo/skill/._notes.md': 'junk',
|
||||
'demo-repo/__MACOSX/._SKILL.md': 'junk',
|
||||
})
|
||||
|
||||
const entries = __test.unzipToEntries(zip)
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
'demo-repo/skill/SKILL.md',
|
||||
'demo-repo/skill/notes.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
+46
-26
@@ -20,7 +20,7 @@ import {
|
||||
suggestVersion,
|
||||
} from './lib/githubImport'
|
||||
import { publishVersionForUser } from './lib/skillPublish'
|
||||
import { sanitizePath } from './lib/skills'
|
||||
import { isMacJunkPath, sanitizePath } from './lib/skills'
|
||||
|
||||
const MAX_SELECTED_BYTES = 50 * 1024 * 1024
|
||||
const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024
|
||||
@@ -192,7 +192,12 @@ export const importGitHubSkill = action({
|
||||
|
||||
const sha256 = await sha256Hex(bytes)
|
||||
const safeBytes = new Uint8Array(bytes)
|
||||
const storageId = await ctx.storage.store(new Blob([safeBytes], { type: 'text/plain' }))
|
||||
let storageId: Id<'_storage'>
|
||||
try {
|
||||
storageId = await ctx.storage.store(new Blob([safeBytes], { type: 'text/plain' }))
|
||||
} catch (error) {
|
||||
throw new ConvexError(buildStoreFailureMessage(sanitized, bytes.byteLength, error))
|
||||
}
|
||||
storedFiles.push({
|
||||
path: sanitized,
|
||||
size: bytes.byteLength,
|
||||
@@ -213,23 +218,28 @@ export const importGitHubSkill = action({
|
||||
if (!displayName) throw new ConvexError('Display name required')
|
||||
if (!version || !semver.valid(version)) throw new ConvexError('Version must be valid semver')
|
||||
|
||||
const result = await publishVersionForUser(ctx, userId, {
|
||||
slug: slugBase,
|
||||
displayName,
|
||||
version,
|
||||
changelog: '',
|
||||
tags,
|
||||
files: storedFiles,
|
||||
source: {
|
||||
kind: 'github',
|
||||
url: resolved.originalUrl,
|
||||
repo: `${resolved.owner}/${resolved.repo}`,
|
||||
ref: resolved.ref,
|
||||
commit: resolved.commit,
|
||||
path: candidate.path,
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
let result: Awaited<ReturnType<typeof publishVersionForUser>>
|
||||
try {
|
||||
result = await publishVersionForUser(ctx, userId, {
|
||||
slug: slugBase,
|
||||
displayName,
|
||||
version,
|
||||
changelog: '',
|
||||
tags,
|
||||
files: storedFiles,
|
||||
source: {
|
||||
kind: 'github',
|
||||
url: resolved.originalUrl,
|
||||
repo: `${resolved.owner}/${resolved.repo}`,
|
||||
ref: resolved.ref,
|
||||
commit: resolved.commit,
|
||||
path: candidate.path,
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new ConvexError(buildPublishFailureMessage(error))
|
||||
}
|
||||
|
||||
return { ok: true, slug: slugBase, version, ...result }
|
||||
},
|
||||
@@ -244,7 +254,7 @@ function unzipToEntries(zipBytes: Uint8Array) {
|
||||
for (const [rawPath, bytes] of Object.entries(entries)) {
|
||||
const normalizedPath = normalizeZipPath(rawPath)
|
||||
if (!normalizedPath) continue
|
||||
if (isJunkPath(normalizedPath)) continue
|
||||
if (isMacJunkPath(normalizedPath)) continue
|
||||
if (!bytes) continue
|
||||
if (bytes.byteLength > MAX_SINGLE_FILE_BYTES) continue
|
||||
totalBytes += bytes.byteLength
|
||||
@@ -308,10 +318,20 @@ function normalizeZipPath(path: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isJunkPath(path: string) {
|
||||
const normalized = path.toLowerCase()
|
||||
if (normalized.startsWith('__macosx/')) return true
|
||||
if (normalized.endsWith('/.ds_store')) return true
|
||||
if (normalized === '.ds_store') return true
|
||||
return false
|
||||
function toErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function buildStoreFailureMessage(path: string, sizeBytes: number, error: unknown) {
|
||||
return `Failed to store file "${path}" (${sizeBytes} bytes). ${toErrorMessage(error)}`
|
||||
}
|
||||
|
||||
function buildPublishFailureMessage(error: unknown) {
|
||||
return `Import failed during publish: ${toErrorMessage(error)}. Check skill format, slug availability, and try again.`
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
buildPublishFailureMessage,
|
||||
buildStoreFailureMessage,
|
||||
unzipToEntries,
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('httpApiV1 handlers', () => {
|
||||
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 }
|
||||
return { ok: true, action: 'ownership_transferred' }
|
||||
})
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('handle' in args) return { _id: 'users:target' }
|
||||
@@ -194,12 +194,14 @@ describe('httpApiV1 handlers', () => {
|
||||
slug: 'a',
|
||||
rightfulOwnerUserId: 'users:target',
|
||||
reason: 'r',
|
||||
transferRootSlugOnly: true,
|
||||
})
|
||||
expect(reclaimCalls[1]?.[1]).toMatchObject({
|
||||
actorUserId: 'users:admin',
|
||||
slug: 'b',
|
||||
rightfulOwnerUserId: 'users:target',
|
||||
reason: 'r',
|
||||
transferRootSlugOnly: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -235,6 +237,19 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response.status).toBe(429)
|
||||
})
|
||||
|
||||
it('429 Retry-After is a relative delay, not an absolute epoch', async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(blockedRate())
|
||||
const response = await __handlers.searchSkillsV1Handler(
|
||||
makeCtx({ runAction: vi.fn(), runMutation }),
|
||||
new Request('https://example.com/api/v1/search?q=test'),
|
||||
)
|
||||
expect(response.status).toBe(429)
|
||||
const retryAfter = Number(response.headers.get('Retry-After'))
|
||||
// Retry-After must be a small relative delay (seconds), not a Unix epoch
|
||||
expect(retryAfter).toBeGreaterThanOrEqual(1)
|
||||
expect(retryAfter).toBeLessThanOrEqual(120)
|
||||
})
|
||||
|
||||
it('resolve validates hash', async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.resolveSkillVersionV1Handler(
|
||||
@@ -857,6 +872,50 @@ describe('httpApiV1 handlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('publish multipart ignores mac junk files', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
vi.mocked(publishVersionForUser).mockResolvedValueOnce({
|
||||
skillId: 's',
|
||||
versionId: 'v',
|
||||
embeddingId: 'e',
|
||||
} as never)
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const store = vi.fn().mockResolvedValue('storage:1')
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
version: '1.0.0',
|
||||
changelog: '',
|
||||
tags: ['latest'],
|
||||
}),
|
||||
)
|
||||
form.append('files', new Blob(['hello'], { type: 'text/plain' }), 'SKILL.md')
|
||||
form.append('files', new Blob(['junk'], { type: 'application/octet-stream' }), '.DS_Store')
|
||||
const response = await __handlers.publishSkillV1Handler(
|
||||
makeCtx({ runMutation, storage: { store } }),
|
||||
new Request('https://example.com/api/v1/skills', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
body: form,
|
||||
}),
|
||||
)
|
||||
if (response.status !== 200) {
|
||||
throw new Error(await response.text())
|
||||
}
|
||||
|
||||
expect(store).toHaveBeenCalledTimes(1)
|
||||
const publishArgs = vi.mocked(publishVersionForUser).mock.calls[0]?.[2] as
|
||||
| { files?: Array<{ path: string }> }
|
||||
| undefined
|
||||
expect(publishArgs?.files?.map((file) => file.path)).toEqual(['SKILL.md'])
|
||||
})
|
||||
|
||||
it('publish rejects missing token', async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.publishSkillV1Handler(
|
||||
@@ -1042,6 +1101,7 @@ describe('httpApiV1 handlers', () => {
|
||||
})
|
||||
|
||||
it('stars add succeeds', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
@@ -1050,7 +1110,6 @@ describe('httpApiV1 handlers', () => {
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, starred: true, alreadyStarred: false })
|
||||
const response = await __handlers.starsPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
@@ -1066,6 +1125,7 @@ describe('httpApiV1 handlers', () => {
|
||||
})
|
||||
|
||||
it('stars delete succeeds', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
@@ -1074,7 +1134,6 @@ describe('httpApiV1 handlers', () => {
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, unstarred: true, alreadyUnstarred: false })
|
||||
const response = await __handlers.starsDeleteRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionCtx } from '../_generated/server'
|
||||
import { assertAdmin } from '../lib/access'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { corsHeaders, mergeHeaders } from '../lib/httpHeaders'
|
||||
import { isMacJunkPath } from '../lib/skills'
|
||||
|
||||
export const MAX_RAW_FILE_BYTES = 200 * 1024
|
||||
|
||||
@@ -259,6 +260,7 @@ export async function parseMultipartPublish(
|
||||
const file = toFileLike(entry)
|
||||
if (!file) continue
|
||||
const path = file.name
|
||||
if (isMacJunkPath(path)) continue
|
||||
const size = file.size
|
||||
const contentType = file.type || undefined
|
||||
const buffer = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
@@ -41,7 +41,12 @@ type ListSkillsResult = {
|
||||
updatedAt: number
|
||||
latestVersionId?: Id<'skillVersions'>
|
||||
}
|
||||
latestVersion: { version: string; createdAt: number; changelog: string } | null
|
||||
latestVersion: {
|
||||
version: string
|
||||
createdAt: number
|
||||
changelog: string
|
||||
parsed?: { clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } } }
|
||||
} | null
|
||||
}>
|
||||
nextCursor: string | null
|
||||
}
|
||||
@@ -202,6 +207,12 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
metadata: item.latestVersion?.parsed?.clawdis
|
||||
? {
|
||||
os: item.latestVersion.parsed.clawdis.os ?? null,
|
||||
systems: item.latestVersion.parsed.clawdis.nix?.systems ?? null,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
@@ -292,6 +303,12 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
changelog: result.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
metadata: result.latestVersion?.parsed?.clawdis
|
||||
? {
|
||||
os: result.latestVersion.parsed.clawdis.os ?? null,
|
||||
systems: result.latestVersion.parsed.clawdis.nix?.systems ?? null,
|
||||
}
|
||||
: null,
|
||||
owner: result.owner
|
||||
? {
|
||||
handle: result.owner.handle ?? null,
|
||||
@@ -348,6 +365,43 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
if (!version) return text('Version not found', 404, rate.headers)
|
||||
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
|
||||
|
||||
// Map llmAnalysis to security status
|
||||
let security = undefined
|
||||
if (version.llmAnalysis) {
|
||||
const analysis = version.llmAnalysis
|
||||
let status: 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error'
|
||||
switch (analysis.verdict) {
|
||||
case 'benign':
|
||||
status = 'clean'
|
||||
break
|
||||
case 'suspicious':
|
||||
status = 'suspicious'
|
||||
break
|
||||
case 'malicious':
|
||||
status = 'malicious'
|
||||
break
|
||||
default:
|
||||
status = analysis.status === 'error' ? 'error' : 'pending'
|
||||
}
|
||||
|
||||
const hasWarnings =
|
||||
analysis.verdict === 'suspicious' ||
|
||||
analysis.verdict === 'malicious' ||
|
||||
(Array.isArray(analysis.dimensions) &&
|
||||
analysis.dimensions.some((dimension) => {
|
||||
if (!dimension || typeof dimension !== 'object') return false
|
||||
const rating = (dimension as { rating?: unknown }).rating
|
||||
return typeof rating === 'string' && rating !== 'ok'
|
||||
}))
|
||||
|
||||
security = {
|
||||
status,
|
||||
hasWarnings,
|
||||
checkedAt: analysis.checkedAt ?? null,
|
||||
model: analysis.model || null,
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
skill: { slug: skill.slug, displayName: skill.displayName },
|
||||
@@ -362,6 +416,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType ?? null,
|
||||
})),
|
||||
security,
|
||||
},
|
||||
},
|
||||
200,
|
||||
|
||||
@@ -163,7 +163,9 @@ async function handleAdminRestore(
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/reclaim
|
||||
* Admin-only: reclaim squatted slugs and reserve them for the rightful owner.
|
||||
* Admin-only: reclaim root slugs for the rightful owner.
|
||||
* Default behavior is non-destructive owner transfer for existing skills
|
||||
* (preserves versions/stats/metadata) and leaves missing slugs untouched.
|
||||
* Body: { handle: string, slugs: string[], reason?: string }
|
||||
*/
|
||||
async function handleAdminReclaim(
|
||||
@@ -185,16 +187,17 @@ async function handleAdminReclaim(
|
||||
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 }> = []
|
||||
const results: Array<{ slug: string; ok: boolean; action?: string; error?: string }> = []
|
||||
for (const slug of slugs) {
|
||||
try {
|
||||
await ctx.runMutation(internal.skills.reclaimSlugInternal, {
|
||||
const result = (await ctx.runMutation(internal.skills.reclaimSlugInternal, {
|
||||
actorUserId,
|
||||
slug: slug.trim().toLowerCase(),
|
||||
rightfulOwnerUserId: targetUser._id,
|
||||
reason,
|
||||
})
|
||||
results.push({ slug, ok: true })
|
||||
transferRootSlugOnly: true,
|
||||
})) as { action?: string }
|
||||
results.push({ slug, ok: true, action: result.action })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Reclaim failed'
|
||||
results.push({ slug, ok: false, error: message })
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@convex-dev/auth/server', () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}))
|
||||
|
||||
const { getAuthUserId } = await import('@convex-dev/auth/server')
|
||||
const {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
assertRole,
|
||||
requireUser,
|
||||
requireUserFromAction,
|
||||
} = await import('./access')
|
||||
|
||||
describe('access.requireUser', () => {
|
||||
it('throws when auth is missing', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null)
|
||||
await expect(
|
||||
requireUser({
|
||||
db: { get: vi.fn() },
|
||||
} as never),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('throws when user is deleted/deactivated/missing', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:1' as never)
|
||||
|
||||
for (const value of [null, { _id: 'users:1', deletedAt: Date.now() }, { _id: 'users:1', deactivatedAt: Date.now() }]) {
|
||||
const dbGet = vi.fn().mockResolvedValue(value as never)
|
||||
await expect(
|
||||
requireUser({
|
||||
db: { get: dbGet },
|
||||
} as never),
|
||||
).rejects.toThrow('User not found')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns auth user when active', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:2' as never)
|
||||
const user = { _id: 'users:2', role: 'user' }
|
||||
const dbGet = vi.fn().mockResolvedValue(user as never)
|
||||
|
||||
const result = await requireUser({
|
||||
db: { get: dbGet },
|
||||
} as never)
|
||||
|
||||
expect(dbGet).toHaveBeenCalledWith('users:2')
|
||||
expect(result).toEqual({ userId: 'users:2', user })
|
||||
})
|
||||
})
|
||||
|
||||
describe('access.requireUserFromAction', () => {
|
||||
it('throws when auth is missing', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null)
|
||||
await expect(
|
||||
requireUserFromAction({
|
||||
runQuery: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('throws when action lookup returns deleted/deactivated/missing user', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:1' as never)
|
||||
|
||||
for (const value of [null, { _id: 'users:1', deletedAt: Date.now() }, { _id: 'users:1', deactivatedAt: Date.now() }]) {
|
||||
const runQuery = vi.fn().mockResolvedValue(value as never)
|
||||
await expect(
|
||||
requireUserFromAction({
|
||||
runQuery,
|
||||
} as never),
|
||||
).rejects.toThrow('User not found')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns active user from action query', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:9' as never)
|
||||
const user = { _id: 'users:9', role: 'admin' }
|
||||
const runQuery = vi.fn().mockResolvedValue(user as never)
|
||||
|
||||
const result = await requireUserFromAction({
|
||||
runQuery,
|
||||
} as never)
|
||||
|
||||
expect(runQuery).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual({ userId: 'users:9', user })
|
||||
})
|
||||
})
|
||||
|
||||
describe('access role assertions', () => {
|
||||
it('assertRole allows matching roles and rejects missing role', () => {
|
||||
expect(() => assertRole({ role: 'admin' } as never, ['admin'])).not.toThrow()
|
||||
expect(() => assertRole({ role: undefined } as never, ['admin'])).toThrow('Forbidden')
|
||||
expect(() => assertRole({ role: 'user' } as never, ['admin'])).toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('assertAdmin/assertModerator enforce expected policy', () => {
|
||||
expect(() => assertAdmin({ role: 'admin' } as never)).not.toThrow()
|
||||
expect(() => assertAdmin({ role: 'moderator' } as never)).toThrow('Forbidden')
|
||||
|
||||
expect(() => assertModerator({ role: 'admin' } as never)).not.toThrow()
|
||||
expect(() => assertModerator({ role: 'moderator' } as never)).not.toThrow()
|
||||
expect(() => assertModerator({ role: 'user' } as never)).toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
export type Role = 'admin' | 'moderator' | 'user'
|
||||
@@ -13,7 +13,9 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
return { userId, user }
|
||||
}
|
||||
|
||||
export async function requireUserFromAction(ctx: ActionCtx) {
|
||||
export async function requireUserFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<{ userId: Id<'users'>; user: Doc<'users'> }> {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function getSkillBadgeMap(
|
||||
const records = await ctx.db
|
||||
.query('skillBadges')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skillId))
|
||||
.collect()
|
||||
.take(10)
|
||||
return buildBadgeMap(records)
|
||||
}
|
||||
|
||||
|
||||
+1
-21
@@ -1,6 +1,7 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { extractResponseText } from './openaiResponse'
|
||||
|
||||
const CHANGELOG_MODEL = process.env.OPENAI_CHANGELOG_MODEL ?? 'gpt-4.1'
|
||||
const MAX_README_CHARS = 8_000
|
||||
@@ -59,27 +60,6 @@ function pickPaths(values: string[]) {
|
||||
return values.slice(0, MAX_PATHS_IN_PROMPT)
|
||||
}
|
||||
|
||||
function extractResponseText(payload: unknown) {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) return null
|
||||
const chunks: string[] = []
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if ((item as { type?: unknown }).type !== 'message') continue
|
||||
const content = (item as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') continue
|
||||
if ((part as { type?: unknown }).type !== 'output_text') continue
|
||||
const text = (part as { text?: unknown }).text
|
||||
if (typeof text === 'string' && text.trim()) chunks.push(text)
|
||||
}
|
||||
}
|
||||
const joined = chunks.join('\n').trim()
|
||||
return joined || null
|
||||
}
|
||||
|
||||
async function generateWithOpenAI(args: {
|
||||
slug: string
|
||||
version: string
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assembleCommentScamEvalUserMessage,
|
||||
buildCommentScamBanReason,
|
||||
isCertainScam,
|
||||
parseCommentScamEvalResponse,
|
||||
} from './commentScamPrompt'
|
||||
|
||||
describe('commentScamPrompt', () => {
|
||||
it('parses valid JSON response', () => {
|
||||
const parsed = parseCommentScamEvalResponse(
|
||||
JSON.stringify({
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Comment instructs users to decode base64 and pipe to bash.',
|
||||
evidence: ['echo + base64 -D | bash', 'fake update-service domain'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(parsed).toEqual({
|
||||
verdict: 'certain_scam',
|
||||
confidence: 'high',
|
||||
explanation: 'Comment instructs users to decode base64 and pipe to bash.',
|
||||
evidence: ['echo + base64 -D | bash', 'fake update-service domain'],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses markdown-fenced JSON', () => {
|
||||
const parsed = parseCommentScamEvalResponse(`\`\`\`json
|
||||
{"verdict":"likely_scam","confidence":"medium","explanation":"Suspicious terminal one-liner.","evidence":["curl | bash"]}
|
||||
\`\`\``)
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
verdict: 'likely_scam',
|
||||
confidence: 'medium',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid response payloads', () => {
|
||||
expect(parseCommentScamEvalResponse('{"verdict":"ban"}')).toBeNull()
|
||||
expect(parseCommentScamEvalResponse('not-json')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds bounded ban reason', () => {
|
||||
const reason = buildCommentScamBanReason({
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:1',
|
||||
explanation: 'A'.repeat(700),
|
||||
evidence: ['B'.repeat(300), 'C'.repeat(300), 'D'.repeat(300), 'E'.repeat(300)],
|
||||
})
|
||||
|
||||
expect(reason.length).toBeLessThanOrEqual(500)
|
||||
expect(reason).toContain('commentId=comments:1')
|
||||
expect(reason).toContain('skillId=skills:1')
|
||||
})
|
||||
|
||||
it('marks certainty only for high-confidence certain_scam', () => {
|
||||
expect(isCertainScam({ verdict: 'certain_scam', confidence: 'high' })).toBe(true)
|
||||
expect(isCertainScam({ verdict: 'certain_scam', confidence: 'medium' })).toBe(false)
|
||||
expect(isCertainScam({ verdict: 'likely_scam', confidence: 'high' })).toBe(false)
|
||||
})
|
||||
|
||||
it('builds compact user message with context', () => {
|
||||
const message = assembleCommentScamEvalUserMessage({
|
||||
commentId: 'comments:1',
|
||||
skillId: 'skills:3',
|
||||
userId: 'users:9',
|
||||
body: ' test ',
|
||||
})
|
||||
|
||||
expect(message).toContain('Comment ID: comments:1')
|
||||
expect(message).toContain('Skill ID: skills:3')
|
||||
expect(message).toContain('Author User ID: users:9')
|
||||
expect(message).toContain('test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
export type CommentScamVerdict = 'not_scam' | 'likely_scam' | 'certain_scam'
|
||||
export type CommentScamConfidence = 'low' | 'medium' | 'high'
|
||||
|
||||
export type CommentScamEvalResponse = {
|
||||
verdict: CommentScamVerdict
|
||||
confidence: CommentScamConfidence
|
||||
explanation: string
|
||||
evidence: string[]
|
||||
}
|
||||
|
||||
export const COMMENT_SCAM_EVAL_MAX_OUTPUT_TOKENS = 1200
|
||||
const MAX_COMMENT_CHARS = 4000
|
||||
const MAX_EXPLANATION_CHARS = 1200
|
||||
const MAX_EVIDENCE_ITEMS = 5
|
||||
const MAX_EVIDENCE_ITEM_CHARS = 160
|
||||
const MAX_BAN_REASON_CHARS = 500
|
||||
|
||||
const VALID_VERDICTS = new Set<CommentScamVerdict>(['not_scam', 'likely_scam', 'certain_scam'])
|
||||
const VALID_CONFIDENCES = new Set<CommentScamConfidence>(['low', 'medium', 'high'])
|
||||
|
||||
export const COMMENT_SCAM_EVALUATOR_SYSTEM_PROMPT = `You are a trust and safety classifier for user comments on a software registry.
|
||||
|
||||
Goal: detect comment scams with high precision.
|
||||
|
||||
A "certain_scam" verdict is only allowed when the comment clearly attempts fraud, credential theft, malware delivery, or social-engineering abuse.
|
||||
|
||||
High-confidence scam patterns include:
|
||||
- Instructing users to run suspicious shell commands (especially obfuscated/base64/piped-to-bash/curl installer tricks).
|
||||
- Fake support/update instructions pointing to unknown domains, executables, or terminal one-liners.
|
||||
- Requests for private keys, seed phrases, passwords, API keys, session tokens, or wallet recovery data.
|
||||
- Impersonation or urgent pressure language to bypass trust checks.
|
||||
- Known scam payload structure (e.g. echo+base64+decode+bash, hidden downloader chains).
|
||||
|
||||
Important anti-false-positive rules:
|
||||
- Do NOT mark legitimate troubleshooting or normal install instructions as "certain_scam" unless the malicious intent is explicit.
|
||||
- If suspicious but ambiguous, use "likely_scam".
|
||||
- If benign/unclear, use "not_scam".
|
||||
|
||||
Output JSON only:
|
||||
{
|
||||
"verdict": "not_scam" | "likely_scam" | "certain_scam",
|
||||
"confidence": "low" | "medium" | "high",
|
||||
"explanation": "short plain-language rationale",
|
||||
"evidence": ["short concrete signal", "..."]
|
||||
}`
|
||||
|
||||
export function getCommentScamEvalModel(): string {
|
||||
return process.env.OPENAI_COMMENT_EVAL_MODEL ?? process.env.OPENAI_EVAL_MODEL ?? 'gpt-5-mini'
|
||||
}
|
||||
|
||||
export function assembleCommentScamEvalUserMessage(args: {
|
||||
commentId: string
|
||||
skillId: string
|
||||
userId: string
|
||||
body: string
|
||||
}): string {
|
||||
const trimmed = args.body.trim()
|
||||
const body =
|
||||
trimmed.length > MAX_COMMENT_CHARS
|
||||
? `${trimmed.slice(0, MAX_COMMENT_CHARS)}\n…[truncated]`
|
||||
: trimmed
|
||||
|
||||
return [
|
||||
`Comment ID: ${args.commentId}`,
|
||||
`Skill ID: ${args.skillId}`,
|
||||
`Author User ID: ${args.userId}`,
|
||||
'Comment body:',
|
||||
'```',
|
||||
body,
|
||||
'```',
|
||||
'Respond with a single JSON object.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function stripCodeFence(raw: string): string {
|
||||
const text = raw.trim()
|
||||
if (!text.startsWith('```')) return text
|
||||
const firstNewline = text.indexOf('\n')
|
||||
if (firstNewline === -1) return text
|
||||
const withoutOpening = text.slice(firstNewline + 1)
|
||||
const lastFence = withoutOpening.lastIndexOf('```')
|
||||
if (lastFence === -1) return withoutOpening.trim()
|
||||
return withoutOpening.slice(0, lastFence).trim()
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
if (value.length <= max) return value
|
||||
if (max <= 3) return value.slice(0, max)
|
||||
return `${value.slice(0, max - 3)}...`
|
||||
}
|
||||
|
||||
export function parseCommentScamEvalResponse(raw: string): CommentScamEvalResponse | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(stripCodeFence(raw))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const obj = parsed as Record<string, unknown>
|
||||
|
||||
const verdict =
|
||||
typeof obj.verdict === 'string' ? (obj.verdict.toLowerCase() as CommentScamVerdict) : null
|
||||
if (!verdict || !VALID_VERDICTS.has(verdict)) return null
|
||||
|
||||
const confidence =
|
||||
typeof obj.confidence === 'string'
|
||||
? (obj.confidence.toLowerCase() as CommentScamConfidence)
|
||||
: null
|
||||
if (!confidence || !VALID_CONFIDENCES.has(confidence)) return null
|
||||
|
||||
const rawExplanation = typeof obj.explanation === 'string' ? obj.explanation.trim() : ''
|
||||
if (!rawExplanation) return null
|
||||
|
||||
const rawEvidence = Array.isArray(obj.evidence) ? obj.evidence : []
|
||||
const evidence = rawEvidence
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_EVIDENCE_ITEMS)
|
||||
.map((item) => truncate(item, MAX_EVIDENCE_ITEM_CHARS))
|
||||
|
||||
return {
|
||||
verdict,
|
||||
confidence,
|
||||
explanation: truncate(rawExplanation, MAX_EXPLANATION_CHARS),
|
||||
evidence,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCertainScam(result: {
|
||||
verdict: CommentScamVerdict
|
||||
confidence: CommentScamConfidence
|
||||
}): boolean {
|
||||
return result.verdict === 'certain_scam' && result.confidence === 'high'
|
||||
}
|
||||
|
||||
export function buildCommentScamBanReason(args: {
|
||||
commentId: string
|
||||
skillId: string
|
||||
explanation: string
|
||||
evidence: string[]
|
||||
}): string {
|
||||
const explanation = args.explanation.trim()
|
||||
const evidence = args.evidence
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
const suffix = ` commentId=${args.commentId} skillId=${args.skillId}`
|
||||
const evidenceSegment = evidence.length > 0 ? ` evidence: ${evidence.join('; ')}.` : ''
|
||||
const core = `comment scam auto-ban. ${explanation}.${evidenceSegment}`
|
||||
const maxCoreChars = Math.max(0, MAX_BAN_REASON_CHARS - suffix.length)
|
||||
return `${truncate(core, maxCoreChars)}${suffix}`
|
||||
}
|
||||
@@ -39,7 +39,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
githubCreatedAt: now.getTime() - 20 * ONE_DAY_MS,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
@@ -72,7 +72,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects accounts younger than 7 days', async () => {
|
||||
it('rejects accounts younger than 14 days', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
@@ -85,7 +85,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
|
||||
).rejects.toThrow(/GitHub account must be at least 14 days old/i)
|
||||
})
|
||||
|
||||
it('fetches githubCreatedAt when missing (by providerAccountId)', async () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ 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 MIN_ACCOUNT_AGE_MS = 14 * 24 * 60 * 60 * 1000
|
||||
|
||||
type GitHubAccountGateCtx = Pick<ActionCtx, 'runQuery' | 'runMutation'>
|
||||
|
||||
type GitHubUser = {
|
||||
login?: string
|
||||
@@ -29,7 +31,7 @@ function buildGitHubHeaders() {
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
export async function requireGitHubAccountAge(ctx: GitHubAccountGateCtx, userId: Id<'users'>) {
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError('User not found')
|
||||
|
||||
@@ -76,7 +78,7 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
|
||||
const remainingMs = MIN_ACCOUNT_AGE_MS - ageMs
|
||||
const remainingDays = Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)))
|
||||
throw new ConvexError(
|
||||
`GitHub account must be at least 7 days old to upload skills. Try again in ${remainingDays} day${
|
||||
`GitHub account must be at least 14 days old to publish skills or post comments. Try again in ${remainingDays} day${
|
||||
remainingDays === 1 ? '' : 's'
|
||||
}.`,
|
||||
)
|
||||
@@ -116,6 +118,7 @@ export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
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
|
||||
|
||||
+105
-1
@@ -74,6 +74,13 @@ export type GitHubBackupContext = {
|
||||
root: string
|
||||
}
|
||||
|
||||
export type GitHubSkillBackupEntry = {
|
||||
owner: string
|
||||
slug: string
|
||||
rootPath: string
|
||||
metaPath: string
|
||||
}
|
||||
|
||||
export function isGitHubBackupConfigured() {
|
||||
return Boolean(
|
||||
process.env.GITHUB_APP_ID &&
|
||||
@@ -108,6 +115,103 @@ export async function fetchGitHubSkillMeta(
|
||||
)
|
||||
}
|
||||
|
||||
export async function listGitHubSkillBackupEntries(
|
||||
context: GitHubBackupContext,
|
||||
): Promise<GitHubSkillBackupEntry[]> {
|
||||
const ref = await githubGet<GitRef>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/ref/heads/${context.branch}`,
|
||||
)
|
||||
const baseCommit = await githubGet<GitCommit>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/commits/${ref.object.sha}`,
|
||||
)
|
||||
const tree = await githubGet<GitTree>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/trees/${baseCommit.tree.sha}?recursive=1`,
|
||||
)
|
||||
|
||||
const prefix = context.root ? `${context.root}/` : ''
|
||||
const entries: GitHubSkillBackupEntry[] = []
|
||||
for (const entry of tree.tree ?? []) {
|
||||
if (entry.type !== 'blob' || !entry.path) continue
|
||||
if (!entry.path.startsWith(prefix) || !entry.path.endsWith(`/${META_FILENAME}`)) continue
|
||||
const relative = entry.path.slice(prefix.length)
|
||||
const segments = relative.split('/')
|
||||
if (segments.length !== 3) continue
|
||||
const [owner, slug, file] = segments
|
||||
if (file !== META_FILENAME) continue
|
||||
const rootPath = prefix ? `${prefix}${owner}/${slug}` : `${owner}/${slug}`
|
||||
entries.push({ owner, slug, rootPath, metaPath: entry.path })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export async function deleteGitHubSkillBackup(
|
||||
context: GitHubBackupContext,
|
||||
ownerHandle: string,
|
||||
slug: string,
|
||||
) {
|
||||
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
|
||||
const ref = await githubGet<GitRef>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/ref/heads/${context.branch}`,
|
||||
)
|
||||
const baseCommitSha = ref.object.sha
|
||||
const baseCommit = await githubGet<GitCommit>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/commits/${baseCommitSha}`,
|
||||
)
|
||||
const baseTreeSha = baseCommit.tree.sha
|
||||
const existingTree = await githubGet<GitTree>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/trees/${baseTreeSha}?recursive=1`,
|
||||
)
|
||||
|
||||
const prefix = `${skillRoot}/`
|
||||
const pathsToDelete = (existingTree.tree ?? [])
|
||||
.filter((entry) => entry.type === 'blob' && entry.path?.startsWith(prefix))
|
||||
.map((entry) => entry.path ?? '')
|
||||
.filter(Boolean)
|
||||
|
||||
if (!pathsToDelete.length) return { deleted: false as const }
|
||||
|
||||
const treeEntries = pathsToDelete.map((path) => ({
|
||||
path,
|
||||
mode: '100644' as const,
|
||||
type: 'blob' as const,
|
||||
sha: null,
|
||||
}))
|
||||
|
||||
const newTree = await githubPost<{ sha: string }>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/trees`,
|
||||
{
|
||||
base_tree: baseTreeSha,
|
||||
tree: treeEntries,
|
||||
},
|
||||
)
|
||||
|
||||
const commit = await githubPost<GitCommit>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/commits`,
|
||||
{
|
||||
message: `delete: ${skillRoot}`,
|
||||
tree: newTree.sha,
|
||||
parents: [baseCommitSha],
|
||||
},
|
||||
)
|
||||
|
||||
await githubPatch(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/git/refs/heads/${context.branch}`,
|
||||
{ sha: commit.sha },
|
||||
)
|
||||
|
||||
return { deleted: true as const }
|
||||
}
|
||||
|
||||
export async function backupSkillToGitHub(
|
||||
ctx: ActionCtx,
|
||||
params: BackupParams,
|
||||
@@ -397,7 +501,7 @@ function parseRepo(repo: string) {
|
||||
return [owner, name] as const
|
||||
}
|
||||
|
||||
function normalizeOwner(value: string) {
|
||||
export function normalizeOwner(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
export const GLOBAL_STATS_KEY = 'default'
|
||||
|
||||
type SkillVisibilityFields = Pick<
|
||||
Doc<'skills'>,
|
||||
'softDeletedAt' | 'moderationStatus' | 'moderationFlags'
|
||||
>
|
||||
|
||||
type GlobalStatsReadCtx = Pick<MutationCtx | QueryCtx, 'db'>
|
||||
type GlobalStatsWriteCtx = Pick<MutationCtx, 'db'>
|
||||
|
||||
export function isPublicSkillDoc(skill: SkillVisibilityFields | null | undefined) {
|
||||
if (!skill || skill.softDeletedAt) return false
|
||||
if (skill.moderationStatus && skill.moderationStatus !== 'active') return false
|
||||
if (skill.moderationFlags?.includes('blocked.malware')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function getPublicSkillVisibilityDelta(
|
||||
before: SkillVisibilityFields | null | undefined,
|
||||
after: SkillVisibilityFields | null | undefined,
|
||||
) {
|
||||
const beforePublic = isPublicSkillDoc(before)
|
||||
const afterPublic = isPublicSkillDoc(after)
|
||||
if (beforePublic === afterPublic) return 0
|
||||
return afterPublic ? 1 : -1
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function isGlobalStatsStorageNotReadyError(error: unknown) {
|
||||
const message = getErrorMessage(error).toLowerCase()
|
||||
if (!message) return false
|
||||
const referencesGlobalStats = message.includes('globalstats') || message.includes('by_key')
|
||||
if (!referencesGlobalStats) return false
|
||||
return (
|
||||
message.includes('table') ||
|
||||
message.includes('index') ||
|
||||
message.includes('schema') ||
|
||||
message.includes('not found') ||
|
||||
message.includes('does not exist') ||
|
||||
message.includes('unknown')
|
||||
)
|
||||
}
|
||||
|
||||
export async function countPublicSkillsForGlobalStats(ctx: GlobalStatsReadCtx) {
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.collect()
|
||||
let count = 0
|
||||
for (const skill of skills) {
|
||||
if (isPublicSkillDoc(skill)) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export async function setGlobalPublicSkillsCount(
|
||||
ctx: GlobalStatsWriteCtx,
|
||||
count: number,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const normalizedCount = Math.max(0, Math.trunc(Number.isFinite(count) ? count : 0))
|
||||
try {
|
||||
const existing = await ctx.db
|
||||
.query('globalStats')
|
||||
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { activeSkillsCount: normalizedCount, updatedAt: now })
|
||||
} else {
|
||||
await ctx.db.insert('globalStats', {
|
||||
key: GLOBAL_STATS_KEY,
|
||||
activeSkillsCount: normalizedCount,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isGlobalStatsStorageNotReadyError(error)) return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function adjustGlobalPublicSkillsCount(
|
||||
ctx: GlobalStatsWriteCtx,
|
||||
delta: number,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const normalizedDelta = Math.trunc(Number.isFinite(delta) ? delta : 0)
|
||||
if (normalizedDelta === 0) return
|
||||
|
||||
let existing:
|
||||
| {
|
||||
_id: Doc<'globalStats'>['_id']
|
||||
activeSkillsCount: number
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
try {
|
||||
existing = await ctx.db
|
||||
.query('globalStats')
|
||||
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
|
||||
.unique()
|
||||
} catch (error) {
|
||||
if (isGlobalStatsStorageNotReadyError(error)) return
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
// No baseline yet (e.g. fresh deploy). Initialize via full recount once.
|
||||
const count = await countPublicSkillsForGlobalStats(ctx)
|
||||
await setGlobalPublicSkillsCount(ctx, count, now)
|
||||
return
|
||||
}
|
||||
|
||||
const nextCount = Math.max(0, existing.activeSkillsCount + normalizedDelta)
|
||||
await ctx.db.patch(existing._id, { activeSkillsCount: nextCount, updatedAt: now })
|
||||
}
|
||||
|
||||
export async function readGlobalPublicSkillsCount(ctx: GlobalStatsReadCtx) {
|
||||
try {
|
||||
const stats = await ctx.db
|
||||
.query('globalStats')
|
||||
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
|
||||
.unique()
|
||||
return stats?.activeSkillsCount ?? null
|
||||
} catch (error) {
|
||||
if (isGlobalStatsStorageNotReadyError(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,51 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getClientIp } from './httpRateLimit'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { applyRateLimit, getClientIp } from './httpRateLimit'
|
||||
|
||||
type MockRateLimitStatus = {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
type MockRateLimitPlan = {
|
||||
ip: MockRateLimitStatus
|
||||
user?: MockRateLimitStatus
|
||||
tokenValid?: boolean
|
||||
userActive?: boolean
|
||||
}
|
||||
|
||||
function makeRateLimitCtx(plan: MockRateLimitPlan) {
|
||||
const runQuery = vi.fn(async (_fn: unknown, args: Record<string, unknown>) => {
|
||||
if ('tokenHash' in args) {
|
||||
if (plan.tokenValid === false) return null
|
||||
return { _id: 'token_1', revokedAt: undefined }
|
||||
}
|
||||
if ('tokenId' in args) {
|
||||
if (plan.userActive === false) return null
|
||||
return { _id: 'users_123', deletedAt: undefined, deactivatedAt: undefined }
|
||||
}
|
||||
if ('key' in args && 'limit' in args && 'windowMs' in args) {
|
||||
const key = String(args.key)
|
||||
if (key.startsWith('ip:')) return plan.ip
|
||||
if (key.startsWith('user:')) return plan.user
|
||||
}
|
||||
throw new Error(`Unexpected runQuery args: ${JSON.stringify(args)}`)
|
||||
})
|
||||
|
||||
const runMutation = vi.fn(async (_fn: unknown, args: Record<string, unknown>) => {
|
||||
const key = String(args.key)
|
||||
const source = key.startsWith('user:') ? plan.user : plan.ip
|
||||
if (!source) throw new Error(`Missing rate limit source for ${key}`)
|
||||
return { allowed: source.allowed, remaining: source.remaining }
|
||||
})
|
||||
|
||||
return {
|
||||
runQuery,
|
||||
runMutation,
|
||||
} as unknown as Parameters<typeof applyRateLimit>[0]
|
||||
}
|
||||
|
||||
describe('getClientIp', () => {
|
||||
let prev: string | undefined
|
||||
@@ -53,4 +98,199 @@ describe('getClientIp', () => {
|
||||
process.env.TRUST_FORWARDED_IPS = 'true'
|
||||
expect(getClientIp(request)).toBe('203.0.113.9')
|
||||
})
|
||||
|
||||
it('prefers x-forwarded-for over x-real-ip when trusted mode is enabled', () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.9, 198.51.100.2',
|
||||
'x-real-ip': '198.51.100.77',
|
||||
},
|
||||
})
|
||||
process.env.TRUST_FORWARDED_IPS = 'true'
|
||||
expect(getClientIp(request)).toBe('203.0.113.9')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRateLimit headers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns delay-seconds Retry-After on 429 (not epoch)', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
|
||||
const runMutation = vi.fn()
|
||||
const ctx = {
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 20,
|
||||
resetAt: 1_030_500,
|
||||
}),
|
||||
runMutation,
|
||||
} as unknown as Parameters<typeof applyRateLimit>[0]
|
||||
const request = new Request('https://example.com', {
|
||||
headers: { 'cf-connecting-ip': '203.0.113.1' },
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.response.status).toBe(429)
|
||||
expect(result.response.headers.get('Retry-After')).toBe('31')
|
||||
expect(result.response.headers.get('X-RateLimit-Reset')).toBe('1031')
|
||||
expect(result.response.headers.get('RateLimit-Reset')).toBe('31')
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes rate-limit headers without Retry-After when allowed', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000_000)
|
||||
const ctx = {
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
limit: 20,
|
||||
resetAt: 2_015_000,
|
||||
}),
|
||||
runMutation: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 18,
|
||||
}),
|
||||
} as unknown as Parameters<typeof applyRateLimit>[0]
|
||||
const request = new Request('https://example.com', {
|
||||
headers: { 'cf-connecting-ip': '203.0.113.1' },
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const headers = new Headers(result.headers)
|
||||
expect(headers.get('X-RateLimit-Limit')).toBe('20')
|
||||
expect(headers.get('X-RateLimit-Remaining')).toBe('18')
|
||||
expect(headers.get('X-RateLimit-Reset')).toBe('2015')
|
||||
expect(headers.get('RateLimit-Limit')).toBe('20')
|
||||
expect(headers.get('RateLimit-Remaining')).toBe('18')
|
||||
expect(headers.get('RateLimit-Reset')).toBe('15')
|
||||
expect(headers.get('Retry-After')).toBeNull()
|
||||
})
|
||||
|
||||
it('allows authenticated users when user bucket is healthy and shared ip bucket is exhausted', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(3_000_000)
|
||||
const ctx = makeRateLimitCtx({
|
||||
ip: {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 20,
|
||||
resetAt: 3_040_000,
|
||||
},
|
||||
user: {
|
||||
allowed: true,
|
||||
remaining: 42,
|
||||
limit: 120,
|
||||
resetAt: 3_010_000,
|
||||
},
|
||||
})
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
authorization: 'Bearer clh_token',
|
||||
'cf-connecting-ip': '203.0.113.1',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const headers = new Headers(result.headers)
|
||||
expect(headers.get('X-RateLimit-Limit')).toBe('120')
|
||||
expect(headers.get('X-RateLimit-Remaining')).toBe('42')
|
||||
expect(headers.get('Retry-After')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not consume ip bucket for authenticated requests', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(3_100_000)
|
||||
const ctx = makeRateLimitCtx({
|
||||
ip: {
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
limit: 20,
|
||||
resetAt: 3_140_000,
|
||||
},
|
||||
user: {
|
||||
allowed: true,
|
||||
remaining: 41,
|
||||
limit: 120,
|
||||
resetAt: 3_110_000,
|
||||
},
|
||||
})
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
authorization: 'Bearer clh_token',
|
||||
'cf-connecting-ip': '203.0.113.1',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(true)
|
||||
const runMutation = (ctx as unknown as { runMutation: ReturnType<typeof vi.fn> }).runMutation
|
||||
const consumedKeys = runMutation.mock.calls.map(([, args]) => String(args.key))
|
||||
expect(consumedKeys.some((key) => key.startsWith('user:'))).toBe(true)
|
||||
expect(consumedKeys.some((key) => key.startsWith('ip:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('denies authenticated users when user bucket is exhausted even if ip bucket is healthy', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(4_000_000)
|
||||
const ctx = makeRateLimitCtx({
|
||||
ip: {
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
limit: 20,
|
||||
resetAt: 4_020_000,
|
||||
},
|
||||
user: {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 120,
|
||||
resetAt: 4_030_000,
|
||||
},
|
||||
})
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
authorization: 'Bearer clh_token',
|
||||
'cf-connecting-ip': '203.0.113.1',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.response.status).toBe(429)
|
||||
expect(result.response.headers.get('X-RateLimit-Limit')).toBe('120')
|
||||
expect(result.response.headers.get('X-RateLimit-Remaining')).toBe('0')
|
||||
expect(result.response.headers.get('Retry-After')).toBe('30')
|
||||
})
|
||||
|
||||
it('falls back to ip enforcement when bearer token is invalid', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(5_000_000)
|
||||
const ctx = makeRateLimitCtx({
|
||||
tokenValid: false,
|
||||
ip: {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: 20,
|
||||
resetAt: 5_030_000,
|
||||
},
|
||||
})
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
authorization: 'Bearer invalid',
|
||||
'cf-connecting-ip': '203.0.113.1',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await applyRateLimit(ctx, request, 'download')
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.response.status).toBe(429)
|
||||
expect(result.response.headers.get('X-RateLimit-Limit')).toBe('20')
|
||||
expect(result.response.headers.get('Retry-After')).toBe('30')
|
||||
})
|
||||
})
|
||||
|
||||
+64
-18
@@ -1,7 +1,7 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { getOptionalApiTokenUserId } from './apiTokenAuth'
|
||||
import { corsHeaders, mergeHeaders } from './httpHeaders'
|
||||
import { hashToken } from './tokens'
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
export const RATE_LIMITS = {
|
||||
@@ -22,17 +22,56 @@ export async function applyRateLimit(
|
||||
request: Request,
|
||||
kind: keyof typeof RATE_LIMITS,
|
||||
): Promise<{ ok: true; headers: HeadersInit } | { ok: false; response: Response }> {
|
||||
const userId = await getOptionalApiTokenUserId(ctx, request)
|
||||
const ip = getClientIp(request) ?? 'unknown'
|
||||
const ipSource = getClientIpSource(request)
|
||||
const hasClientIp = ip !== 'unknown'
|
||||
|
||||
// Authenticated requests are enforced and consumed by user bucket only to
|
||||
// avoid draining shared IP quota.
|
||||
if (userId) {
|
||||
const userResult = await checkRateLimit(ctx, `user:${userId}`, RATE_LIMITS[kind].key)
|
||||
const headers = rateHeaders(userResult)
|
||||
if (!userResult.allowed) {
|
||||
console.info('rate_limit_denied', {
|
||||
kind,
|
||||
auth: true,
|
||||
userAllowed: false,
|
||||
ipAllowed: null,
|
||||
ipSource,
|
||||
hasClientIp,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
response: new Response('Rate limit exceeded', {
|
||||
status: 429,
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return { ok: true, headers }
|
||||
}
|
||||
|
||||
// Anonymous requests remain IP-enforced.
|
||||
const ipResult = await checkRateLimit(ctx, `ip:${ip}`, RATE_LIMITS[kind].ip)
|
||||
const token = parseBearerToken(request)
|
||||
const keyResult = token
|
||||
? await checkRateLimit(ctx, `key:${await hashToken(token)}`, RATE_LIMITS[kind].key)
|
||||
: null
|
||||
const headers = rateHeaders(ipResult)
|
||||
|
||||
const chosen = pickMostRestrictive(ipResult, keyResult)
|
||||
const headers = rateHeaders(chosen)
|
||||
|
||||
if (!ipResult.allowed || (keyResult && !keyResult.allowed)) {
|
||||
if (!ipResult.allowed) {
|
||||
console.info('rate_limit_denied', {
|
||||
kind,
|
||||
auth: false,
|
||||
userAllowed: null,
|
||||
ipAllowed: ipResult.allowed,
|
||||
ipSource,
|
||||
hasClientIp,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
response: new Response('Rate limit exceeded', {
|
||||
@@ -59,13 +98,22 @@ export function getClientIp(request: Request) {
|
||||
if (!shouldTrustForwardedIps()) return null
|
||||
|
||||
const forwarded =
|
||||
request.headers.get('x-real-ip') ??
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
request.headers.get('fly-client-ip')
|
||||
|
||||
return splitFirstIp(forwarded)
|
||||
}
|
||||
|
||||
function getClientIpSource(request: Request) {
|
||||
if (request.headers.get('cf-connecting-ip')) return 'cf-connecting-ip'
|
||||
if (!shouldTrustForwardedIps()) return 'none'
|
||||
if (request.headers.get('x-forwarded-for')) return 'x-forwarded-for'
|
||||
if (request.headers.get('x-real-ip')) return 'x-real-ip'
|
||||
if (request.headers.get('fly-client-ip')) return 'fly-client-ip'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
async function checkRateLimit(
|
||||
ctx: ActionCtx,
|
||||
key: string,
|
||||
@@ -110,20 +158,18 @@ async function checkRateLimit(
|
||||
}
|
||||
}
|
||||
|
||||
function pickMostRestrictive(primary: RateLimitResult, secondary: RateLimitResult | null) {
|
||||
if (!secondary) return primary
|
||||
if (!primary.allowed) return primary
|
||||
if (!secondary.allowed) return secondary
|
||||
return secondary.remaining < primary.remaining ? secondary : primary
|
||||
}
|
||||
|
||||
function rateHeaders(result: RateLimitResult): HeadersInit {
|
||||
const nowMs = Date.now()
|
||||
const resetSeconds = Math.ceil(result.resetAt / 1000)
|
||||
const resetDelaySeconds = Math.max(1, Math.ceil((result.resetAt - nowMs) / 1000))
|
||||
return {
|
||||
'X-RateLimit-Limit': String(result.limit),
|
||||
'X-RateLimit-Remaining': String(result.remaining),
|
||||
'X-RateLimit-Reset': String(resetSeconds),
|
||||
...(result.allowed ? {} : { 'Retry-After': String(resetSeconds) }),
|
||||
'RateLimit-Limit': String(result.limit),
|
||||
'RateLimit-Remaining': String(result.remaining),
|
||||
'RateLimit-Reset': String(resetDelaySeconds),
|
||||
...(result.allowed ? {} : { 'Retry-After': String(resetDelaySeconds) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractResponseText } from './openaiResponse'
|
||||
|
||||
describe('extractResponseText', () => {
|
||||
it('returns null for invalid payload shapes', () => {
|
||||
expect(extractResponseText(null)).toBeNull()
|
||||
expect(extractResponseText({})).toBeNull()
|
||||
expect(extractResponseText({ output: {} })).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts output_text chunks from message content', () => {
|
||||
const payload = {
|
||||
output: [
|
||||
{ type: 'reasoning', content: [] },
|
||||
{
|
||||
type: 'message',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'First line' },
|
||||
{ type: 'output_text', text: 'Second line' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(extractResponseText(payload)).toBe('First line\nSecond line')
|
||||
})
|
||||
|
||||
it('ignores blank and non-output_text parts', () => {
|
||||
const payload = {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'ignored' },
|
||||
{ type: 'output_text', text: ' ' },
|
||||
{ type: 'output_text', text: 'kept' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(extractResponseText(payload)).toBe('kept')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function extractResponseText(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) return null
|
||||
const chunks: string[] = []
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if ((item as { type?: unknown }).type !== 'message') continue
|
||||
const content = (item as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') continue
|
||||
if ((part as { type?: unknown }).type !== 'output_text') continue
|
||||
const text = (part as { text?: unknown }).text
|
||||
if (typeof text === 'string' && text.trim()) chunks.push(text)
|
||||
}
|
||||
}
|
||||
const joined = chunks.join('\n').trim()
|
||||
return joined || null
|
||||
}
|
||||
@@ -65,4 +65,32 @@ describe('public skill mapping', () => {
|
||||
comments: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns skill when moderationStatus is active', () => {
|
||||
const skill = makeSkill({ moderationStatus: 'active' })
|
||||
expect(toPublicSkill(skill)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('filters out skill when moderationStatus is hidden', () => {
|
||||
const skill = makeSkill({ moderationStatus: 'hidden' })
|
||||
expect(toPublicSkill(skill)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns skill when moderationStatus is undefined (legacy)', () => {
|
||||
const skill = makeSkill({ moderationStatus: undefined })
|
||||
expect(toPublicSkill(skill)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('filters out soft-deleted skills', () => {
|
||||
const skill = makeSkill({ softDeletedAt: Date.now() })
|
||||
expect(toPublicSkill(skill)).toBeNull()
|
||||
})
|
||||
|
||||
it('filters out skills with blocked.malware flag', () => {
|
||||
const skill = makeSkill({
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: ['blocked.malware'],
|
||||
})
|
||||
expect(toPublicSkill(skill)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import { isPublicSkillDoc } from './globalStats'
|
||||
|
||||
export type PublicUser = Pick<
|
||||
Doc<'users'>,
|
||||
@@ -52,9 +53,8 @@ export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser
|
||||
}
|
||||
|
||||
export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSkill | null {
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
if (skill.moderationStatus && skill.moderationStatus !== 'active') return null
|
||||
if (skill.moderationFlags?.includes('blocked.malware')) return null
|
||||
if (!skill) return null
|
||||
if (!isPublicSkillDoc(skill)) return null
|
||||
const stats = {
|
||||
downloads:
|
||||
typeof skill.statsDownloads === 'number'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MAX_ACTIVE_REPORTS_PER_USER = 20
|
||||
export const AUTO_HIDE_REPORT_THRESHOLD = 3
|
||||
export const MAX_REPORT_REASON_LENGTH = 500
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
enforceReservedSlugCooldownForNewSkill,
|
||||
formatReservedSlugCooldownMessage,
|
||||
} from './reservedSlugs'
|
||||
|
||||
describe('reservedSlugs', () => {
|
||||
it('throws a user-facing error when slug is actively reserved by another user', async () => {
|
||||
const now = Date.now()
|
||||
const db = {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'reservedSlugs') throw new Error(`unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug_active_deletedAt') {
|
||||
throw new Error(`unexpected index ${name}`)
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => [
|
||||
{
|
||||
_id: 'reservedSlugs:1',
|
||||
slug: 'taken-skill',
|
||||
originalOwnerUserId: 'users:owner',
|
||||
deletedAt: now - 1000,
|
||||
expiresAt: now + 60_000,
|
||||
releasedAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
enforceReservedSlugCooldownForNewSkill(
|
||||
{ db } as never,
|
||||
{ slug: 'taken-skill', userId: 'users:caller' as never, now },
|
||||
),
|
||||
).rejects.toThrow(formatReservedSlugCooldownMessage('taken-skill', now + 60_000))
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ConvexError } from 'convex/values'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
@@ -5,6 +6,13 @@ type ReservedSlug = Doc<'reservedSlugs'>
|
||||
|
||||
const DEFAULT_ACTIVE_LIMIT = 25
|
||||
|
||||
export function formatReservedSlugCooldownMessage(slug: string, expiresAt: number) {
|
||||
return (
|
||||
`Slug "${slug}" is reserved for its previous owner until ${new Date(expiresAt).toISOString()}. ` +
|
||||
'Please choose a different slug.'
|
||||
)
|
||||
}
|
||||
|
||||
function reservedSlugQuery(ctx: QueryCtx | MutationCtx, slug: string) {
|
||||
return ctx.db
|
||||
.query('reservedSlugs')
|
||||
@@ -116,13 +124,9 @@ export async function enforceReservedSlugCooldownForNewSkill(
|
||||
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.',
|
||||
)
|
||||
throw new ConvexError(formatReservedSlugCooldownMessage(params.slug, latest.expiresAt))
|
||||
}
|
||||
|
||||
await ctx.db.patch(latest._id, { releasedAt: params.now })
|
||||
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ Flag when:
|
||||
- The number of required environment variables is high relative to the skill's complexity
|
||||
- The skill requires config paths that grant access to gateway auth, channel tokens, or tool policies
|
||||
- Environment variables named with patterns like SECRET, TOKEN, KEY, PASSWORD are required but not justified by the skill's purpose
|
||||
- The SKILL.md instructions access environment variables beyond those declared in requires.env or primaryEnv
|
||||
- The SKILL.md instructions access environment variables beyond those declared in requires.env, primaryEnv, or envVars
|
||||
|
||||
### 5. Persistence and privilege
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getFrontmatterMetadata,
|
||||
getFrontmatterValue,
|
||||
hashSkillFiles,
|
||||
isMacJunkPath,
|
||||
isTextFile,
|
||||
parseClawdisMetadata,
|
||||
parseFrontmatter,
|
||||
@@ -111,16 +112,17 @@ export async function publishVersionForUser(
|
||||
...file,
|
||||
path: file.path as string,
|
||||
}))
|
||||
if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
const publishFiles = safeFiles.filter((file) => !isMacJunkPath(file.path))
|
||||
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
throw new ConvexError('Only text-based files are allowed')
|
||||
}
|
||||
|
||||
const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const totalBytes = publishFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError('Skill bundle exceeds 50MB limit')
|
||||
}
|
||||
|
||||
const readmeFile = safeFiles.find(
|
||||
const readmeFile = publishFiles.find(
|
||||
(file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
|
||||
)
|
||||
if (!readmeFile) throw new ConvexError('SKILL.md is required')
|
||||
@@ -204,7 +206,7 @@ export async function publishVersionForUser(
|
||||
const metadata = mergeSourceIntoMetadata(frontmatterMetadata, args.source, qualityAssessment)
|
||||
|
||||
const otherFiles = [] as Array<{ path: string; content: string }>
|
||||
for (const file of safeFiles) {
|
||||
for (const file of publishFiles) {
|
||||
if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue
|
||||
const content = await fetchText(ctx, file.storageId)
|
||||
@@ -219,7 +221,7 @@ export async function publishVersionForUser(
|
||||
})
|
||||
|
||||
const fingerprintPromise = hashSkillFiles(
|
||||
safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
)
|
||||
|
||||
const changelogPromise =
|
||||
@@ -229,7 +231,7 @@ export async function publishVersionForUser(
|
||||
slug,
|
||||
version,
|
||||
readmeText,
|
||||
files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
files: publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
})
|
||||
|
||||
const embeddingPromise = generateEmbedding(embeddingText)
|
||||
@@ -258,7 +260,7 @@ export async function publishVersionForUser(
|
||||
}
|
||||
: undefined,
|
||||
bypassNewSkillRateLimit: options.bypassNewSkillRateLimit || undefined,
|
||||
files: safeFiles.map((file) => ({
|
||||
files: publishFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path,
|
||||
})),
|
||||
@@ -298,7 +300,7 @@ export async function publishVersionForUser(
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: safeFiles,
|
||||
files: publishFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getFrontmatterValue, parseFrontmatter } from './skills'
|
||||
import { extractResponseText } from './openaiResponse'
|
||||
|
||||
const SKILL_SUMMARY_MODEL = process.env.OPENAI_SKILL_SUMMARY_MODEL ?? 'gpt-4.1-mini'
|
||||
const MAX_README_CHARS = 8_000
|
||||
@@ -61,27 +62,6 @@ function deriveIdentityFallback(args: { slug: string; displayName: string }) {
|
||||
return normalizeSummary(`Automation skill for ${base}.`)
|
||||
}
|
||||
|
||||
function extractResponseText(payload: unknown) {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) return null
|
||||
const chunks: string[] = []
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if ((item as { type?: unknown }).type !== 'message') continue
|
||||
const content = (item as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') continue
|
||||
if ((part as { type?: unknown }).type !== 'output_text') continue
|
||||
const text = (part as { text?: unknown }).text
|
||||
if (typeof text === 'string' && text.trim()) chunks.push(text)
|
||||
}
|
||||
}
|
||||
const joined = chunks.join('\n').trim()
|
||||
return joined || null
|
||||
}
|
||||
|
||||
export async function generateSkillSummary(args: {
|
||||
slug: string
|
||||
displayName: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
getFrontmatterMetadata,
|
||||
getFrontmatterValue,
|
||||
hashSkillFiles,
|
||||
isMacJunkPath,
|
||||
isTextFile,
|
||||
parseClawdisMetadata,
|
||||
parseFrontmatter,
|
||||
@@ -152,6 +153,15 @@ describe('skills utils', () => {
|
||||
expect(isTextFile('data.json')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects mac junk paths', () => {
|
||||
expect(isMacJunkPath('.DS_Store')).toBe(true)
|
||||
expect(isMacJunkPath('folder/.DS_Store')).toBe(true)
|
||||
expect(isMacJunkPath('folder/._config.md')).toBe(true)
|
||||
expect(isMacJunkPath('__MACOSX/._SKILL.md')).toBe(true)
|
||||
expect(isMacJunkPath('docs/SKILL.md')).toBe(false)
|
||||
expect(isMacJunkPath('notes.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('builds embedding text', () => {
|
||||
const frontmatter = { name: 'Demo', description: 'Hello' }
|
||||
const text = buildEmbeddingText({
|
||||
@@ -195,3 +205,161 @@ describe('skills utils', () => {
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClawdisMetadata — env/deps/author/links (#350)', () => {
|
||||
it('parses envVars from clawdis block', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
metadata:
|
||||
clawdis:
|
||||
envVars:
|
||||
- name: ANTHROPIC_API_KEY
|
||||
required: true
|
||||
description: API key for Claude
|
||||
- name: MAX_TURNS
|
||||
required: false
|
||||
description: Max turns per phase
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.envVars).toHaveLength(2)
|
||||
expect(meta?.envVars?.[0]).toEqual({
|
||||
name: 'ANTHROPIC_API_KEY',
|
||||
required: true,
|
||||
description: 'API key for Claude',
|
||||
})
|
||||
expect(meta?.envVars?.[1]?.required).toBe(false)
|
||||
})
|
||||
|
||||
it('parses dependencies from clawdis block', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
metadata:
|
||||
clawdis:
|
||||
dependencies:
|
||||
- name: securevibes
|
||||
type: pip
|
||||
version: ">=0.3.0"
|
||||
url: https://pypi.org/project/securevibes/
|
||||
repository: https://github.com/anshumanbh/securevibes
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.dependencies).toHaveLength(1)
|
||||
expect(meta?.dependencies?.[0]).toEqual({
|
||||
name: 'securevibes',
|
||||
type: 'pip',
|
||||
version: '>=0.3.0',
|
||||
url: 'https://pypi.org/project/securevibes/',
|
||||
repository: 'https://github.com/anshumanbh/securevibes',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses author and links from clawdis block', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
metadata:
|
||||
clawdis:
|
||||
author: anshumanbh
|
||||
links:
|
||||
homepage: https://securevibes.ai
|
||||
repository: https://github.com/anshumanbh/securevibes
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.author).toBe('anshumanbh')
|
||||
expect(meta?.links?.homepage).toBe('https://securevibes.ai')
|
||||
expect(meta?.links?.repository).toBe('https://github.com/anshumanbh/securevibes')
|
||||
})
|
||||
|
||||
it('parses env/deps/author/links from top-level frontmatter (no clawdis block)', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
env:
|
||||
- name: MY_API_KEY
|
||||
required: true
|
||||
description: Main API key
|
||||
dependencies:
|
||||
- name: requests
|
||||
type: pip
|
||||
author: someuser
|
||||
links:
|
||||
homepage: https://example.com
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.envVars).toHaveLength(1)
|
||||
expect(meta?.envVars?.[0]?.name).toBe('MY_API_KEY')
|
||||
expect(meta?.dependencies).toHaveLength(1)
|
||||
expect(meta?.author).toBe('someuser')
|
||||
expect(meta?.links?.homepage).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('handles string-only env arrays as required env vars', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
metadata:
|
||||
clawdis:
|
||||
envVars:
|
||||
- API_KEY
|
||||
- SECRET_TOKEN
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.envVars).toHaveLength(2)
|
||||
expect(meta?.envVars?.[0]).toEqual({ name: 'API_KEY', required: true })
|
||||
})
|
||||
|
||||
it('normalizes unknown dependency types to other', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
metadata:
|
||||
clawdis:
|
||||
dependencies:
|
||||
- name: sometool
|
||||
type: ruby
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.dependencies?.[0]?.type).toBe('other')
|
||||
})
|
||||
|
||||
it('returns undefined when no declarations present', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
name: simple-skill
|
||||
description: A simple skill
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses requires.env from top-level frontmatter (no clawdis block) (#522)', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
name: sigil-security
|
||||
description: Secure AI agent wallets.
|
||||
homepage: https://sigil.codes
|
||||
requires:
|
||||
env:
|
||||
- SIGIL_API_KEY
|
||||
- SIGIL_ACCOUNT_ADDRESS
|
||||
- SIGIL_AGENT_PRIVATE_KEY
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.requires?.env).toEqual([
|
||||
'SIGIL_API_KEY',
|
||||
'SIGIL_ACCOUNT_ADDRESS',
|
||||
'SIGIL_AGENT_PRIVATE_KEY',
|
||||
])
|
||||
expect(meta?.homepage).toBe('https://sigil.codes')
|
||||
})
|
||||
|
||||
it('parses requires.bins and requires.anyBins from top-level frontmatter (#522)', () => {
|
||||
const frontmatter = parseFrontmatter(`---
|
||||
name: my-tool
|
||||
description: A tool skill.
|
||||
requires:
|
||||
bins:
|
||||
- curl
|
||||
- jq
|
||||
anyBins:
|
||||
- rg
|
||||
- fd
|
||||
config:
|
||||
- ~/.config/mytool.json
|
||||
primaryEnv: MY_API_KEY
|
||||
---`)
|
||||
const meta = parseClawdisMetadata(frontmatter)
|
||||
expect(meta?.requires?.bins).toEqual(['curl', 'jq'])
|
||||
expect(meta?.requires?.anyBins).toEqual(['rg', 'fd'])
|
||||
expect(meta?.requires?.config).toEqual(['~/.config/mytool.json'])
|
||||
expect(meta?.primaryEnv).toBe('MY_API_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
+162
-1
@@ -79,7 +79,12 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
|
||||
? (openclawMeta as Record<string, unknown>)
|
||||
: undefined
|
||||
const clawdisRaw = metadataSource ?? frontmatter.clawdis
|
||||
if (!clawdisRaw || typeof clawdisRaw !== 'object' || Array.isArray(clawdisRaw)) return undefined
|
||||
|
||||
// Support top-level frontmatter env/dependencies/author/links as fallback
|
||||
// even when no clawdis block exists (per #350)
|
||||
if (!clawdisRaw || typeof clawdisRaw !== 'object' || Array.isArray(clawdisRaw)) {
|
||||
return parseFrontmatterLevelDeclarations(frontmatter)
|
||||
}
|
||||
|
||||
try {
|
||||
const clawdisObj = clawdisRaw as Record<string, unknown>
|
||||
@@ -122,6 +127,19 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) {
|
||||
const config = parseClawdbotConfigSpec(clawdisObj.config)
|
||||
if (config) metadata.config = config
|
||||
|
||||
// Parse env var declarations (detailed env with descriptions)
|
||||
const envVars = parseEnvVarDeclarations(clawdisObj.envVars ?? clawdisObj.env)
|
||||
if (envVars.length > 0) metadata.envVars = envVars
|
||||
|
||||
// Parse dependency declarations
|
||||
const dependencies = parseDependencyDeclarations(clawdisObj.dependencies)
|
||||
if (dependencies.length > 0) metadata.dependencies = dependencies
|
||||
|
||||
// Parse author and links
|
||||
if (typeof clawdisObj.author === 'string') metadata.author = clawdisObj.author
|
||||
const links = parseSkillLinks(clawdisObj.links)
|
||||
if (links) metadata.links = links
|
||||
|
||||
return parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata')
|
||||
} catch {
|
||||
return undefined
|
||||
@@ -140,6 +158,22 @@ export function isTextFile(path: string, contentType?: string | null) {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isMacJunkPath(path: string) {
|
||||
const normalized = path
|
||||
.trim()
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase()
|
||||
if (!normalized) return false
|
||||
const segments = normalized.split('/').filter(Boolean)
|
||||
if (segments.length === 0) return false
|
||||
if (segments.includes('__macosx')) return true
|
||||
const basename = segments.at(-1) ?? ''
|
||||
if (basename === '.ds_store') return true
|
||||
if (basename.startsWith('._')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function sanitizePath(path: string) {
|
||||
const trimmed = path.trim().replace(/^\/+/, '')
|
||||
if (!trimmed || trimmed.includes('..') || trimmed.includes('\\')) {
|
||||
@@ -279,3 +313,130 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
const proto = Object.getPrototypeOf(value)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse env var declarations from frontmatter.
|
||||
* Accepts either an array of {name, required?, description?} objects
|
||||
* or a simple string array (converted to {name, required: true}).
|
||||
*/
|
||||
function parseEnvVarDeclarations(input: unknown): Array<{ name: string; required?: boolean; description?: string }> {
|
||||
if (!input) return []
|
||||
if (!Array.isArray(input)) return []
|
||||
return input
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { name: item.trim(), required: true }
|
||||
}
|
||||
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).name === 'string') {
|
||||
const obj = item as Record<string, unknown>
|
||||
const decl: { name: string; required?: boolean; description?: string } = {
|
||||
name: String(obj.name).trim(),
|
||||
}
|
||||
if (typeof obj.required === 'boolean') decl.required = obj.required
|
||||
if (typeof obj.description === 'string') decl.description = obj.description.trim()
|
||||
return decl
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null && item.name.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse dependency declarations from frontmatter.
|
||||
* Accepts an array of {name, type, version?, url?, repository?} objects.
|
||||
*/
|
||||
function parseDependencyDeclarations(input: unknown): Array<{
|
||||
name: string
|
||||
type: 'pip' | 'npm' | 'brew' | 'go' | 'cargo' | 'apt' | 'other'
|
||||
version?: string
|
||||
url?: string
|
||||
repository?: string
|
||||
}> {
|
||||
if (!input || !Array.isArray(input)) return []
|
||||
const validTypes = new Set(['pip', 'npm', 'brew', 'go', 'cargo', 'apt', 'other'])
|
||||
return input
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const obj = item as Record<string, unknown>
|
||||
if (typeof obj.name !== 'string') return null
|
||||
const typeStr = typeof obj.type === 'string' ? obj.type.trim().toLowerCase() : 'other'
|
||||
const depType = validTypes.has(typeStr)
|
||||
? (typeStr as 'pip' | 'npm' | 'brew' | 'go' | 'cargo' | 'apt' | 'other')
|
||||
: 'other'
|
||||
const decl: {
|
||||
name: string
|
||||
type: typeof depType
|
||||
version?: string
|
||||
url?: string
|
||||
repository?: string
|
||||
} = { name: String(obj.name).trim(), type: depType }
|
||||
if (typeof obj.version === 'string') decl.version = obj.version.trim()
|
||||
if (typeof obj.url === 'string') decl.url = obj.url.trim()
|
||||
if (typeof obj.repository === 'string') decl.repository = obj.repository.trim()
|
||||
return decl
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null && item.name.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse links object from frontmatter.
|
||||
*/
|
||||
function parseSkillLinks(input: unknown): { homepage?: string; repository?: string; documentation?: string; changelog?: string } | undefined {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
|
||||
const obj = input as Record<string, unknown>
|
||||
const links: { homepage?: string; repository?: string; documentation?: string; changelog?: string } = {}
|
||||
if (typeof obj.homepage === 'string') links.homepage = obj.homepage.trim()
|
||||
if (typeof obj.repository === 'string') links.repository = obj.repository.trim()
|
||||
if (typeof obj.documentation === 'string') links.documentation = obj.documentation.trim()
|
||||
if (typeof obj.changelog === 'string') links.changelog = obj.changelog.trim()
|
||||
return Object.keys(links).length > 0 ? links : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse top-level frontmatter env/dependencies/author/links
|
||||
* when no clawdis block is present (fallback for #350).
|
||||
*/
|
||||
function parseFrontmatterLevelDeclarations(frontmatter: ParsedSkillFrontmatter): ClawdisSkillMetadata | undefined {
|
||||
const metadata: ClawdisSkillMetadata = {}
|
||||
|
||||
// Parse requires block (env, bins, anyBins, config) from top-level frontmatter (#522)
|
||||
const requiresRaw = frontmatter.requires
|
||||
if (requiresRaw && typeof requiresRaw === 'object' && !Array.isArray(requiresRaw)) {
|
||||
const req = requiresRaw as Record<string, unknown>
|
||||
const bins = normalizeStringList(req.bins)
|
||||
const anyBins = normalizeStringList(req.anyBins)
|
||||
const env = normalizeStringList(req.env)
|
||||
const config = normalizeStringList(req.config)
|
||||
if (bins.length || anyBins.length || env.length || config.length) {
|
||||
metadata.requires = {}
|
||||
if (bins.length) metadata.requires.bins = bins
|
||||
if (anyBins.length) metadata.requires.anyBins = anyBins
|
||||
if (env.length) metadata.requires.env = env
|
||||
if (config.length) metadata.requires.config = config
|
||||
}
|
||||
}
|
||||
|
||||
// Parse primaryEnv from top-level frontmatter
|
||||
if (typeof frontmatter.primaryEnv === 'string') {
|
||||
metadata.primaryEnv = String(frontmatter.primaryEnv).trim()
|
||||
}
|
||||
|
||||
const envVars = parseEnvVarDeclarations(frontmatter.env)
|
||||
if (envVars.length > 0) metadata.envVars = envVars
|
||||
|
||||
const dependencies = parseDependencyDeclarations(frontmatter.dependencies)
|
||||
if (dependencies.length > 0) metadata.dependencies = dependencies
|
||||
|
||||
if (typeof frontmatter.author === 'string') metadata.author = String(frontmatter.author).trim()
|
||||
|
||||
const links = parseSkillLinks(frontmatter.links)
|
||||
if (links) metadata.links = links
|
||||
|
||||
if (typeof frontmatter.homepage === 'string') {
|
||||
metadata.homepage = String(frontmatter.homepage).trim()
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length > 0
|
||||
? parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata')
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { extractResponseText } from './openaiResponse'
|
||||
|
||||
const CHANGELOG_MODEL = process.env.OPENAI_CHANGELOG_MODEL ?? 'gpt-4.1'
|
||||
const MAX_README_CHARS = 8_000
|
||||
@@ -59,27 +60,6 @@ function pickPaths(values: string[]) {
|
||||
return values.slice(0, MAX_PATHS_IN_PROMPT)
|
||||
}
|
||||
|
||||
function extractResponseText(payload: unknown) {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) return null
|
||||
const chunks: string[] = []
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if ((item as { type?: unknown }).type !== 'message') continue
|
||||
const content = (item as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') continue
|
||||
if ((part as { type?: unknown }).type !== 'output_text') continue
|
||||
const text = (part as { text?: unknown }).text
|
||||
if (typeof text === 'string' && text.trim()) chunks.push(text)
|
||||
}
|
||||
}
|
||||
const joined = chunks.join('\n').trim()
|
||||
return joined || null
|
||||
}
|
||||
|
||||
async function generateWithOpenAI(args: {
|
||||
slug: string
|
||||
version: string
|
||||
|
||||
+13
-11
@@ -10,6 +10,7 @@ import {
|
||||
getFrontmatterMetadata,
|
||||
getFrontmatterValue,
|
||||
hashSkillFiles,
|
||||
isMacJunkPath,
|
||||
isTextFile,
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
@@ -100,22 +101,23 @@ export async function publishSoulVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => {
|
||||
const path = sanitizePath(file.path)
|
||||
if (!path) throw new ConvexError('Invalid file paths')
|
||||
if (!isTextFile(path, file.contentType ?? undefined)) {
|
||||
throw new ConvexError('Only text-based files are allowed')
|
||||
}
|
||||
return { ...file, path }
|
||||
})
|
||||
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path))
|
||||
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
throw new ConvexError('Only text-based files are allowed')
|
||||
}
|
||||
|
||||
const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
const totalBytes = publishFiles.reduce((sum, file) => sum + file.size, 0)
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError('Soul bundle exceeds 50MB limit')
|
||||
}
|
||||
|
||||
const isSoulFile = (path: string) => path.toLowerCase() === 'soul.md'
|
||||
const readmeFile = sanitizedFiles.find((file) => isSoulFile(file.path))
|
||||
const readmeFile = publishFiles.find((file) => isSoulFile(file.path))
|
||||
if (!readmeFile) throw new ConvexError('SOUL.md is required')
|
||||
|
||||
const nonSoulFiles = sanitizedFiles.filter((file) => !isSoulFile(file.path))
|
||||
const nonSoulFiles = publishFiles.filter((file) => !isSoulFile(file.path))
|
||||
if (nonSoulFiles.length > 0) {
|
||||
throw new ConvexError('Only SOUL.md is allowed for soul bundles')
|
||||
}
|
||||
@@ -132,8 +134,8 @@ export async function publishSoulVersionForUser(
|
||||
})
|
||||
|
||||
const fingerprint = await hashSkillFiles(
|
||||
sanitizedFiles.map((file) => ({
|
||||
path: file.path ?? '',
|
||||
publishFiles.map((file) => ({
|
||||
path: file.path,
|
||||
sha256: file.sha256,
|
||||
})),
|
||||
)
|
||||
@@ -145,7 +147,7 @@ export async function publishSoulVersionForUser(
|
||||
slug,
|
||||
version,
|
||||
readmeText,
|
||||
files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
|
||||
files: publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
})
|
||||
|
||||
const embeddingPromise = generateEmbedding(embeddingText)
|
||||
@@ -166,7 +168,7 @@ export async function publishSoulVersionForUser(
|
||||
changelogSource,
|
||||
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
|
||||
fingerprint,
|
||||
files: sanitizedFiles,
|
||||
files: publishFiles,
|
||||
parsed: {
|
||||
frontmatter,
|
||||
metadata,
|
||||
@@ -186,7 +188,7 @@ export async function publishSoulVersionForUser(
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: sanitizedFiles,
|
||||
files: publishFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildUserSearchResults } from './userSearch'
|
||||
|
||||
function makeUser(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: 'users:1',
|
||||
_creationTime: 1,
|
||||
handle: 'alice',
|
||||
name: 'alice-gh',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
...overrides,
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('buildUserSearchResults', () => {
|
||||
it('returns all users when query is empty', () => {
|
||||
const users = [makeUser({ _id: 'users:1' }), makeUser({ _id: 'users:2', handle: 'bob' })]
|
||||
const result = buildUserSearchResults(users)
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('matches compact handle/search variants', () => {
|
||||
const users = [makeUser({ handle: 'alice-dev' }), makeUser({ _id: 'users:2', handle: 'bob' })]
|
||||
const result = buildUserSearchResults(users, 'alicedev')
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items[0]?.handle).toBe('alice-dev')
|
||||
})
|
||||
|
||||
it('does not throw on malformed legacy field types', () => {
|
||||
const users = [
|
||||
makeUser({
|
||||
_id: 'users:legacy',
|
||||
handle: 42,
|
||||
name: { bad: true },
|
||||
displayName: null,
|
||||
email: ['legacy@example.com'],
|
||||
}),
|
||||
makeUser({ _id: 'users:2', handle: 'carol' }),
|
||||
]
|
||||
|
||||
expect(() => buildUserSearchResults(users, 'car')).not.toThrow()
|
||||
const result = buildUserSearchResults(users, 'car')
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items[0]?._id).toBe('users:2')
|
||||
})
|
||||
|
||||
it('ranks exact id match above fuzzy matches', () => {
|
||||
const users = [
|
||||
makeUser({ _id: 'users:target', handle: 'target-user', _creationTime: 1 }),
|
||||
makeUser({ _id: 'users:2', handle: 'users:target', _creationTime: 10 }),
|
||||
]
|
||||
|
||||
const result = buildUserSearchResults(users, 'users:target')
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items[0]?._id).toBe('users:target')
|
||||
})
|
||||
|
||||
it('uses creation time as tie-break when scores are equal', () => {
|
||||
const users = [
|
||||
makeUser({ _id: 'users:older', handle: 'alpha', _creationTime: 1 }),
|
||||
makeUser({ _id: 'users:newer', handle: 'alpha-two', _creationTime: 50 }),
|
||||
]
|
||||
|
||||
const result = buildUserSearchResults(users, 'pha')
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items[0]?._id).toBe('users:newer')
|
||||
expect(result.items[1]?._id).toBe('users:older')
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,15 @@ function normalizeCompact(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
function toSearchText(value: unknown) {
|
||||
return typeof value === 'string' ? value.toLowerCase() : ''
|
||||
}
|
||||
|
||||
function scoreUser(user: Doc<'users'>, query: string, compactQuery: string) {
|
||||
const handle = user.handle?.toLowerCase() ?? ''
|
||||
const name = user.name?.toLowerCase() ?? ''
|
||||
const displayName = user.displayName?.toLowerCase() ?? ''
|
||||
const email = user.email?.toLowerCase() ?? ''
|
||||
const handle = toSearchText(user.handle)
|
||||
const name = toSearchText(user.name)
|
||||
const displayName = toSearchText(user.displayName)
|
||||
const email = toSearchText(user.email)
|
||||
const id = String(user._id).toLowerCase()
|
||||
|
||||
let score = 0
|
||||
|
||||
+99
-22
@@ -2,6 +2,13 @@ import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import { internalAction } from './_generated/server'
|
||||
import {
|
||||
assembleCommentScamEvalUserMessage,
|
||||
COMMENT_SCAM_EVALUATOR_SYSTEM_PROMPT,
|
||||
COMMENT_SCAM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
getCommentScamEvalModel,
|
||||
parseCommentScamEvalResponse,
|
||||
} from './lib/commentScamPrompt'
|
||||
import type { SkillEvalContext } from './lib/securityPrompt'
|
||||
import {
|
||||
assembleEvalUserMessage,
|
||||
@@ -11,32 +18,12 @@ import {
|
||||
parseLlmEvalResponse,
|
||||
SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
} from './lib/securityPrompt'
|
||||
import { extractResponseText } from './lib/openaiResponse'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractResponseText(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const output = (payload as { output?: unknown }).output
|
||||
if (!Array.isArray(output)) return null
|
||||
const chunks: string[] = []
|
||||
for (const item of output) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
if ((item as { type?: unknown }).type !== 'message') continue
|
||||
const content = (item as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) continue
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') continue
|
||||
if ((part as { type?: unknown }).type !== 'output_text') continue
|
||||
const text = (part as { text?: unknown }).text
|
||||
if (typeof text === 'string' && text.trim()) chunks.push(text)
|
||||
}
|
||||
}
|
||||
const joined = chunks.join('\n').trim()
|
||||
return joined || null
|
||||
}
|
||||
|
||||
function verdictToStatus(verdict: string): string {
|
||||
switch (verdict) {
|
||||
case 'benign':
|
||||
@@ -142,6 +129,8 @@ export const evaluateWithLlm = internalAction({
|
||||
// 6. Build eval context
|
||||
const parsed = version.parsed as SkillEvalContext['parsed']
|
||||
const fm = parsed.frontmatter ?? {}
|
||||
const clawdisRecord = (parsed.clawdis ?? {}) as Record<string, unknown>
|
||||
const clawdisLinks = (clawdisRecord.links ?? {}) as Record<string, unknown>
|
||||
|
||||
const evalCtx: SkillEvalContext = {
|
||||
slug: skill.slug,
|
||||
@@ -151,7 +140,11 @@ export const evaluateWithLlm = internalAction({
|
||||
createdAt: version.createdAt,
|
||||
summary: (skill.summary as string | undefined) ?? undefined,
|
||||
source: (fm.source as string | undefined) ?? undefined,
|
||||
homepage: (fm.homepage as string | undefined) ?? undefined,
|
||||
homepage:
|
||||
(fm.homepage as string | undefined) ??
|
||||
(clawdisRecord.homepage as string | undefined) ??
|
||||
(clawdisLinks.homepage as string | undefined) ??
|
||||
undefined,
|
||||
parsed,
|
||||
files: version.files.map((f) => ({ path: f.path, size: f.size })),
|
||||
skillMdContent,
|
||||
@@ -381,3 +374,87 @@ export const backfillLlmEval = internalAction({
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
export const evaluateCommentForScam = internalAction({
|
||||
args: {
|
||||
commentId: v.id('comments'),
|
||||
skillId: v.id('skills'),
|
||||
userId: v.id('users'),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
return { ok: false as const, error: 'OPENAI_API_KEY not configured' }
|
||||
}
|
||||
|
||||
const model = getCommentScamEvalModel()
|
||||
const input = assembleCommentScamEvalUserMessage({
|
||||
commentId: String(args.commentId),
|
||||
skillId: String(args.skillId),
|
||||
userId: String(args.userId),
|
||||
body: args.body,
|
||||
})
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
model,
|
||||
instructions: COMMENT_SCAM_EVALUATOR_SYSTEM_PROMPT,
|
||||
input,
|
||||
max_output_tokens: COMMENT_SCAM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_object',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
let response: Response | null = null
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
response = await fetch('https://api.openai.com/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES) {
|
||||
const delay = 2 ** attempt * 2000 + Math.random() * 1000
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorText = response ? await response.text() : 'No response'
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `OpenAI API error (${response?.status}): ${errorText.slice(0, 200)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown
|
||||
const raw = extractResponseText(payload)
|
||||
if (!raw) {
|
||||
return { ok: false as const, error: 'Empty response from OpenAI' }
|
||||
}
|
||||
|
||||
const parsed = parseCommentScamEvalResponse(raw)
|
||||
if (!parsed) {
|
||||
console.error(`[commentScam] Parse failure for ${args.commentId}: ${raw.slice(0, 400)}`)
|
||||
return { ok: false as const, error: 'Failed to parse scam evaluation response' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
model,
|
||||
verdict: parsed.verdict,
|
||||
confidence: parsed.confidence,
|
||||
explanation: parsed.explanation,
|
||||
evidence: parsed.evidence,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
backfillSkillSummariesInternalHandler,
|
||||
cleanupEmptySkillsInternalHandler,
|
||||
nominateEmptySkillSpammersInternalHandler,
|
||||
upsertSkillBadgeRecordInternal,
|
||||
} = await import('./maintenance')
|
||||
const { internal } = await import('./_generated/api')
|
||||
const { generateSkillSummary } = await import('./lib/skillSummary')
|
||||
@@ -196,6 +197,85 @@ describe('maintenance backfill', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('maintenance badge denormalization', () => {
|
||||
it('upserts table badge and keeps skill.badges in sync', async () => {
|
||||
const unique = vi.fn().mockResolvedValue(null)
|
||||
const query = vi.fn().mockReturnValue({
|
||||
withIndex: () => ({ unique }),
|
||||
})
|
||||
const insert = vi.fn().mockResolvedValue('skillBadges:1')
|
||||
const get = vi.fn().mockResolvedValue({ _id: 'skills:1', badges: undefined })
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query,
|
||||
insert,
|
||||
get,
|
||||
patch,
|
||||
},
|
||||
} as never
|
||||
|
||||
const result = await (
|
||||
upsertSkillBadgeRecordInternal as unknown as { _handler: Function }
|
||||
)._handler(ctx, {
|
||||
skillId: 'skills:1',
|
||||
kind: 'highlighted',
|
||||
byUserId: 'users:1',
|
||||
at: 123,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ inserted: true })
|
||||
expect(insert).toHaveBeenCalledWith('skillBadges', {
|
||||
skillId: 'skills:1',
|
||||
kind: 'highlighted',
|
||||
byUserId: 'users:1',
|
||||
at: 123,
|
||||
})
|
||||
expect(patch).toHaveBeenCalledWith('skills:1', {
|
||||
badges: {
|
||||
highlighted: { byUserId: 'users:1', at: 123 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('resyncs denormalized badge even when table record already exists', async () => {
|
||||
const unique = vi.fn().mockResolvedValue({ _id: 'skillBadges:existing' })
|
||||
const query = vi.fn().mockReturnValue({
|
||||
withIndex: () => ({ unique }),
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const get = vi.fn().mockResolvedValue({ _id: 'skills:1', badges: {} })
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query,
|
||||
insert,
|
||||
get,
|
||||
patch,
|
||||
},
|
||||
} as never
|
||||
|
||||
const result = await (
|
||||
upsertSkillBadgeRecordInternal as unknown as { _handler: Function }
|
||||
)._handler(ctx, {
|
||||
skillId: 'skills:1',
|
||||
kind: 'official',
|
||||
byUserId: 'users:2',
|
||||
at: 456,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ inserted: false })
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).toHaveBeenCalledWith('skills:1', {
|
||||
badges: {
|
||||
official: { byUserId: 'users:2', at: 456 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('maintenance fingerprint backfill', () => {
|
||||
it('backfills fingerprint field and inserts index entry', async () => {
|
||||
const { hashSkillFiles } = await import('./lib/skills')
|
||||
|
||||
+119
-1
@@ -642,17 +642,32 @@ export const upsertSkillBadgeRecordInternal = internalMutation({
|
||||
at: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const syncDenormalizedBadge = async () => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
await ctx.db.patch(args.skillId, {
|
||||
badges: {
|
||||
...(skill.badges as Record<string, unknown> | undefined),
|
||||
[args.kind]: { byUserId: args.byUserId, at: args.at },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query('skillBadges')
|
||||
.withIndex('by_skill_kind', (q) => q.eq('skillId', args.skillId).eq('kind', args.kind))
|
||||
.unique()
|
||||
if (existing) return { inserted: false as const }
|
||||
if (existing) {
|
||||
await syncDenormalizedBadge()
|
||||
return { inserted: false as const }
|
||||
}
|
||||
await ctx.db.insert('skillBadges', {
|
||||
skillId: args.skillId,
|
||||
kind: args.kind,
|
||||
byUserId: args.byUserId,
|
||||
at: args.at,
|
||||
})
|
||||
await syncDenormalizedBadge()
|
||||
return { inserted: true as const }
|
||||
},
|
||||
})
|
||||
@@ -1411,6 +1426,109 @@ export const nominateEmptySkillSpammers: ReturnType<typeof action> = action({
|
||||
},
|
||||
})
|
||||
|
||||
// Backfill embeddingSkillMap from existing skillEmbeddings.
|
||||
// Run once after deploying the schema change:
|
||||
// npx convex run maintenance:backfillEmbeddingSkillMapInternal --prod
|
||||
export const backfillEmbeddingSkillMapInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? 200, 10, 500)
|
||||
const { page, continueCursor, isDone } = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
let inserted = 0
|
||||
for (const embedding of page) {
|
||||
const existing = await ctx.db
|
||||
.query('embeddingSkillMap')
|
||||
.withIndex('by_embedding', (q) => q.eq('embeddingId', embedding._id))
|
||||
.unique()
|
||||
if (!existing) {
|
||||
await ctx.db.insert('embeddingSkillMap', {
|
||||
embeddingId: embedding._id,
|
||||
skillId: embedding.skillId,
|
||||
})
|
||||
inserted++
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDone) {
|
||||
await ctx.scheduler.runAfter(0, internal.maintenance.backfillEmbeddingSkillMapInternal, {
|
||||
cursor: continueCursor,
|
||||
batchSize: args.batchSize,
|
||||
})
|
||||
}
|
||||
|
||||
return { inserted, isDone, scanned: page.length }
|
||||
},
|
||||
})
|
||||
|
||||
// Sync skillBadges table → denormalized skill.badges field.
|
||||
// Run after deploying the badge-read removal to ensure all skills
|
||||
// have up-to-date badges on the skill doc itself.
|
||||
export const backfillDenormalizedBadgesInternal = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? 100, 10, 200)
|
||||
const { page, continueCursor, isDone } = await ctx.db
|
||||
.query('skills')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
let patched = 0
|
||||
for (const skill of page) {
|
||||
const records = await ctx.db
|
||||
.query('skillBadges')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(10)
|
||||
|
||||
// Build canonical badge map from the table
|
||||
const canonical: Record<string, { byUserId: Id<'users'>; at: number }> = {}
|
||||
for (const r of records) {
|
||||
canonical[r.kind] = { byUserId: r.byUserId, at: r.at }
|
||||
}
|
||||
|
||||
// Compare with existing denormalized badges (keys + values)
|
||||
const existing = (skill.badges ?? {}) as Record<
|
||||
string,
|
||||
{ byUserId?: Id<'users'>; at?: number } | undefined
|
||||
>
|
||||
const canonicalKeys = Object.keys(canonical)
|
||||
const existingKeys = Object.keys(existing).filter((k) => existing[k] !== undefined)
|
||||
const needsPatch =
|
||||
canonicalKeys.length !== existingKeys.length ||
|
||||
canonicalKeys.some((k) => {
|
||||
const current = existing[k]
|
||||
const next = canonical[k]
|
||||
return (
|
||||
!current ||
|
||||
current.byUserId !== next.byUserId ||
|
||||
current.at !== next.at
|
||||
)
|
||||
})
|
||||
|
||||
if (needsPatch) {
|
||||
await ctx.db.patch(skill._id, { badges: canonical })
|
||||
patched++
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDone) {
|
||||
await ctx.scheduler.runAfter(0, internal.maintenance.backfillDenormalizedBadgesInternal, {
|
||||
cursor: continueCursor,
|
||||
batchSize: args.batchSize,
|
||||
})
|
||||
}
|
||||
|
||||
return { patched, isDone, scanned: page.length }
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const rounded = Math.trunc(value)
|
||||
if (!Number.isFinite(rounded)) return min
|
||||
|
||||
@@ -310,6 +310,14 @@ const skillEmbeddings = defineTable({
|
||||
filterFields: ['visibility'],
|
||||
})
|
||||
|
||||
// Lightweight lookup: embeddingId → skillId (~100 bytes per doc).
|
||||
// Avoids reading full skillEmbeddings docs (~12KB each with vector)
|
||||
// during search hydration.
|
||||
const embeddingSkillMap = defineTable({
|
||||
embeddingId: v.id('skillEmbeddings'),
|
||||
skillId: v.id('skills'),
|
||||
}).index('by_embedding', ['embeddingId'])
|
||||
|
||||
const skillDailyStats = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
day: v.number(),
|
||||
@@ -342,6 +350,12 @@ const skillStatBackfillState = defineTable({
|
||||
updatedAt: v.number(),
|
||||
}).index('by_key', ['key'])
|
||||
|
||||
const globalStats = defineTable({
|
||||
key: v.string(),
|
||||
activeSkillsCount: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_key', ['key'])
|
||||
|
||||
const skillStatEvents = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
kind: v.union(
|
||||
@@ -395,12 +409,37 @@ const comments = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
userId: v.id('users'),
|
||||
body: v.string(),
|
||||
reportCount: v.optional(v.number()),
|
||||
lastReportedAt: v.optional(v.number()),
|
||||
scamScanVerdict: v.optional(
|
||||
v.union(v.literal('not_scam'), v.literal('likely_scam'), v.literal('certain_scam')),
|
||||
),
|
||||
scamScanConfidence: v.optional(v.union(v.literal('low'), v.literal('medium'), v.literal('high'))),
|
||||
scamScanExplanation: v.optional(v.string()),
|
||||
scamScanEvidence: v.optional(v.array(v.string())),
|
||||
scamScanModel: v.optional(v.string()),
|
||||
scamScanCheckedAt: v.optional(v.number()),
|
||||
scamBanTriggeredAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
deletedBy: v.optional(v.id('users')),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_scam_scan_checked', ['scamScanCheckedAt'])
|
||||
|
||||
const commentReports = defineTable({
|
||||
commentId: v.id('comments'),
|
||||
skillId: v.id('skills'),
|
||||
userId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_comment', ['commentId'])
|
||||
.index('by_comment_createdAt', ['commentId', 'createdAt'])
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_comment_user', ['commentId', 'userId'])
|
||||
|
||||
const skillReports = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
@@ -570,13 +609,16 @@ export default defineSchema({
|
||||
skillBadges,
|
||||
soulVersionFingerprints,
|
||||
skillEmbeddings,
|
||||
embeddingSkillMap,
|
||||
soulEmbeddings,
|
||||
skillDailyStats,
|
||||
skillLeaderboards,
|
||||
skillStatBackfillState,
|
||||
globalStats,
|
||||
skillStatEvents,
|
||||
skillStatUpdateCursors,
|
||||
comments,
|
||||
commentReports,
|
||||
skillReports,
|
||||
soulComments,
|
||||
stars,
|
||||
|
||||
+16
-27
@@ -4,9 +4,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { tokenize } from './lib/searchText'
|
||||
import { __test, hydrateResults, lexicalFallbackSkills, searchSkills } from './search'
|
||||
|
||||
const { generateEmbeddingMock, getSkillBadgeMapsMock } = vi.hoisted(() => ({
|
||||
const { generateEmbeddingMock } = vi.hoisted(() => ({
|
||||
generateEmbeddingMock: vi.fn(),
|
||||
getSkillBadgeMapsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/embeddings', () => ({
|
||||
@@ -14,7 +13,6 @@ vi.mock('./lib/embeddings', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./lib/badges', () => ({
|
||||
getSkillBadgeMaps: getSkillBadgeMapsMock,
|
||||
isSkillHighlighted: (skill: { badges?: Record<string, unknown> }) =>
|
||||
Boolean(skill.badges?.highlighted),
|
||||
}))
|
||||
@@ -50,9 +48,8 @@ describe('search helpers', () => {
|
||||
]
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(fallback)
|
||||
.mockResolvedValueOnce([]) // hydrateResults
|
||||
.mockResolvedValueOnce(fallback) // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -71,18 +68,15 @@ describe('search helpers', () => {
|
||||
})
|
||||
|
||||
it('applies highlightedOnly filtering in lexical fallback', async () => {
|
||||
const highlighted = makeSkillDoc({
|
||||
id: 'skills:hl',
|
||||
slug: 'orf-highlighted',
|
||||
displayName: 'ORF Highlighted',
|
||||
})
|
||||
const highlighted = {
|
||||
...makeSkillDoc({
|
||||
id: 'skills:hl',
|
||||
slug: 'orf-highlighted',
|
||||
displayName: 'ORF Highlighted',
|
||||
}),
|
||||
badges: { highlighted: { byUserId: 'users:mod', at: 1 } },
|
||||
}
|
||||
const plain = makeSkillDoc({ id: 'skills:plain', slug: 'orf-plain', displayName: 'ORF Plain' })
|
||||
getSkillBadgeMapsMock.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['skills:hl', { highlighted: { byUserId: 'users:mod', at: 1 } }],
|
||||
['skills:plain', {}],
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(
|
||||
makeLexicalCtx({
|
||||
@@ -104,12 +98,6 @@ describe('search helpers', () => {
|
||||
moderationFlags: ['flagged.suspicious'],
|
||||
})
|
||||
const clean = makeSkillDoc({ id: 'skills:clean', slug: 'orf-clean', displayName: 'ORF Clean' })
|
||||
getSkillBadgeMapsMock.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['skills:suspicious', {}],
|
||||
['skills:clean', {}],
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(
|
||||
makeLexicalCtx({
|
||||
@@ -125,7 +113,6 @@ describe('search helpers', () => {
|
||||
|
||||
it('includes exact slug match from by_slug even when recent scan is empty', async () => {
|
||||
const exactSlugSkill = makeSkillDoc({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' })
|
||||
getSkillBadgeMapsMock.mockResolvedValueOnce(new Map([['skills:orf', {}]]))
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkill,
|
||||
recentSkills: [],
|
||||
@@ -197,9 +184,8 @@ describe('search helpers', () => {
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(fallbackEntries)
|
||||
.mockResolvedValueOnce(vectorEntries) // hydrateResults
|
||||
.mockResolvedValueOnce(fallbackEntries) // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -237,6 +223,9 @@ describe('search helpers', () => {
|
||||
if (id === 'skillVersions:1') return { _id: 'skillVersions:1', version: '1.0.0' }
|
||||
return null
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ embeddingIds: ['skillEmbeddings:1'], nonSuspiciousOnly: true },
|
||||
|
||||
+25
-59
@@ -3,7 +3,7 @@ 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 { isSkillHighlighted } from './lib/badges'
|
||||
import { generateEmbedding } from './lib/embeddings'
|
||||
import { toPublicSkill, toPublicSoul, toPublicUser } from './lib/public'
|
||||
import { matchesExactTokens, tokenize } from './lib/searchText'
|
||||
@@ -40,7 +40,7 @@ const SLUG_PREFIX_BOOST = 0.8
|
||||
const NAME_EXACT_BOOST = 1.1
|
||||
const NAME_PREFIX_BOOST = 0.6
|
||||
const POPULARITY_WEIGHT = 0.08
|
||||
const FALLBACK_SCAN_LIMIT = 1200
|
||||
const FALLBACK_SCAN_LIMIT = 500
|
||||
|
||||
function getNextCandidateLimit(current: number, max: number) {
|
||||
const next = Math.min(current * 2, max)
|
||||
@@ -149,21 +149,11 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
results.map((result) => [result._id, result._score]),
|
||||
)
|
||||
|
||||
const badgeMapEntries = (await ctx.runQuery(internal.search.getSkillBadgeMapsInternal, {
|
||||
skillIds: hydrated.map((entry) => entry.skill._id),
|
||||
})) as Array<[Id<'skills'>, SkillBadgeMap]>
|
||||
const badgeMapBySkillId = new Map(badgeMapEntries)
|
||||
const hydratedWithBadges = hydrated.map((entry) => ({
|
||||
...entry,
|
||||
skill: {
|
||||
...entry.skill,
|
||||
badges: badgeMapBySkillId.get(entry.skill._id) ?? {},
|
||||
},
|
||||
}))
|
||||
|
||||
// Skills already have badges from their docs (via toPublicSkill).
|
||||
// No need for a separate badge table lookup.
|
||||
const filtered = args.highlightedOnly
|
||||
? hydratedWithBadges.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: hydratedWithBadges
|
||||
? hydrated.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: hydrated
|
||||
|
||||
exactMatches = filtered.filter((entry) =>
|
||||
matchesExactTokens(queryTokens, [
|
||||
@@ -215,14 +205,6 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
},
|
||||
})
|
||||
|
||||
export const getBadgeMapsForSkills = internalQuery({
|
||||
args: { skillIds: v.array(v.id('skills')) },
|
||||
handler: async (ctx, args): Promise<Array<[Id<'skills'>, SkillBadgeMap]>> => {
|
||||
const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
|
||||
return Array.from(badgeMap.entries())
|
||||
},
|
||||
})
|
||||
|
||||
export const hydrateResults = internalQuery({
|
||||
args: {
|
||||
embeddingIds: v.array(v.id('skillEmbeddings')),
|
||||
@@ -233,21 +215,26 @@ export const hydrateResults = internalQuery({
|
||||
|
||||
const entries: Array<SkillSearchEntry | null> = await Promise.all(
|
||||
args.embeddingIds.map(async (embeddingId) => {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) return null
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
// Use lightweight lookup table (~100 bytes) instead of full embedding doc (~12KB).
|
||||
const lookup = await ctx.db
|
||||
.query('embeddingSkillMap')
|
||||
.withIndex('by_embedding', (q) => q.eq('embeddingId', embeddingId))
|
||||
.unique()
|
||||
// Fallback to full embedding doc for rows not yet backfilled.
|
||||
const skillId = lookup
|
||||
? lookup.skillId
|
||||
: await ctx.db.get(embeddingId).then((e) => e?.skillId)
|
||||
if (!skillId) return null
|
||||
const skill = await ctx.db.get(skillId)
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null
|
||||
const [version, ownerInfo] = await Promise.all([
|
||||
ctx.db.get(embedding.versionId),
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const ownerInfo = await getOwnerInfo(skill.ownerUserId)
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
return {
|
||||
embeddingId,
|
||||
skill: publicSkill,
|
||||
version,
|
||||
version: null as Doc<'skillVersions'> | null,
|
||||
ownerHandle: ownerInfo.handle,
|
||||
owner: ownerInfo.owner,
|
||||
}
|
||||
@@ -309,15 +296,12 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
|
||||
const entries = await Promise.all(
|
||||
matched.map(async (skill) => {
|
||||
const [version, ownerInfo] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : Promise.resolve(null),
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const ownerInfo = await getOwnerInfo(skill.ownerUserId)
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
return {
|
||||
skill: publicSkill,
|
||||
version,
|
||||
version: null as Doc<'skillVersions'> | null,
|
||||
ownerHandle: ownerInfo.handle,
|
||||
owner: ownerInfo.owner,
|
||||
}
|
||||
@@ -326,21 +310,11 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
const validEntries = entries.filter((entry): entry is SkillSearchEntry => entry !== null)
|
||||
if (validEntries.length === 0) return []
|
||||
|
||||
const badgeMap = await getSkillBadgeMaps(
|
||||
ctx,
|
||||
validEntries.map((entry) => entry.skill._id),
|
||||
)
|
||||
const withBadges = validEntries.map((entry) => ({
|
||||
...entry,
|
||||
skill: {
|
||||
...entry.skill,
|
||||
badges: badgeMap.get(entry.skill._id) ?? {},
|
||||
},
|
||||
}))
|
||||
|
||||
// Skills already have badges from their docs (via toPublicSkill).
|
||||
// No need for a separate badge table lookup.
|
||||
const filtered = args.highlightedOnly
|
||||
? withBadges.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: withBadges
|
||||
? validEntries.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: validEntries
|
||||
return filtered.slice(0, limit)
|
||||
},
|
||||
})
|
||||
@@ -440,14 +414,6 @@ export const hydrateSoulResults = internalQuery({
|
||||
},
|
||||
})
|
||||
|
||||
export const getSkillBadgeMapsInternal = internalQuery({
|
||||
args: { skillIds: v.array(v.id('skills')) },
|
||||
handler: async (ctx, args) => {
|
||||
const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
|
||||
return Array.from(badgeMap.entries())
|
||||
},
|
||||
})
|
||||
|
||||
export const __test = {
|
||||
getNextCandidateLimit,
|
||||
matchesAllTokens,
|
||||
|
||||
+23
-60
@@ -3,14 +3,18 @@
|
||||
*
|
||||
* Instead of updating skill stats synchronously in the hot path (which can cause
|
||||
* contention when multiple users download/star/install the same skill), we insert
|
||||
* lightweight event records and process them in batches via a cron job.
|
||||
* lightweight event records and process them in batches via cron jobs.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User action (download, star, install) → insertStatEvent() writes to skillStatEvents table
|
||||
* 2. Cron job runs every 5 minutes → processSkillStatEventsInternal() processes batches
|
||||
* 3. Events are aggregated per-skill to minimize database operations
|
||||
* 4. Stats are applied to skill documents and daily stats tables
|
||||
* 5. Events are marked as processed (kept forever for auditing)
|
||||
* Two processing paths run at different frequencies to balance freshness vs bandwidth:
|
||||
*
|
||||
* 1. **Daily stats (15-minute cron)** — `processSkillStatEventsAction`
|
||||
* Writes to skillDailyStats for trending/leaderboards. Uses a cursor in
|
||||
* skillStatUpdateCursors. Does NOT touch skill documents.
|
||||
*
|
||||
* 2. **Skill doc sync (6-hour cron)** — `processSkillStatEventsInternal`
|
||||
* Patches skill documents with accumulated stat deltas. Uses processedAt
|
||||
* field to track progress. Runs infrequently because patching skill docs
|
||||
* invalidates reactive queries for all subscribers (thundering herd).
|
||||
*/
|
||||
|
||||
import { v } from 'convex/values'
|
||||
@@ -175,7 +179,7 @@ function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
|
||||
/**
|
||||
* Process a batch of unprocessed stat events.
|
||||
*
|
||||
* Called by cron every 5 minutes. Processes up to batchSize events (default 100).
|
||||
* Called by the 6-hour cron to sync stats to skill docs. Processes up to batchSize events (default 500).
|
||||
* If the batch is full, schedules an immediate follow-up run to drain the queue.
|
||||
*
|
||||
* Processing steps:
|
||||
@@ -198,7 +202,7 @@ function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
|
||||
export const processSkillStatEventsInternal = internalMutation({
|
||||
args: { batchSize: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 100
|
||||
const batchSize = args.batchSize ?? 500
|
||||
const now = Date.now()
|
||||
|
||||
// Level 1: Fetch a batch of unprocessed events
|
||||
@@ -252,25 +256,13 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
installsAllTime: deltas.installsAllTime,
|
||||
installsCurrent: deltas.installsCurrent,
|
||||
})
|
||||
await ctx.db.patch(skill._id, {
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
// Don't update `updatedAt` — stat changes shouldn't move the
|
||||
// skill's position in the by_active_updated index.
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
}
|
||||
|
||||
// Update daily stats for trending/leaderboards
|
||||
// We use the ORIGINAL event timestamp (occurredAt) so that:
|
||||
// - A download at Mon 11:55 PM counts toward Monday's stats
|
||||
// - Even if the cron processes it on Tuesday
|
||||
//
|
||||
// Level 4: bumpDailySkillStats does its own coalescing - multiple
|
||||
// events on the same day will update the same daily record
|
||||
for (const occurredAt of deltas.downloadEvents) {
|
||||
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, downloads: 1 })
|
||||
}
|
||||
for (const occurredAt of deltas.installNewEvents) {
|
||||
await bumpDailySkillStats(ctx, { skillId, now: occurredAt, installs: 1 })
|
||||
}
|
||||
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
|
||||
// action cron (processSkillStatEventsAction), not here.
|
||||
|
||||
// Mark all events for this skill as processed
|
||||
for (const event of skillEvents) {
|
||||
@@ -352,11 +344,11 @@ const skillDeltaValidator = v.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Apply aggregated stats to skills and update the cursor.
|
||||
* Write aggregated daily stats and advance the cursor.
|
||||
* This is a single atomic mutation that:
|
||||
* 1. Updates all affected skills with their aggregated deltas
|
||||
* 2. Updates daily stats for trending
|
||||
* 3. Advances the cursor to the new position
|
||||
* 1. Updates daily stats for trending/leaderboards (skillDailyStats)
|
||||
* 2. Advances the cursor to the new position
|
||||
* NOTE: Does NOT patch skill documents — that's handled by processSkillStatEventsInternal.
|
||||
*/
|
||||
export const applyAggregatedStatsAndUpdateCursor = internalMutation({
|
||||
args: {
|
||||
@@ -366,37 +358,8 @@ export const applyAggregatedStatsAndUpdateCursor = internalMutation({
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
|
||||
// Process each skill's aggregated deltas
|
||||
// Update daily stats for trending/leaderboards
|
||||
for (const delta of args.skillDeltas) {
|
||||
const skill = await ctx.db.get(delta.skillId)
|
||||
|
||||
// Skill was deleted - skip
|
||||
if (!skill) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply aggregated deltas to skill stats
|
||||
if (
|
||||
delta.downloads !== 0 ||
|
||||
delta.stars !== 0 ||
|
||||
delta.comments !== 0 ||
|
||||
delta.installsAllTime !== 0 ||
|
||||
delta.installsCurrent !== 0
|
||||
) {
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
downloads: delta.downloads,
|
||||
stars: delta.stars,
|
||||
comments: delta.comments,
|
||||
installsAllTime: delta.installsAllTime,
|
||||
installsCurrent: delta.installsCurrent,
|
||||
})
|
||||
await ctx.db.patch(skill._id, {
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Update daily stats for trending/leaderboards
|
||||
for (const occurredAt of delta.downloadEvents) {
|
||||
await bumpDailySkillStats(ctx, { skillId: delta.skillId, now: occurredAt, downloads: 1 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { countPublicSkills } from './skills'
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>
|
||||
}
|
||||
|
||||
const countPublicSkillsHandler = (
|
||||
countPublicSkills as unknown as WrappedHandler<Record<string, never>, number>
|
||||
)._handler
|
||||
|
||||
function makeSkillsQuery(skills: Array<{ softDeletedAt?: number; moderationStatus?: string | null }>) {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_active_updated') throw new Error(`unexpected skills index ${name}`)
|
||||
return {
|
||||
collect: async () => skills,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('skills.countPublicSkills', () => {
|
||||
it('returns precomputed global stats count when available', async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'globalStats') {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({ _id: 'globalStats:1', activeSkillsCount: 123 }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'skills') {
|
||||
return makeSkillsQuery([])
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await countPublicSkillsHandler(ctx, {})
|
||||
expect(result).toBe(123)
|
||||
})
|
||||
|
||||
it('falls back to live count when global stats row is missing', async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'globalStats') {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'skills') {
|
||||
return makeSkillsQuery([
|
||||
{ softDeletedAt: undefined, moderationStatus: 'active' },
|
||||
{ softDeletedAt: undefined, moderationStatus: 'hidden' },
|
||||
{ softDeletedAt: undefined, moderationStatus: 'active' },
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await countPublicSkillsHandler(ctx, {})
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('falls back to live count when globalStats table is unavailable', async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'globalStats') {
|
||||
throw new Error('unexpected table globalStats')
|
||||
}
|
||||
if (table === 'skills') {
|
||||
return makeSkillsQuery([
|
||||
{ softDeletedAt: undefined, moderationStatus: 'active' },
|
||||
{ softDeletedAt: undefined, moderationStatus: 'active' },
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await countPublicSkillsHandler(ctx, {})
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
/* @vitest-environment node */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getSkillBadgeMapMock, getSkillBadgeMapsMock, isSkillHighlightedMock } = vi.hoisted(() => ({
|
||||
getSkillBadgeMapMock: vi.fn(),
|
||||
getSkillBadgeMapsMock: vi.fn(),
|
||||
isSkillHighlightedMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/badges', () => ({
|
||||
getSkillBadgeMap: getSkillBadgeMapMock,
|
||||
getSkillBadgeMaps: getSkillBadgeMapsMock,
|
||||
isSkillHighlighted: isSkillHighlightedMock,
|
||||
}))
|
||||
|
||||
import { listPublicPageV2 } from './skills'
|
||||
|
||||
type ListArgs = {
|
||||
paginationOpts: { cursor: string | null; numItems: number; id?: number }
|
||||
sort?: 'newest' | 'updated' | 'downloads' | 'installs' | 'stars' | 'name'
|
||||
dir?: 'asc' | 'desc'
|
||||
highlightedOnly?: boolean
|
||||
nonSuspiciousOnly?: boolean
|
||||
}
|
||||
|
||||
type ListResult = {
|
||||
page: Array<{ skill: { slug: string } }>
|
||||
continueCursor: string | null
|
||||
isDone: boolean
|
||||
}
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>
|
||||
}
|
||||
|
||||
const listPublicPageV2Handler = (listPublicPageV2 as unknown as WrappedHandler<ListArgs, ListResult>)
|
||||
._handler
|
||||
|
||||
describe('skills.listPublicPageV2', () => {
|
||||
beforeEach(() => {
|
||||
getSkillBadgeMapMock.mockReset()
|
||||
getSkillBadgeMapsMock.mockReset()
|
||||
getSkillBadgeMapsMock.mockResolvedValue(new Map())
|
||||
isSkillHighlightedMock.mockReset()
|
||||
isSkillHighlightedMock.mockImplementation((skill: { slug?: string }) =>
|
||||
Boolean(skill.slug?.startsWith('hl-')),
|
||||
)
|
||||
})
|
||||
|
||||
it('applies highlightedOnly and nonSuspiciousOnly together', async () => {
|
||||
const highlightedClean = makeSkill('skills:hl-clean', 'hl-clean', 'users:1', 'skillVersions:1')
|
||||
const plainClean = makeSkill('skills:plain', 'plain', 'users:2', 'skillVersions:2')
|
||||
const highlightedSuspicious = makeSkill(
|
||||
'skills:hl-suspicious',
|
||||
'hl-suspicious',
|
||||
'users:3',
|
||||
'skillVersions:3',
|
||||
['flagged.suspicious'],
|
||||
)
|
||||
|
||||
const paginateMock = vi.fn().mockResolvedValue({
|
||||
page: [highlightedClean, plainClean, highlightedSuspicious],
|
||||
continueCursor: 'next-cursor',
|
||||
isDone: false,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
const orderMock = vi.fn(() => ({ paginate: paginateMock }))
|
||||
const eqMock = vi.fn(() => ({}))
|
||||
const withIndexMock = vi.fn((_index: string, builder: (q: { eq: typeof eqMock }) => unknown) => {
|
||||
builder({ eq: eqMock })
|
||||
return { order: orderMock }
|
||||
})
|
||||
const getMock = vi.fn(async (id: string) => {
|
||||
if (id.startsWith('users:')) return makeUser(id)
|
||||
if (id.startsWith('skillVersions:')) return makeVersion(id)
|
||||
return null
|
||||
})
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'skills') throw new Error(`unexpected table ${table}`)
|
||||
return { withIndex: withIndexMock }
|
||||
}),
|
||||
get: getMock,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: true,
|
||||
nonSuspiciousOnly: true,
|
||||
})
|
||||
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(result.page[0]?.skill.slug).toBe('hl-clean')
|
||||
expect(result.continueCursor).toBe('next-cursor')
|
||||
expect(result.isDone).toBe(false)
|
||||
expect(withIndexMock).toHaveBeenCalledWith('by_active_stats_downloads', expect.any(Function))
|
||||
expect(orderMock).toHaveBeenCalledWith('desc')
|
||||
expect(paginateMock).toHaveBeenCalledWith({ cursor: null, numItems: 25 })
|
||||
expect(eqMock).toHaveBeenCalledWith('softDeletedAt', undefined)
|
||||
})
|
||||
|
||||
it('skips fully filtered pages until it finds matching skills', async () => {
|
||||
const plain = makeSkill('skills:plain', 'plain', 'users:1', 'skillVersions:1')
|
||||
const highlightedClean = makeSkill('skills:hl-clean', 'hl-clean', 'users:2', 'skillVersions:2')
|
||||
const paginateMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
page: [plain],
|
||||
continueCursor: 'next-cursor',
|
||||
isDone: false,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
page: [highlightedClean],
|
||||
continueCursor: 'after-highlighted',
|
||||
isDone: false,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({ paginate: paginateMock })),
|
||||
})),
|
||||
})),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id.startsWith('users:')) return makeUser(id)
|
||||
if (id.startsWith('skillVersions:')) return makeVersion(id)
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: true,
|
||||
nonSuspiciousOnly: false,
|
||||
})
|
||||
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(result.page[0]?.skill.slug).toBe('hl-clean')
|
||||
expect(result.continueCursor).toBe('after-highlighted')
|
||||
expect(result.isDone).toBe(false)
|
||||
expect(paginateMock).toHaveBeenCalledTimes(2)
|
||||
expect(paginateMock).toHaveBeenNthCalledWith(1, { cursor: null, numItems: 25 })
|
||||
expect(paginateMock).toHaveBeenNthCalledWith(2, { cursor: 'next-cursor', numItems: 25 })
|
||||
})
|
||||
|
||||
it('returns exhausted when filtered pages remain empty to the end', async () => {
|
||||
const plain = makeSkill('skills:plain', 'plain', 'users:1', 'skillVersions:1')
|
||||
const paginateMock = vi.fn().mockResolvedValue({
|
||||
page: [plain],
|
||||
continueCursor: null,
|
||||
isDone: true,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({ paginate: paginateMock })),
|
||||
})),
|
||||
})),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 25 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: true,
|
||||
nonSuspiciousOnly: false,
|
||||
})
|
||||
|
||||
expect(result.page).toEqual([])
|
||||
expect(result.continueCursor).toBeNull()
|
||||
expect(result.isDone).toBe(true)
|
||||
expect(paginateMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restarts pagination from first page when cursor is stale', async () => {
|
||||
const plain = makeSkill('skills:plain', 'plain', 'users:1', 'skillVersions:1')
|
||||
const paginateMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Failed to parse cursor'))
|
||||
.mockResolvedValueOnce({
|
||||
page: [plain],
|
||||
continueCursor: 'next-cursor',
|
||||
isDone: false,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({ paginate: paginateMock })),
|
||||
})),
|
||||
})),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id.startsWith('users:')) return makeUser(id)
|
||||
if (id.startsWith('skillVersions:')) return makeVersion(id)
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: 'stale-cursor', numItems: 25, id: 123456 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: false,
|
||||
nonSuspiciousOnly: false,
|
||||
})
|
||||
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(result.page[0]?.skill.slug).toBe('plain')
|
||||
expect(result.continueCursor).toBe('next-cursor')
|
||||
expect(result.isDone).toBe(false)
|
||||
expect(paginateMock).toHaveBeenNthCalledWith(1, { cursor: 'stale-cursor', numItems: 25 })
|
||||
expect(paginateMock).toHaveBeenNthCalledWith(2, { cursor: null, numItems: 25 })
|
||||
expect(paginateMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('drops pagination id from client options on first-page queries', async () => {
|
||||
const plain = makeSkill('skills:plain', 'plain', 'users:1', 'skillVersions:1')
|
||||
const paginateMock = vi.fn().mockResolvedValue({
|
||||
page: [plain],
|
||||
continueCursor: 'next-cursor',
|
||||
isDone: false,
|
||||
pageStatus: null,
|
||||
splitCursor: null,
|
||||
})
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({ paginate: paginateMock })),
|
||||
})),
|
||||
})),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id.startsWith('users:')) return makeUser(id)
|
||||
if (id.startsWith('skillVersions:')) return makeVersion(id)
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: null, numItems: 25, id: 999_999_999 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: false,
|
||||
nonSuspiciousOnly: false,
|
||||
})
|
||||
|
||||
expect(result.page).toHaveLength(1)
|
||||
expect(paginateMock).toHaveBeenCalledTimes(1)
|
||||
expect(paginateMock).toHaveBeenCalledWith({ cursor: null, numItems: 25 })
|
||||
expect(paginateMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not swallow non-cursor paginate errors', async () => {
|
||||
const paginateMock = vi.fn().mockRejectedValue(new Error('database unavailable'))
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({ paginate: paginateMock })),
|
||||
})),
|
||||
})),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
listPublicPageV2Handler(ctx, {
|
||||
paginationOpts: { cursor: 'stale-cursor', numItems: 25, id: 999_999_999 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
highlightedOnly: false,
|
||||
nonSuspiciousOnly: false,
|
||||
}),
|
||||
).rejects.toThrow('database unavailable')
|
||||
|
||||
expect(paginateMock).toHaveBeenCalledTimes(1)
|
||||
expect(paginateMock).toHaveBeenCalledWith({ cursor: 'stale-cursor', numItems: 25 })
|
||||
})
|
||||
})
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
slug: string,
|
||||
ownerUserId: string,
|
||||
latestVersionId: string,
|
||||
moderationFlags?: string[],
|
||||
) {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
slug,
|
||||
displayName: slug,
|
||||
summary: `${slug} summary`,
|
||||
ownerUserId,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags,
|
||||
}
|
||||
}
|
||||
|
||||
function makeUser(id: string) {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: 'owner',
|
||||
name: 'Owner',
|
||||
displayName: 'Owner',
|
||||
image: null,
|
||||
bio: null,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function makeVersion(id: string) {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
version: '1.0.0',
|
||||
createdAt: 1,
|
||||
changelog: '',
|
||||
changelogSource: 'user',
|
||||
parsed: {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getPendingScanSkillsInternal } from './skills'
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string
|
||||
versionId: string | null
|
||||
sha256hash: string | null
|
||||
checkCount: number
|
||||
}>
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>
|
||||
}
|
||||
|
||||
const getPendingScanSkillsHandler = (
|
||||
getPendingScanSkillsInternal as unknown as WrappedHandler<Record<string, unknown>, PendingScanResult>
|
||||
)._handler
|
||||
|
||||
describe('skills.getPendingScanSkillsInternal', () => {
|
||||
it('includes unresolved VT records from the oldest slice and skips finalized ones', async () => {
|
||||
const recentSkills = [
|
||||
makeSkill('skills:recent-clean', 'skillVersions:recent-clean', 'scanner.llm.clean'),
|
||||
makeSkill('skills:recent-malicious', 'skillVersions:recent-malicious', 'scanner.vt.pending'),
|
||||
]
|
||||
const oldestSkills = [
|
||||
makeSkill('skills:old-pending', 'skillVersions:old-pending', 'scanner.vt.pending'),
|
||||
makeSkill('skills:old-stale', 'skillVersions:old-stale', 'scanner.llm.clean'),
|
||||
makeSkill('skills:old-no-hash', 'skillVersions:old-no-hash', 'scanner.vt.pending'),
|
||||
]
|
||||
|
||||
const versions = new Map<string, unknown>([
|
||||
[
|
||||
'skillVersions:recent-clean',
|
||||
{ _id: 'skillVersions:recent-clean', sha256hash: 'a'.repeat(64), vtAnalysis: { status: 'clean' } },
|
||||
],
|
||||
[
|
||||
'skillVersions:recent-malicious',
|
||||
{
|
||||
_id: 'skillVersions:recent-malicious',
|
||||
sha256hash: 'b'.repeat(64),
|
||||
vtAnalysis: { status: 'malicious' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'skillVersions:old-pending',
|
||||
{ _id: 'skillVersions:old-pending', sha256hash: 'c'.repeat(64), vtAnalysis: { status: 'pending' } },
|
||||
],
|
||||
[
|
||||
'skillVersions:old-stale',
|
||||
{ _id: 'skillVersions:old-stale', sha256hash: 'd'.repeat(64), vtAnalysis: { status: 'stale' } },
|
||||
],
|
||||
['skillVersions:old-no-hash', { _id: 'skillVersions:old-no-hash' }],
|
||||
])
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'skills') throw new Error(`unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ eq: () => ({}) })
|
||||
if (indexName === 'by_active_updated') {
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => recentSkills,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (indexName === 'by_active_created') {
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => oldestSkills,
|
||||
}),
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected index ${indexName}`)
|
||||
},
|
||||
}
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await getPendingScanSkillsHandler(ctx, {
|
||||
limit: 25,
|
||||
skipRecentMinutes: 0,
|
||||
})
|
||||
|
||||
const ids = new Set(result.map((entry) => entry.skillId))
|
||||
expect(ids.has('skills:old-pending')).toBe(true)
|
||||
expect(ids.has('skills:old-stale')).toBe(true)
|
||||
expect(ids.has('skills:recent-clean')).toBe(false)
|
||||
expect(ids.has('skills:recent-malicious')).toBe(false)
|
||||
expect(ids.has('skills:old-no-hash')).toBe(false)
|
||||
})
|
||||
|
||||
it('exhaustive mode ignores recent-check suppression for manual backfills', async () => {
|
||||
const now = Date.now()
|
||||
const allSkills = [
|
||||
makeSkill('skills:recently-checked', 'skillVersions:recently-checked', 'scanner.vt.pending', now),
|
||||
]
|
||||
const versions = new Map<string, unknown>([
|
||||
[
|
||||
'skillVersions:recently-checked',
|
||||
{ _id: 'skillVersions:recently-checked', sha256hash: 'e'.repeat(64) },
|
||||
],
|
||||
])
|
||||
|
||||
const withIndex = vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ eq: () => ({}) })
|
||||
if (indexName !== 'by_active_updated') throw new Error(`unexpected index ${indexName}`)
|
||||
return {
|
||||
collect: async () => allSkills,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'skills') throw new Error(`unexpected table ${table}`)
|
||||
return { withIndex }
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await getPendingScanSkillsHandler(ctx, {
|
||||
limit: 25,
|
||||
skipRecentMinutes: 60,
|
||||
exhaustive: true,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.skillId).toBe('skills:recently-checked')
|
||||
})
|
||||
|
||||
it('does not clamp exhaustive mode to 100 records', async () => {
|
||||
const allSkills = Array.from({ length: 150 }, (_, i) =>
|
||||
makeSkill(`skills:bulk-${i}`, `skillVersions:bulk-${i}`, 'scanner.vt.pending'),
|
||||
)
|
||||
const versions = new Map<string, unknown>(
|
||||
allSkills.map((skill) => {
|
||||
const versionId = skill.latestVersionId as string
|
||||
return [versionId, { _id: versionId, sha256hash: `${String(versionId).slice(-8)}${'f'.repeat(56)}` }]
|
||||
}),
|
||||
)
|
||||
|
||||
const withIndex = vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ eq: () => ({}) })
|
||||
if (indexName !== 'by_active_updated') throw new Error(`unexpected index ${indexName}`)
|
||||
return {
|
||||
collect: async () => allSkills,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'skills') throw new Error(`unexpected table ${table}`)
|
||||
return { withIndex }
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await getPendingScanSkillsHandler(ctx, {
|
||||
limit: 10000,
|
||||
exhaustive: true,
|
||||
skipRecentMinutes: 0,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(150)
|
||||
})
|
||||
})
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
versionId: string,
|
||||
moderationReason: string,
|
||||
scanLastCheckedAt?: number,
|
||||
) {
|
||||
return {
|
||||
_id: id,
|
||||
moderationStatus: 'active',
|
||||
moderationReason,
|
||||
latestVersionId: versionId,
|
||||
scanLastCheckedAt,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,21 @@ const clearOwnerSuspiciousFlagsHandler = (
|
||||
clearOwnerSuspiciousFlagsInternal as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler
|
||||
|
||||
function buildGlobalStatsQuery(table: string) {
|
||||
if (table !== 'globalStats') return null
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_key') throw new Error(`unexpected globalStats index ${name}`)
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: 'globalStats:1',
|
||||
activeSkillsCount: 100,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPublishArgs(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
userId: 'users:owner',
|
||||
@@ -67,6 +82,8 @@ describe('skills anti-spam guards', () => {
|
||||
deletedAt: undefined,
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table)
|
||||
if (globalStatsQuery) return globalStatsQuery
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
@@ -103,6 +120,132 @@ describe('skills anti-spam guards', () => {
|
||||
).rejects.toThrow(/max 5 new skills per hour/i)
|
||||
})
|
||||
|
||||
it('returns a user-facing slug-taken message when publishing to another owner slug', async () => {
|
||||
let authAccountLookupCount = 0
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
|
||||
if (id === 'users:owner') {
|
||||
return {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'authAccounts') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'userIdAndProvider') throw new Error(`unexpected auth index ${name}`)
|
||||
return {
|
||||
unique: async () => {
|
||||
authAccountLookupCount += 1
|
||||
return authAccountLookupCount === 1
|
||||
? { providerAccountId: 'owner-gh' }
|
||||
: { providerAccountId: 'caller-gh' }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
insertVersionHandler(
|
||||
{ db } as never,
|
||||
createPublishArgs({
|
||||
userId: 'users:caller',
|
||||
slug: 'taken-skill',
|
||||
}) as never,
|
||||
),
|
||||
).rejects.toThrow('Slug is already taken. Choose a different slug. Existing skill: /alice/taken-skill')
|
||||
})
|
||||
|
||||
it('does not include a URL in slug-taken message when conflicting owner is deleted', async () => {
|
||||
let authAccountLookupCount = 0
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
|
||||
if (id === 'users:owner') {
|
||||
return {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: Date.now(),
|
||||
deactivatedAt: undefined,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'authAccounts') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'userIdAndProvider') throw new Error(`unexpected auth index ${name}`)
|
||||
return {
|
||||
unique: async () => {
|
||||
authAccountLookupCount += 1
|
||||
return authAccountLookupCount === 1
|
||||
? { providerAccountId: 'owner-gh' }
|
||||
: { providerAccountId: 'caller-gh' }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
insertVersionHandler(
|
||||
{ db } as never,
|
||||
createPublishArgs({
|
||||
userId: 'users:caller',
|
||||
slug: 'taken-skill',
|
||||
}) as never,
|
||||
),
|
||||
).rejects.toThrow('Slug is already taken. Choose a different slug.')
|
||||
})
|
||||
|
||||
it('keeps suspicious skills visible for low-trust publishers', async () => {
|
||||
const patch = vi.fn(async () => {})
|
||||
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
|
||||
@@ -127,6 +270,8 @@ describe('skills anti-spam guards', () => {
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table)
|
||||
if (globalStatsQuery) return globalStatsQuery
|
||||
if (table === 'skillVersions') {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
@@ -197,6 +342,8 @@ describe('skills anti-spam guards', () => {
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table)
|
||||
if (globalStatsQuery) return globalStatsQuery
|
||||
if (table === 'skillVersions') {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
@@ -265,6 +412,8 @@ describe('skills anti-spam guards', () => {
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table)
|
||||
if (globalStatsQuery) return globalStatsQuery
|
||||
if (table === 'skillVersions') {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
@@ -324,6 +473,8 @@ describe('skills anti-spam guards', () => {
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table)
|
||||
if (globalStatsQuery) return globalStatsQuery
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reclaimSlugInternal } from './skills'
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>
|
||||
}
|
||||
|
||||
const reclaimSlugInternalHandler = (
|
||||
reclaimSlugInternal as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler
|
||||
|
||||
describe('skills reclaim ownership transfer', () => {
|
||||
it('transfers ownership in-place when transferRootSlugOnly is true', async () => {
|
||||
const now = Date.now()
|
||||
const patch = vi.fn(async () => {})
|
||||
const insert = vi.fn(async () => {})
|
||||
const runAfter = vi.fn(async () => {})
|
||||
|
||||
const existingSkill = {
|
||||
_id: 'skills:1',
|
||||
slug: 'capability-evolver',
|
||||
ownerUserId: 'users:old',
|
||||
}
|
||||
const activeReservation = {
|
||||
_id: 'reservedSlugs:1',
|
||||
slug: 'capability-evolver',
|
||||
originalOwnerUserId: 'users:old',
|
||||
deletedAt: now - 1_000,
|
||||
expiresAt: now + 10_000,
|
||||
}
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === 'users:admin') return { _id: 'users:admin', role: 'admin' }
|
||||
if (id === 'users:new') return { _id: 'users:new', role: 'user' }
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
|
||||
return { unique: async () => existingSkill }
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'skillEmbeddings') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_skill') throw new Error(`unexpected embeddings index ${name}`)
|
||||
return {
|
||||
collect: async () => [{ _id: 'skillEmbeddings:1', skillId: 'skills:1', ownerId: 'users:old' }],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'reservedSlugs') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug_active_deletedAt') {
|
||||
throw new Error(`unexpected reservedSlugs index ${name}`)
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => [activeReservation],
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
}
|
||||
|
||||
const result = (await reclaimSlugInternalHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
slug: 'Capability-Evolver',
|
||||
rightfulOwnerUserId: 'users:new',
|
||||
transferRootSlugOnly: true,
|
||||
} as never,
|
||||
)) as { ok: boolean; action: string }
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'ownership_transferred' })
|
||||
expect(runAfter).not.toHaveBeenCalled()
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'skills:1',
|
||||
expect.objectContaining({
|
||||
ownerUserId: 'users:new',
|
||||
}),
|
||||
)
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'skillEmbeddings:1',
|
||||
expect.objectContaining({
|
||||
ownerId: 'users:new',
|
||||
}),
|
||||
)
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'reservedSlugs:1',
|
||||
expect.objectContaining({
|
||||
releasedAt: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns missing without reserving when transferRootSlugOnly is true and slug does not exist', async () => {
|
||||
const insert = vi.fn(async () => {})
|
||||
const patch = vi.fn(async () => {})
|
||||
const runAfter = vi.fn(async () => {})
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === 'users:admin') return { _id: 'users:admin', role: 'admin' }
|
||||
if (id === 'users:new') return { _id: 'users:new', role: 'user' }
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
|
||||
return { unique: async () => null }
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
}
|
||||
|
||||
const result = (await reclaimSlugInternalHandler(
|
||||
{ db, scheduler: { runAfter } } as never,
|
||||
{
|
||||
actorUserId: 'users:admin',
|
||||
slug: 'missing-slug',
|
||||
rightfulOwnerUserId: 'users:new',
|
||||
transferRootSlugOnly: true,
|
||||
} as never,
|
||||
)) as { ok: boolean; action: string }
|
||||
|
||||
expect(result).toEqual({ ok: true, action: 'missing' })
|
||||
expect(runAfter).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatReservedSlugCooldownMessage } from './lib/reservedSlugs'
|
||||
|
||||
vi.mock('@convex-dev/auth/server', () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { checkSlugAvailability } from './skills'
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>
|
||||
}
|
||||
|
||||
type SkillDoc = {
|
||||
_id: string
|
||||
slug: string
|
||||
ownerUserId: string
|
||||
softDeletedAt?: number
|
||||
moderationStatus?: 'active' | 'hidden' | 'removed'
|
||||
moderationFlags?: string[]
|
||||
}
|
||||
|
||||
type ReservationDoc = {
|
||||
_id: string
|
||||
slug: string
|
||||
originalOwnerUserId: string
|
||||
deletedAt: number
|
||||
expiresAt: number
|
||||
releasedAt?: number
|
||||
}
|
||||
|
||||
const checkSlugAvailabilityHandler = (
|
||||
checkSlugAvailability as unknown as WrappedHandler<{ slug: string }>
|
||||
)._handler
|
||||
|
||||
function createCtx(options: {
|
||||
skill: SkillDoc | null
|
||||
reservation?: ReservationDoc | null
|
||||
owner?: { _id: string; handle?: string | null; deletedAt?: number; deactivatedAt?: number } | null
|
||||
callerId?: string
|
||||
ownerProviderAccountId?: string | null
|
||||
callerProviderAccountId?: string | null
|
||||
}) {
|
||||
const callerId = options.callerId ?? 'users:caller'
|
||||
let authAccountLookupCount = 0
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === callerId) {
|
||||
return { _id: callerId, deletedAt: undefined, deactivatedAt: undefined }
|
||||
}
|
||||
if (options.owner && id === options.owner._id) return options.owner
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === 'skills') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
|
||||
return {
|
||||
unique: async () => options.skill,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'reservedSlugs') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug_active_deletedAt') {
|
||||
throw new Error(`unexpected reservedSlugs index ${name}`)
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => (options.reservation ? [options.reservation] : []),
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'authAccounts') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'userIdAndProvider') {
|
||||
throw new Error(`unexpected authAccounts index ${name}`)
|
||||
}
|
||||
return {
|
||||
unique: async () => {
|
||||
authAccountLookupCount += 1
|
||||
if (authAccountLookupCount === 1) {
|
||||
return options.ownerProviderAccountId
|
||||
? { providerAccountId: options.ownerProviderAccountId }
|
||||
: null
|
||||
}
|
||||
return options.callerProviderAccountId
|
||||
? { providerAccountId: options.callerProviderAccountId }
|
||||
: null
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
}
|
||||
|
||||
return { db }
|
||||
}
|
||||
|
||||
describe('skills.checkSlugAvailability', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns taken without URL for non-public collisions', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: 123,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
},
|
||||
ownerProviderAccountId: 'owner-gh',
|
||||
callerProviderAccountId: 'caller-gh',
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: false,
|
||||
reason: 'taken',
|
||||
message: 'Slug is already taken. Choose a different slug.',
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns taken with URL for public collisions', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
},
|
||||
ownerProviderAccountId: 'owner-gh',
|
||||
callerProviderAccountId: 'caller-gh',
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: false,
|
||||
reason: 'taken',
|
||||
message: 'Slug is already taken. Choose a different slug. Existing skill: /alice/taken-skill',
|
||||
url: '/alice/taken-skill',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns taken without requiring auth context', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
},
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: false,
|
||||
reason: 'taken',
|
||||
message: 'Slug is already taken. Choose a different slug. Existing skill: /alice/taken-skill',
|
||||
url: '/alice/taken-skill',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns available when slug belongs to current user', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:caller',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
},
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: true,
|
||||
reason: 'available',
|
||||
message: null,
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns reserved when active reservation belongs to another user', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: null,
|
||||
reservation: {
|
||||
_id: 'reservedSlugs:1',
|
||||
slug: 'taken-skill',
|
||||
originalOwnerUserId: 'users:owner',
|
||||
deletedAt: now - 1_000,
|
||||
expiresAt: now + 60_000,
|
||||
releasedAt: undefined,
|
||||
},
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: false,
|
||||
reason: 'reserved',
|
||||
message: formatReservedSlugCooldownMessage('taken-skill', now + 60_000),
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns reserved without requiring auth context', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: null,
|
||||
reservation: {
|
||||
_id: 'reservedSlugs:1',
|
||||
slug: 'taken-skill',
|
||||
originalOwnerUserId: 'users:owner',
|
||||
deletedAt: now - 1_000,
|
||||
expiresAt: now + 60_000,
|
||||
releasedAt: undefined,
|
||||
},
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: false,
|
||||
reason: 'reserved',
|
||||
message: formatReservedSlugCooldownMessage('taken-skill', now + 60_000),
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns available when reservation has expired', async () => {
|
||||
const now = 1_700_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: null,
|
||||
reservation: {
|
||||
_id: 'reservedSlugs:1',
|
||||
slug: 'taken-skill',
|
||||
originalOwnerUserId: 'users:owner',
|
||||
deletedAt: now - 120_000,
|
||||
expiresAt: now - 60_000,
|
||||
releasedAt: undefined,
|
||||
},
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: true,
|
||||
reason: 'available',
|
||||
message: null,
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns available when ownership can be healed via shared GitHub identity', async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
|
||||
|
||||
const result = (await checkSlugAvailabilityHandler(
|
||||
createCtx({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'taken-skill',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:owner',
|
||||
handle: 'alice',
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
},
|
||||
ownerProviderAccountId: 'shared-gh',
|
||||
callerProviderAccountId: 'shared-gh',
|
||||
}) as never,
|
||||
{ slug: 'taken-skill' } as never,
|
||||
)) as {
|
||||
available: boolean
|
||||
reason: string
|
||||
message: string | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
available: true,
|
||||
reason: 'available',
|
||||
message: null,
|
||||
url: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
+465
-72
@@ -17,21 +17,34 @@ import {
|
||||
getSkillBadgeMap,
|
||||
getSkillBadgeMaps,
|
||||
isSkillHighlighted,
|
||||
type SkillBadgeMap,
|
||||
} from './lib/badges'
|
||||
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
|
||||
import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
} from './lib/githubIdentity'
|
||||
import {
|
||||
adjustGlobalPublicSkillsCount,
|
||||
countPublicSkillsForGlobalStats,
|
||||
getPublicSkillVisibilityDelta,
|
||||
isPublicSkillDoc,
|
||||
readGlobalPublicSkillsCount,
|
||||
} from './lib/globalStats'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
import { deriveModerationFlags } from './lib/moderation'
|
||||
import { toPublicSkill, toPublicUser } from './lib/public'
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
MAX_REPORT_REASON_LENGTH,
|
||||
} from './lib/reporting'
|
||||
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
|
||||
import { scheduleNextBatchIfNeeded } from './lib/batching'
|
||||
import {
|
||||
enforceReservedSlugCooldownForNewSkill,
|
||||
formatReservedSlugCooldownMessage,
|
||||
getLatestActiveReservedSlug,
|
||||
listActiveReservedSlugsForSlug,
|
||||
reserveSlugForHardDeleteFinalize,
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from './lib/reservedSlugs'
|
||||
@@ -54,13 +67,10 @@ const MAX_LIST_LIMIT = 50
|
||||
const MAX_PUBLIC_LIST_LIMIT = 200
|
||||
const MAX_LIST_BULK_LIMIT = 200
|
||||
const MAX_LIST_TAKE = 1000
|
||||
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
|
||||
@@ -111,12 +121,36 @@ function stripSuspiciousFlag(flags: string[] | undefined) {
|
||||
return next.length ? next : undefined
|
||||
}
|
||||
|
||||
function buildConflictingSkillUrl(skill: Doc<'skills'>, owner: Doc<'users'> | null | undefined) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id)
|
||||
if (!ownerParam) return null
|
||||
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`
|
||||
}
|
||||
|
||||
function buildSlugTakenErrorMessage(skill: Doc<'skills'>, owner: Doc<'users'> | null | undefined) {
|
||||
const base = 'Slug is already taken. Choose a different slug.'
|
||||
const url = buildConflictingSkillUrl(skill, owner)
|
||||
if (!url) return base
|
||||
return `${base} Existing skill: ${url}`
|
||||
}
|
||||
|
||||
function normalizeScannerSuspiciousReason(reason: string | undefined) {
|
||||
if (!reason) return reason
|
||||
if (!reason.startsWith('scanner.') || !reason.endsWith('.suspicious')) return reason
|
||||
return `${reason.slice(0, -'.suspicious'.length)}.clean`
|
||||
}
|
||||
|
||||
async function adjustGlobalPublicCountForSkillChange(
|
||||
ctx: MutationCtx,
|
||||
previousSkill: Doc<'skills'> | null | undefined,
|
||||
nextSkill: Doc<'skills'> | null | undefined,
|
||||
) {
|
||||
const delta = getPublicSkillVisibilityDelta(previousSkill, nextSkill)
|
||||
if (delta === 0) return
|
||||
await adjustGlobalPublicSkillsCount(ctx, delta)
|
||||
}
|
||||
|
||||
async function getOwnerTrustSignals(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
owner: Doc<'users'>,
|
||||
@@ -171,6 +205,7 @@ const HARD_DELETE_PHASES = [
|
||||
'fingerprints',
|
||||
'embeddings',
|
||||
'comments',
|
||||
'commentReports',
|
||||
'reports',
|
||||
'stars',
|
||||
'badges',
|
||||
@@ -219,7 +254,9 @@ async function hardDeleteSkillStep(
|
||||
if (Object.keys(patch).length) {
|
||||
patch.lastReviewedAt = now
|
||||
patch.updatedAt = now
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -280,6 +317,21 @@ async function hardDeleteSkillStep(
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'comments')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'commentReports')
|
||||
return
|
||||
}
|
||||
case 'commentReports': {
|
||||
const commentReports = await ctx.db
|
||||
.query('commentReports')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const report of commentReports) {
|
||||
await ctx.db.delete(report._id)
|
||||
}
|
||||
if (commentReports.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'commentReports')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'reports')
|
||||
return
|
||||
}
|
||||
@@ -476,8 +528,10 @@ type PublicSkillListVersion = Pick<
|
||||
> & {
|
||||
parsed?: {
|
||||
clawdis?: {
|
||||
os?: string[]
|
||||
nix?: {
|
||||
plugin?: boolean
|
||||
systems?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,18 +545,16 @@ type ManagementSkillEntry = {
|
||||
|
||||
type BadgeKind = Doc<'skillBadges'>['kind']
|
||||
|
||||
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
async function buildPublicSkillEntries(
|
||||
ctx: QueryCtx,
|
||||
skills: Doc<'skills'>[],
|
||||
opts?: { includeVersion?: boolean },
|
||||
) {
|
||||
const includeVersion = opts?.includeVersion ?? true
|
||||
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(
|
||||
ctx,
|
||||
skills.map((skill) => skill._id),
|
||||
)
|
||||
: new Map()
|
||||
|
||||
const getOwnerInfo = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerInfoCache.get(ownerUserId)
|
||||
@@ -523,11 +575,10 @@ async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
const entries = await Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const [latestVersionDoc, ownerInfo] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
includeVersion && skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const badges = badgeMapBySkillId.get(skill._id) ?? {}
|
||||
const publicSkill = toPublicSkill({ ...skill, badges })
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
const latestVersion = toPublicSkillListVersion(latestVersionDoc)
|
||||
return {
|
||||
@@ -626,14 +677,24 @@ async function upsertSkillBadge(
|
||||
.unique()
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at })
|
||||
return existing._id
|
||||
} else {
|
||||
await ctx.db.insert('skillBadges', {
|
||||
skillId,
|
||||
kind,
|
||||
byUserId: userId,
|
||||
at,
|
||||
})
|
||||
}
|
||||
// Keep denormalized badges field on skill doc in sync
|
||||
const skill = await ctx.db.get(skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skillId, {
|
||||
badges: {
|
||||
...(skill.badges as Record<string, unknown> | undefined),
|
||||
[kind]: { byUserId: userId, at },
|
||||
},
|
||||
})
|
||||
}
|
||||
return ctx.db.insert('skillBadges', {
|
||||
skillId,
|
||||
kind,
|
||||
byUserId: userId,
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
async function removeSkillBadge(ctx: MutationCtx, skillId: Id<'skills'>, kind: BadgeKind) {
|
||||
@@ -644,6 +705,12 @@ async function removeSkillBadge(ctx: MutationCtx, skillId: Id<'skills'>, kind: B
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id)
|
||||
}
|
||||
// Keep denormalized badges field on skill doc in sync
|
||||
const skill = await ctx.db.get(skillId)
|
||||
if (skill) {
|
||||
const { [kind]: _, ...remainingBadges } = (skill.badges ?? {}) as Record<string, unknown>
|
||||
await ctx.db.patch(skillId, { badges: remainingBadges })
|
||||
}
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
@@ -749,6 +816,99 @@ export const getBySlug = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const checkSlugAvailability = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
if (!slug) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'taken' as const,
|
||||
message: 'Slug is required.',
|
||||
url: null,
|
||||
}
|
||||
}
|
||||
|
||||
const skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slug))
|
||||
.unique()
|
||||
|
||||
if (!skill) {
|
||||
const reservation = await getLatestActiveReservedSlug(ctx, slug)
|
||||
if (
|
||||
reservation &&
|
||||
reservation.expiresAt > Date.now() &&
|
||||
reservation.originalOwnerUserId !== userId
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'reserved' as const,
|
||||
message: formatReservedSlugCooldownMessage(slug, reservation.expiresAt),
|
||||
url: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'available' as const,
|
||||
message: null,
|
||||
url: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (userId && skill.ownerUserId === userId) {
|
||||
return {
|
||||
available: true,
|
||||
reason: 'available' as const,
|
||||
message: null,
|
||||
url: null,
|
||||
}
|
||||
}
|
||||
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
const url = buildConflictingSkillUrl(skill, owner)
|
||||
const slugTakenMessage = buildSlugTakenErrorMessage(skill, owner)
|
||||
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'taken' as const,
|
||||
message: slugTakenMessage,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
|
||||
getGitHubProviderAccountId(ctx, skill.ownerUserId),
|
||||
getGitHubProviderAccountId(ctx, userId),
|
||||
])
|
||||
|
||||
if (
|
||||
canHealSkillOwnershipByGitHubProviderAccountId(
|
||||
ownerProviderAccountId,
|
||||
callerProviderAccountId,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
available: true,
|
||||
reason: 'available' as const,
|
||||
message: null,
|
||||
url: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: false,
|
||||
reason: 'taken' as const,
|
||||
message: slugTakenMessage,
|
||||
url,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getBySlugForStaff = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -887,7 +1047,9 @@ export const clearOwnerSuspiciousFlagsInternal = internalMutation({
|
||||
patch.moderationStatus = 'active'
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
updated += 1
|
||||
}
|
||||
|
||||
@@ -1440,7 +1602,7 @@ export const report = mutation({
|
||||
await ctx.db.insert('skillReports', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
reason: reason.slice(0, 500),
|
||||
reason: reason.slice(0, MAX_REPORT_REASON_LENGTH),
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
@@ -1462,7 +1624,9 @@ export const report = mutation({
|
||||
})
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...updates }
|
||||
await ctx.db.patch(skill._id, updates)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now)
|
||||
@@ -1563,34 +1727,97 @@ export const listPublicPageV2 = query({
|
||||
),
|
||||
),
|
||||
dir: v.optional(v.union(v.literal('asc'), v.literal('desc'))),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const sort = args.sort ?? 'newest'
|
||||
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),
|
||||
}
|
||||
const { numItems, cursor: initialCursor } = normalizePublicListPagination(args.paginationOpts)
|
||||
|
||||
const runPaginate = (cursor: string | null) =>
|
||||
ctx.db
|
||||
.query('skills')
|
||||
.withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
|
||||
.order(dir)
|
||||
.paginate({ cursor, numItems })
|
||||
|
||||
// Use the index to filter out soft-deleted skills at query time.
|
||||
// softDeletedAt === undefined means active (non-deleted) skills only.
|
||||
const result = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
|
||||
.order(dir)
|
||||
.paginate(paginationOpts)
|
||||
// When post-pagination filters are active, skip empty filtered pages so clients
|
||||
// don't bounce between CanLoadMore/LoadingMore with no visible new rows.
|
||||
let result = await paginateWithStaleCursorRecovery(runPaginate, initialCursor)
|
||||
let filteredPage = filterPublicSkillPage(result.page, args)
|
||||
|
||||
const filteredPage = args.nonSuspiciousOnly
|
||||
? result.page.filter((skill) => !isSkillSuspicious(skill))
|
||||
: result.page
|
||||
while ((args.nonSuspiciousOnly || args.highlightedOnly) && filteredPage.length === 0 && !result.isDone) {
|
||||
result = await runPaginate(result.continueCursor)
|
||||
filteredPage = filterPublicSkillPage(result.page, args)
|
||||
}
|
||||
|
||||
// Build the public skill entries (fetch latestVersion + ownerHandle)
|
||||
const items = await buildPublicSkillEntries(ctx, filteredPage)
|
||||
return { ...result, page: items }
|
||||
},
|
||||
})
|
||||
|
||||
function filterPublicSkillPage(
|
||||
page: Array<Doc<'skills'>>,
|
||||
args: { highlightedOnly?: boolean; nonSuspiciousOnly?: boolean },
|
||||
) {
|
||||
if (!args.nonSuspiciousOnly && !args.highlightedOnly) {
|
||||
return page
|
||||
}
|
||||
return page.filter((skill) => {
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return false
|
||||
if (args.highlightedOnly && !isSkillHighlighted(skill)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePublicListPagination(paginationOpts: {
|
||||
cursor?: string | null
|
||||
numItems: number
|
||||
}) {
|
||||
return {
|
||||
cursor: paginationOpts.cursor ?? null,
|
||||
numItems: clampInt(paginationOpts.numItems, 1, MAX_PUBLIC_LIST_LIMIT),
|
||||
}
|
||||
}
|
||||
|
||||
async function paginateWithStaleCursorRecovery<T>(
|
||||
runPaginate: (cursor: string | null) => Promise<T>,
|
||||
initialCursor: string | null,
|
||||
) {
|
||||
try {
|
||||
return await runPaginate(initialCursor)
|
||||
} catch (error) {
|
||||
// Some clients may send stale cursors after index/query argument changes.
|
||||
// Recover by restarting from the first page instead of surfacing a 500.
|
||||
if (!initialCursor || !isCursorParseError(error)) {
|
||||
throw error
|
||||
}
|
||||
return runPaginate(null)
|
||||
}
|
||||
}
|
||||
|
||||
function isCursorParseError(error: unknown) {
|
||||
if (typeof error === 'string') return error.includes('Failed to parse cursor')
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
return typeof message === 'string' && message.includes('Failed to parse cursor')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const countPublicSkills = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const statsCount = await readGlobalPublicSkillsCount(ctx)
|
||||
if (typeof statsCount === 'number') return statsCount
|
||||
// Fallback for uninitialized/missing globalStats storage.
|
||||
return countPublicSkillsForGlobalStats(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
function sortToIndex(
|
||||
sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime',
|
||||
):
|
||||
@@ -1682,19 +1909,47 @@ export const getSkillByIdInternal = internalQuery({
|
||||
})
|
||||
|
||||
export const getPendingScanSkillsInternal = internalQuery({
|
||||
args: { limit: v.optional(v.number()), skipRecentMinutes: v.optional(v.number()) },
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
skipRecentMinutes: v.optional(v.number()),
|
||||
exhaustive: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 10, 1, 100)
|
||||
const skipRecentMinutes = args.skipRecentMinutes ?? 60
|
||||
const exhaustive = args.exhaustive ?? false
|
||||
const limit = exhaustive ? Math.max(1, Math.floor(args.limit ?? 10000)) : clampInt(args.limit ?? 10, 1, 100)
|
||||
const skipRecentMinutes = exhaustive ? 0 : (args.skipRecentMinutes ?? 60)
|
||||
const skipThreshold = Date.now() - skipRecentMinutes * 60 * 1000
|
||||
|
||||
// Use an indexed query and bounded scan to avoid full-table reads under spam/high volume.
|
||||
const poolSize = Math.min(Math.max(limit * 20, 200), 1000)
|
||||
const allSkills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.order('desc')
|
||||
.take(poolSize)
|
||||
let allSkills: Doc<'skills'>[] = []
|
||||
if (exhaustive) {
|
||||
// Used by manual/backfill tooling where fairness matters more than query cost.
|
||||
allSkills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.collect()
|
||||
} else {
|
||||
// Mix "most recently updated" with "oldest created" slices so older pending
|
||||
// items don't starve behind high-churn records.
|
||||
const poolSize = Math.min(Math.max(limit * 20, 200), 1000)
|
||||
const [recentSkills, oldestSkills] = await Promise.all([
|
||||
ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.order('desc')
|
||||
.take(poolSize),
|
||||
ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_created', (q) => q.eq('softDeletedAt', undefined))
|
||||
.order('asc')
|
||||
.take(poolSize),
|
||||
])
|
||||
|
||||
const deduped = new Map<Id<'skills'>, Doc<'skills'>>()
|
||||
for (const skill of [...recentSkills, ...oldestSkills]) {
|
||||
deduped.set(skill._id, skill)
|
||||
}
|
||||
allSkills = [...deduped.values()]
|
||||
}
|
||||
|
||||
const candidates = allSkills.filter((skill) => {
|
||||
const reason = skill.moderationReason
|
||||
@@ -1709,10 +1964,11 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
)
|
||||
})
|
||||
|
||||
// Filter out recently checked skills
|
||||
const skills = candidates.filter(
|
||||
(s) => !s.scanLastCheckedAt || s.scanLastCheckedAt < skipThreshold,
|
||||
)
|
||||
// Filter out recently checked skills unless caller explicitly disables recency filtering.
|
||||
const skills =
|
||||
skipRecentMinutes <= 0
|
||||
? candidates
|
||||
: candidates.filter((s) => !s.scanLastCheckedAt || s.scanLastCheckedAt < skipThreshold)
|
||||
|
||||
// Shuffle and take the requested limit (Fisher-Yates)
|
||||
for (let i = skills.length - 1; i > 0; i--) {
|
||||
@@ -1728,10 +1984,13 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
checkCount: number
|
||||
}> = []
|
||||
|
||||
const FINAL_VT_STATUSES = new Set(['clean', 'malicious', 'suspicious'])
|
||||
for (const skill of selected) {
|
||||
const version = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
// Skip skills where version already has vtAnalysis or lacks sha256hash
|
||||
if (version?.vtAnalysis || !version?.sha256hash) continue
|
||||
if (!version?.sha256hash) continue
|
||||
const vtStatus = version.vtAnalysis?.status?.trim().toLowerCase()
|
||||
// Keep retrying unresolved VT results (pending/stale/error), but skip finalized outcomes.
|
||||
if (vtStatus && FINAL_VT_STATUSES.has(vtStatus)) continue
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version?._id ?? null,
|
||||
@@ -1913,6 +2172,7 @@ export const getActiveSkillBatchForRescanInternal = internalQuery({
|
||||
versionId: Id<'skillVersions'>
|
||||
sha256hash: string
|
||||
slug: string
|
||||
wasFlagged: boolean
|
||||
}> = []
|
||||
let nextCursor = cursor
|
||||
|
||||
@@ -1933,6 +2193,8 @@ export const getActiveSkillBatchForRescanInternal = internalQuery({
|
||||
versionId: version._id,
|
||||
sha256hash: version.sha256hash,
|
||||
slug: skill.slug,
|
||||
wasFlagged:
|
||||
(skill.moderationFlags as string[] | undefined)?.includes('flagged.suspicious') ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2130,9 +2392,13 @@ export const getSkillsWithNullModerationStatusInternal = internalQuery({
|
||||
export const setSkillModerationStatusActiveInternal = internalMutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.skillId, {
|
||||
moderationStatus: 'active',
|
||||
})
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
|
||||
const patch: Partial<Doc<'skills'>> = { moderationStatus: 'active' }
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(args.skillId, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2238,7 +2504,9 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
hiddenCount += 1
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt)
|
||||
}
|
||||
|
||||
@@ -2278,7 +2546,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
continue
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'restored.unban',
|
||||
@@ -2286,7 +2554,10 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now)
|
||||
restoredCount += 1
|
||||
@@ -2516,7 +2787,6 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
const existingFlags: string[] = (skill.moderationFlags as string[] | undefined) ?? []
|
||||
const existingReason: string | undefined = skill.moderationReason as string | undefined
|
||||
const alreadyBlocked = existingFlags.includes('blocked.malware')
|
||||
const alreadyFlagged = existingFlags.includes('flagged.suspicious')
|
||||
const bypassSuspicious =
|
||||
isSuspicious && !alreadyBlocked && isPrivilegedOwnerForSuspiciousBypass(owner)
|
||||
|
||||
@@ -2525,8 +2795,8 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
if (isMalicious || alreadyBlocked) {
|
||||
// Malicious from ANY scanner → blocked.malware (upgrade from suspicious)
|
||||
newFlags = ['blocked.malware']
|
||||
} else if ((isSuspicious || alreadyFlagged) && !bypassSuspicious) {
|
||||
// Suspicious from ANY scanner → flagged.suspicious
|
||||
} else if (isSuspicious && !bypassSuspicious) {
|
||||
// Suspicious from this scanner → flagged.suspicious
|
||||
newFlags = ['flagged.suspicious']
|
||||
} else if (isClean) {
|
||||
// Clean from this scanner — only clear if no other scanner has flagged
|
||||
@@ -2554,7 +2824,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
'Quality gate quarantine is still active. Manual moderation review required.')
|
||||
: undefined
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
moderationStatus: nextModerationStatus,
|
||||
moderationReason: nextModerationReason,
|
||||
moderationFlags: newFlags,
|
||||
@@ -2563,7 +2833,10 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: nextModerationStatus === 'hidden' ? now : undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
// Auto-ban authors of malicious skills (skips moderators/admins)
|
||||
if (isMalicious && skill.ownerUserId) {
|
||||
@@ -2616,7 +2889,7 @@ export const escalateByVtInternal = internalMutation({
|
||||
newFlags = ['flagged.suspicious']
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
moderationFlags: newFlags.length ? newFlags : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
@@ -2631,7 +2904,9 @@ export const escalateByVtInternal = internalMutation({
|
||||
patch.moderationStatus = 'hidden'
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
// Auto-ban authors of malicious skills
|
||||
if (isMalicious && skill.ownerUserId) {
|
||||
@@ -2921,14 +3196,17 @@ export const setSoftDeleted = mutation({
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: args.deleted ? now : undefined,
|
||||
moderationStatus: args.deleted ? 'hidden' : 'active',
|
||||
hiddenAt: args.deleted ? now : undefined,
|
||||
hiddenBy: args.deleted ? user._id : undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
@@ -2983,6 +3261,38 @@ export const changeOwner = mutation({
|
||||
},
|
||||
})
|
||||
|
||||
async function transferSkillOwnershipAndEmbeddings(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
skill: Doc<'skills'>
|
||||
ownerUserId: Id<'users'>
|
||||
now: number
|
||||
},
|
||||
) {
|
||||
if (params.skill.ownerUserId === params.ownerUserId) return
|
||||
|
||||
await ctx.db.patch(params.skill._id, {
|
||||
ownerUserId: params.ownerUserId,
|
||||
lastReviewedAt: params.now,
|
||||
updatedAt: params.now,
|
||||
})
|
||||
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, params.skill._id)
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
ownerId: params.ownerUserId,
|
||||
updatedAt: params.now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseActiveReservationsForSlug(ctx: MutationCtx, slug: string, releasedAt: number) {
|
||||
const active = await listActiveReservedSlugsForSlug(ctx, slug)
|
||||
for (const reservation of active) {
|
||||
await ctx.db.patch(reservation._id, { releasedAt })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: reclaim a squatted slug by hard-deleting the squatter's skill
|
||||
* and reserving the slug for the rightful owner.
|
||||
@@ -3061,6 +3371,7 @@ export const reclaimSlugInternal = internalMutation({
|
||||
slug: v.string(),
|
||||
rightfulOwnerUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
transferRootSlugOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
@@ -3071,12 +3382,82 @@ export const reclaimSlugInternal = internalMutation({
|
||||
if (!slug) throw new Error('Slug required')
|
||||
|
||||
const now = Date.now()
|
||||
const transferRootSlugOnly = args.transferRootSlugOnly === true
|
||||
|
||||
const rightfulOwner = await ctx.db.get(args.rightfulOwnerUserId)
|
||||
if (!rightfulOwner || rightfulOwner.deletedAt || rightfulOwner.deactivatedAt) {
|
||||
throw new Error('Rightful owner not found')
|
||||
}
|
||||
|
||||
const existingSkill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slug))
|
||||
.unique()
|
||||
|
||||
if (transferRootSlugOnly) {
|
||||
if (!existingSkill) {
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: 'slug.reclaim',
|
||||
targetType: 'slug',
|
||||
targetId: slug,
|
||||
metadata: {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
transferRootSlugOnly: true,
|
||||
action: 'missing',
|
||||
reason: args.reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
return { ok: true as const, action: 'missing' as const }
|
||||
}
|
||||
|
||||
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
|
||||
await releaseActiveReservationsForSlug(ctx, slug, now)
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: 'slug.reclaim',
|
||||
targetType: 'slug',
|
||||
targetId: slug,
|
||||
metadata: {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
transferRootSlugOnly: true,
|
||||
action: 'already_owned',
|
||||
reason: args.reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
return { ok: true as const, action: 'already_owned' as const }
|
||||
}
|
||||
|
||||
await transferSkillOwnershipAndEmbeddings(ctx, {
|
||||
skill: existingSkill,
|
||||
ownerUserId: args.rightfulOwnerUserId,
|
||||
now,
|
||||
})
|
||||
await releaseActiveReservationsForSlug(ctx, slug, now)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: 'slug.reclaim',
|
||||
targetType: 'slug',
|
||||
targetId: slug,
|
||||
metadata: {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
previousOwnerUserId: existingSkill.ownerUserId,
|
||||
hadSquatter: true,
|
||||
transferRootSlugOnly: true,
|
||||
action: 'ownership_transferred',
|
||||
reason: args.reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
return { ok: true as const, action: 'ownership_transferred' as const }
|
||||
}
|
||||
|
||||
if (existingSkill && existingSkill.ownerUserId !== args.rightfulOwnerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: existingSkill._id,
|
||||
@@ -3328,8 +3709,9 @@ export const insertVersion = internalMutation({
|
||||
// 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)
|
||||
const slugTakenMessage = buildSlugTakenErrorMessage(skill, owner)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
throw new ConvexError(slugTakenMessage)
|
||||
}
|
||||
|
||||
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
|
||||
@@ -3344,7 +3726,7 @@ export const insertVersion = internalMutation({
|
||||
callerProviderAccountId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
throw new ConvexError(slugTakenMessage)
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
|
||||
@@ -3472,6 +3854,9 @@ export const insertVersion = internalMutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
skill = await ctx.db.get(skillId)
|
||||
if (skill) {
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, skill)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skill) throw new Error('Skill creation failed')
|
||||
@@ -3481,7 +3866,7 @@ export const insertVersion = internalMutation({
|
||||
.withIndex('by_skill_version', (q) => q.eq('skillId', skill._id).eq('version', args.version))
|
||||
.unique()
|
||||
if (existingVersion) {
|
||||
throw new Error('Version already exists')
|
||||
throw new ConvexError('Version already exists')
|
||||
}
|
||||
|
||||
const versionId = await ctx.db.insert('skillVersions', {
|
||||
@@ -3513,7 +3898,7 @@ export const insertVersion = internalMutation({
|
||||
files: args.files,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
displayName: args.displayName,
|
||||
summary: nextSummary ?? undefined,
|
||||
latestVersionId: versionId,
|
||||
@@ -3526,7 +3911,10 @@ export const insertVersion = internalMutation({
|
||||
quality: qualityRecord ?? skill.quality,
|
||||
moderationFlags: moderationFlags.length ? moderationFlags : undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
const badgeMap = await getSkillBadgeMap(ctx, skill._id)
|
||||
const isApproved = Boolean(badgeMap.redactionApproved)
|
||||
@@ -3541,6 +3929,8 @@ export const insertVersion = internalMutation({
|
||||
visibility: embeddingVisibilityFor(true, isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
// Lightweight lookup so search hydration can skip reading the 12KB embedding doc
|
||||
await ctx.db.insert('embeddingSkillMap', { embeddingId, skillId: skill._id })
|
||||
|
||||
if (latestBefore) {
|
||||
const previousEmbedding = await ctx.db
|
||||
@@ -3591,14 +3981,17 @@ export const setSkillSoftDeletedInternal = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: args.deleted ? now : undefined,
|
||||
moderationStatus: args.deleted ? 'hidden' : 'active',
|
||||
hiddenAt: args.deleted ? now : undefined,
|
||||
hiddenBy: args.deleted ? args.userId : undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./lib/access', () => ({
|
||||
assertModerator: vi.fn(),
|
||||
requireUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/githubAccount', () => ({
|
||||
requireGitHubAccountAge: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireUser } = await import('./lib/access')
|
||||
const { requireGitHubAccountAge } = await import('./lib/githubAccount')
|
||||
const { addHandler } = await import('./soulComments')
|
||||
|
||||
describe('soul comments mutations', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset()
|
||||
vi.mocked(requireGitHubAccountAge).mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('add enforces github account age and writes comment', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { _id: 'users:1', role: 'user' },
|
||||
} as never)
|
||||
vi.mocked(requireGitHubAccountAge).mockResolvedValue(undefined as never)
|
||||
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
_id: 'souls:1',
|
||||
stats: { comments: 3 },
|
||||
})
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const ctx = { db: { get, insert, patch } } as never
|
||||
|
||||
await addHandler(ctx, { soulId: 'souls:1', body: ' hello soul ' } as never)
|
||||
|
||||
expect(requireGitHubAccountAge).toHaveBeenCalledWith(ctx, 'users:1')
|
||||
expect(insert).toHaveBeenCalledWith('soulComments', {
|
||||
soulId: 'souls:1',
|
||||
userId: 'users:1',
|
||||
body: 'hello soul',
|
||||
createdAt: 1_700_000_000_000,
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
expect(patch).toHaveBeenCalledWith('souls:1', {
|
||||
stats: { comments: 4 },
|
||||
updatedAt: 1_700_000_000_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('add rejects when github account age gate fails', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:new',
|
||||
user: { _id: 'users:new', role: 'user' },
|
||||
} as never)
|
||||
vi.mocked(requireGitHubAccountAge).mockRejectedValue(
|
||||
new Error('GitHub account must be at least 14 days old to upload skills. Try again in 5 days.'),
|
||||
)
|
||||
|
||||
const get = vi.fn()
|
||||
const insert = vi.fn()
|
||||
const patch = vi.fn()
|
||||
const ctx = { db: { get, insert, patch } } as never
|
||||
|
||||
await expect(addHandler(ctx, { soulId: 'souls:1', body: 'hello' } as never)).rejects.toThrow(
|
||||
/at least 14 days old/i,
|
||||
)
|
||||
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+67
-55
@@ -1,7 +1,10 @@
|
||||
import { v } from 'convex/values'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { assertModerator, requireUser } from './lib/access'
|
||||
import { requireGitHubAccountAge } from './lib/githubAccount'
|
||||
import { type PublicUser, toPublicUser } from './lib/public'
|
||||
|
||||
export const listBySoul = query({
|
||||
@@ -26,63 +29,72 @@ export const listBySoul = query({
|
||||
|
||||
export const add = mutation({
|
||||
args: { soulId: v.id('souls'), body: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const soul = await ctx.db.get(args.soulId)
|
||||
if (!soul) throw new Error('Soul not found')
|
||||
|
||||
await ctx.db.insert('soulComments', {
|
||||
soulId: args.soulId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(soul._id, {
|
||||
stats: { ...soul.stats, comments: soul.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
handler: addHandler,
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: { commentId: v.id('soulComments') },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertModerator(user)
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const soul = await ctx.db.get(comment.soulId)
|
||||
if (soul) {
|
||||
await ctx.db.patch(soul._id, {
|
||||
stats: { ...soul.stats, comments: Math.max(0, soul.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'soul.comment.delete',
|
||||
targetType: 'soulComment',
|
||||
targetId: comment._id,
|
||||
metadata: { soulId: comment.soulId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
handler: removeHandler,
|
||||
})
|
||||
|
||||
export async function addHandler(ctx: MutationCtx, args: { soulId: Id<'souls'>; body: string }) {
|
||||
const { userId } = await requireUser(ctx)
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const soul = await ctx.db.get(args.soulId)
|
||||
if (!soul) throw new Error('Soul not found')
|
||||
|
||||
await ctx.db.insert('soulComments', {
|
||||
soulId: args.soulId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(soul._id, {
|
||||
stats: { ...soul.stats, comments: soul.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeHandler(
|
||||
ctx: MutationCtx,
|
||||
args: { commentId: Id<'soulComments'> },
|
||||
) {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertModerator(user)
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const soul = await ctx.db.get(comment.soulId)
|
||||
if (soul) {
|
||||
await ctx.db.patch(soul._id, {
|
||||
stats: { ...soul.stats, comments: Math.max(0, soul.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'soul.comment.delete',
|
||||
targetType: 'soulComment',
|
||||
targetId: comment._id,
|
||||
metadata: { soulId: comment.soulId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { insertVersion } from './souls'
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>
|
||||
}
|
||||
|
||||
const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<string, unknown>>)
|
||||
._handler
|
||||
|
||||
describe('souls.insertVersion', () => {
|
||||
it('throws a soul-specific ownership error for non-owners', async () => {
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
|
||||
return null
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'souls') throw new Error(`unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== 'by_slug') throw new Error(`unexpected index ${name}`)
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => [
|
||||
{
|
||||
_id: 'souls:1',
|
||||
slug: 'demo-soul',
|
||||
ownerUserId: 'users:owner',
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(
|
||||
insertVersionHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
userId: 'users:caller',
|
||||
slug: 'demo-soul',
|
||||
displayName: 'Demo Soul',
|
||||
version: '1.0.0',
|
||||
changelog: 'Initial',
|
||||
changelogSource: 'user',
|
||||
tags: ['latest'],
|
||||
fingerprint: 'f'.repeat(64),
|
||||
files: [
|
||||
{
|
||||
path: 'SOUL.md',
|
||||
size: 100,
|
||||
storageId: '_storage:1',
|
||||
sha256: 'a'.repeat(64),
|
||||
contentType: 'text/markdown',
|
||||
},
|
||||
],
|
||||
parsed: {
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
},
|
||||
embedding: [0.1, 0.2],
|
||||
} as never,
|
||||
),
|
||||
).rejects.toThrow('Only the owner can publish soul updates')
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -405,7 +405,7 @@ export const insertVersion = internalMutation({
|
||||
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
|
||||
|
||||
if (soul && soul.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
throw new ConvexError('Only the owner can publish soul updates')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
@@ -3,6 +3,10 @@ import { internal } from './_generated/api'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
import {
|
||||
countPublicSkillsForGlobalStats,
|
||||
setGlobalPublicSkillsCount,
|
||||
} from './lib/globalStats'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 200
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
@@ -299,3 +303,11 @@ export const runReconcileSkillStarCountsInternal = internalAction({
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export const updateGlobalStatsInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const count = await countPublicSkillsForGlobalStats(ctx)
|
||||
await setGlobalPublicSkillsCount(ctx, count)
|
||||
},
|
||||
})
|
||||
|
||||
+520
-1
@@ -5,8 +5,13 @@ vi.mock('./lib/access', async () => {
|
||||
return { ...actual, requireUser: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('./skillStatEvents', () => ({
|
||||
insertStatEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireUser } = await import('./lib/access')
|
||||
const { ensureHandler } = await import('./users')
|
||||
const { insertStatEvent } = await import('./skillStatEvents')
|
||||
const { ensureHandler, list, searchInternal, banUserInternal } = await import('./users')
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn()
|
||||
@@ -14,6 +19,64 @@ function makeCtx() {
|
||||
return { ctx: { db: { patch, get } } as never, patch, get }
|
||||
}
|
||||
|
||||
function makeListCtx(users: Array<Record<string, unknown>>) {
|
||||
const take = vi.fn(async (n: number) => users.slice(0, n))
|
||||
const collect = vi.fn(async () => users)
|
||||
const order = vi.fn(() => ({ take, collect }))
|
||||
const query = vi.fn(() => ({ order }))
|
||||
const get = vi.fn()
|
||||
return {
|
||||
ctx: { db: { query, get } } as never,
|
||||
take,
|
||||
collect,
|
||||
order,
|
||||
query,
|
||||
get,
|
||||
}
|
||||
}
|
||||
|
||||
function makeBanCtx() {
|
||||
const patch = vi.fn()
|
||||
const insert = vi.fn()
|
||||
const get = vi.fn()
|
||||
const runMutation = vi.fn()
|
||||
const apiTokens = [{ _id: 'apiTokens:1', revokedAt: undefined }]
|
||||
const userComments = [
|
||||
{
|
||||
_id: 'comments:active',
|
||||
userId: 'users:target',
|
||||
skillId: 'skills:1',
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
{
|
||||
_id: 'comments:already-deleted',
|
||||
userId: 'users:target',
|
||||
skillId: 'skills:1',
|
||||
softDeletedAt: 123,
|
||||
},
|
||||
]
|
||||
const soulComments = [
|
||||
{
|
||||
_id: 'soulComments:active',
|
||||
userId: 'users:target',
|
||||
soulId: 'souls:1',
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: (_index: string, _cb: unknown) => {
|
||||
if (table === 'apiTokens') return { collect: vi.fn().mockResolvedValue(apiTokens) }
|
||||
if (table === 'comments') return { collect: vi.fn().mockResolvedValue(userComments) }
|
||||
if (table === 'soulComments') return { collect: vi.fn().mockResolvedValue(soulComments) }
|
||||
throw new Error(`Unexpected table ${table}`)
|
||||
},
|
||||
}))
|
||||
|
||||
const ctx = { db: { patch, insert, get, query }, runMutation } as never
|
||||
return { ctx, patch, insert, get, runMutation }
|
||||
}
|
||||
|
||||
describe('ensureHandler', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset()
|
||||
@@ -87,4 +150,460 @@ describe('ensureHandler', () => {
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not patch when user metadata is already normalized', async () => {
|
||||
const { ctx, patch, get } = makeCtx()
|
||||
get.mockResolvedValue({
|
||||
_id: 'users:4',
|
||||
handle: 'steady',
|
||||
displayName: 'Steady Name',
|
||||
name: 'steady',
|
||||
role: 'user',
|
||||
_creationTime: 1,
|
||||
createdAt: 1,
|
||||
})
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:4',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: 'steady',
|
||||
displayName: 'Steady Name',
|
||||
name: 'steady',
|
||||
role: 'user',
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never)
|
||||
|
||||
const result = await ensureHandler(ctx)
|
||||
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(get).toHaveBeenCalledWith('users:4')
|
||||
expect(result).toMatchObject({ _id: 'users:4' })
|
||||
})
|
||||
|
||||
it('sets admin role when normalized handle is steipete and role is missing', async () => {
|
||||
const { ctx, patch } = makeCtx()
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: 'steipete',
|
||||
displayName: 'steipete',
|
||||
name: 'steipete',
|
||||
role: undefined,
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await ensureHandler(ctx)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('users:admin', {
|
||||
displayName: 'steipete',
|
||||
role: 'admin',
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('derives handle/display name from email when missing', async () => {
|
||||
const { ctx, patch } = makeCtx()
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:email',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
displayName: undefined,
|
||||
name: undefined,
|
||||
email: 'owner@example.com',
|
||||
role: undefined,
|
||||
createdAt: undefined,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await ensureHandler(ctx)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('users:email', {
|
||||
handle: 'owner',
|
||||
displayName: 'owner',
|
||||
role: 'user',
|
||||
createdAt: 1,
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('users.list', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset()
|
||||
})
|
||||
|
||||
it('uses take(limit) without full collect when search is empty', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = [
|
||||
{ _id: 'users:1', _creationTime: 3, handle: 'alice', role: 'user' },
|
||||
{ _id: 'users:2', _creationTime: 2, handle: 'bob', role: 'user' },
|
||||
{ _id: 'users:3', _creationTime: 1, handle: 'carol', role: 'user' },
|
||||
]
|
||||
const { ctx, take, collect } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 2 })) as {
|
||||
items: Array<Record<string, unknown>>
|
||||
total: number
|
||||
}
|
||||
|
||||
expect(take).toHaveBeenCalledWith(2)
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses bounded scan for search instead of full collect', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = [
|
||||
{ _id: 'users:1', _creationTime: 3, handle: 'alice', role: 'user' },
|
||||
{ _id: 'users:2', _creationTime: 2, handle: 'bob', role: 'user' },
|
||||
{ _id: 'users:3', _creationTime: 1, handle: 'carol', role: 'user' },
|
||||
]
|
||||
const { ctx, take, collect } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: 'ali' })) as {
|
||||
items: Array<Record<string, unknown>>
|
||||
total: number
|
||||
}
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500)
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.handle).toBe('alice')
|
||||
})
|
||||
|
||||
it('clamps large limit and search scan size', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = Array.from({ length: 8_000 }, (_value, index) => ({
|
||||
_id: `users:${index}`,
|
||||
_creationTime: 10_000 - index,
|
||||
handle: `user-${index}`,
|
||||
role: 'user',
|
||||
}))
|
||||
const { ctx, take } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
await listHandler(ctx, { limit: 999, search: 'user' })
|
||||
|
||||
expect(take).toHaveBeenCalledWith(2_000)
|
||||
})
|
||||
|
||||
it('handles malformed legacy user fields without throwing', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = [
|
||||
{
|
||||
_id: 'users:legacy',
|
||||
_creationTime: 99,
|
||||
handle: 123,
|
||||
name: { broken: true },
|
||||
displayName: null,
|
||||
email: ['legacy@example.com'],
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
_id: 'users:2',
|
||||
_creationTime: 98,
|
||||
handle: 'carol',
|
||||
role: 'user',
|
||||
},
|
||||
]
|
||||
const { ctx } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
await expect(listHandler(ctx, { limit: 50, search: 'car' })).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{ _id: 'users:2' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('treats whitespace search as empty search', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = [
|
||||
{ _id: 'users:1', _creationTime: 2, handle: 'alice', role: 'user' },
|
||||
{ _id: 'users:2', _creationTime: 1, handle: 'bob', role: 'user' },
|
||||
]
|
||||
const { ctx, take, collect } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: ' ' })) as {
|
||||
items: Array<Record<string, unknown>>
|
||||
total: number
|
||||
}
|
||||
|
||||
expect(take).toHaveBeenCalledWith(50)
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
expect(result.total).toBe(2)
|
||||
})
|
||||
|
||||
it('clamps non-positive limit to one', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
const users = [
|
||||
{ _id: 'users:1', _creationTime: 2, handle: 'alice', role: 'user' },
|
||||
{ _id: 'users:2', _creationTime: 1, handle: 'bob', role: 'user' },
|
||||
]
|
||||
const { ctx, take } = makeListCtx(users)
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 0 })) as {
|
||||
items: Array<Record<string, unknown>>
|
||||
total: number
|
||||
}
|
||||
|
||||
expect(take).toHaveBeenCalledWith(1)
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects non-admin actors', async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:basic',
|
||||
user: { _id: 'users:basic', role: 'user' },
|
||||
} as never)
|
||||
const { ctx } = makeListCtx([])
|
||||
const listHandler = (list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> })
|
||||
._handler
|
||||
|
||||
await expect(listHandler(ctx, { limit: 10 })).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('users.searchInternal', () => {
|
||||
it('rejects missing actor', async () => {
|
||||
const { ctx, get } = makeListCtx([])
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler
|
||||
get.mockResolvedValue(null)
|
||||
|
||||
await expect(handler(ctx, { actorUserId: 'users:missing' })).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('uses bounded scan and returns mapped fields', async () => {
|
||||
const users = [
|
||||
{ _id: 'users:1', _creationTime: 2, handle: 'alice', name: 'alice', role: 'user' },
|
||||
{ _id: 'users:2', _creationTime: 1, handle: 'bob', name: 'bob', role: 'moderator' },
|
||||
]
|
||||
const { ctx, take, collect, get } = makeListCtx(users)
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler
|
||||
get.mockResolvedValue({ _id: 'users:admin', role: 'admin' })
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: 'users:admin',
|
||||
query: 'ali',
|
||||
limit: 25,
|
||||
})) as {
|
||||
items: Array<Record<string, unknown>>
|
||||
total: number
|
||||
}
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500)
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
userId: 'users:1',
|
||||
handle: 'alice',
|
||||
displayName: null,
|
||||
name: 'alice',
|
||||
role: 'user',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects deactivated actors', async () => {
|
||||
const { ctx, get } = makeListCtx([])
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler
|
||||
get.mockResolvedValue({ _id: 'users:ghost', role: 'admin', deactivatedAt: Date.now() })
|
||||
|
||||
await expect(handler(ctx, { actorUserId: 'users:ghost' })).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('rejects non-admin actors', async () => {
|
||||
const { ctx, get } = makeListCtx([])
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler
|
||||
get.mockResolvedValue({ _id: 'users:mod', role: 'moderator' })
|
||||
|
||||
await expect(handler(ctx, { actorUserId: 'users:mod', query: 'a' })).rejects.toThrow(
|
||||
'Forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps limit for empty query and uses non-search path', async () => {
|
||||
const users = Array.from({ length: 400 }, (_value, index) => ({
|
||||
_id: `users:${index}`,
|
||||
_creationTime: 1_000 - index,
|
||||
handle: `user-${index}`,
|
||||
role: 'user',
|
||||
}))
|
||||
const { ctx, take, collect, get } = makeListCtx(users)
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler
|
||||
get.mockResolvedValue({ _id: 'users:admin', role: 'admin' })
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: 'users:admin',
|
||||
limit: 999,
|
||||
query: ' ',
|
||||
})) as { items: Array<Record<string, unknown>>; total: number }
|
||||
|
||||
expect(take).toHaveBeenCalledWith(200)
|
||||
expect(collect).not.toHaveBeenCalled()
|
||||
expect(result.total).toBe(200)
|
||||
expect(result.items).toHaveLength(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('users.banUserInternal', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(insertStatEvent).mockReset()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('soft-deletes target user comments (skill + soul) during ban', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
const { ctx, get, patch, insert, runMutation } = makeBanCtx()
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === 'users:actor') return { _id: 'users:actor', role: 'moderator' }
|
||||
if (id === 'users:target') return { _id: 'users:target', role: 'user' }
|
||||
if (id === 'souls:1') return { _id: 'souls:1', stats: { comments: 3 } }
|
||||
return null
|
||||
})
|
||||
|
||||
runMutation
|
||||
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const handler = (
|
||||
banUserInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: { actorUserId: string; targetUserId: string; reason?: string },
|
||||
) => Promise<unknown>
|
||||
}
|
||||
)._handler
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: 'users:actor',
|
||||
targetUserId: 'users:target',
|
||||
reason: 'spam',
|
||||
})) as {
|
||||
ok: boolean
|
||||
alreadyBanned: boolean
|
||||
deletedComments: { skillComments: number; soulComments: number }
|
||||
}
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
alreadyBanned: false,
|
||||
deletedComments: { skillComments: 1, soulComments: 1 },
|
||||
})
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('comments:active', {
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
deletedBy: 'users:actor',
|
||||
})
|
||||
expect(patch).toHaveBeenCalledWith('soulComments:active', {
|
||||
softDeletedAt: 1_700_000_000_000,
|
||||
deletedBy: 'users:actor',
|
||||
})
|
||||
expect(patch).toHaveBeenCalledWith('souls:1', {
|
||||
stats: { comments: 2 },
|
||||
updatedAt: 1_700_000_000_000,
|
||||
})
|
||||
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, { skillId: 'skills:1', kind: 'uncomment' })
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
'auditLogs',
|
||||
expect.objectContaining({
|
||||
action: 'user.ban',
|
||||
metadata: expect.objectContaining({
|
||||
deletedSkillComments: 1,
|
||||
deletedSoulComments: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('re-ban of already banned user still cleans lingering comments', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
const { ctx, get, patch, runMutation } = makeBanCtx()
|
||||
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === 'users:actor') return { _id: 'users:actor', role: 'moderator' }
|
||||
if (id === 'users:target') return { _id: 'users:target', role: 'user', deletedAt: 1_600_000_000_000 }
|
||||
if (id === 'souls:1') return { _id: 'souls:1', stats: { comments: 3 } }
|
||||
return null
|
||||
})
|
||||
|
||||
const handler = (
|
||||
banUserInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: { actorUserId: string; targetUserId: string; reason?: string },
|
||||
) => Promise<unknown>
|
||||
}
|
||||
)._handler
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: 'users:actor',
|
||||
targetUserId: 'users:target',
|
||||
reason: 'cleanup',
|
||||
})) as {
|
||||
ok: boolean
|
||||
alreadyBanned: boolean
|
||||
deletedComments: { skillComments: number; soulComments: number }
|
||||
deletedSkills: number
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
alreadyBanned: true,
|
||||
deletedSkills: 0,
|
||||
deletedComments: { skillComments: 1, soulComments: 1 },
|
||||
})
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
expect(patch).toHaveBeenCalledWith('comments:active', {
|
||||
softDeletedAt: 1_600_000_000_000,
|
||||
deletedBy: 'users:actor',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+131
-14
@@ -8,9 +8,13 @@ import { assertAdmin, assertModerator, requireUser } from './lib/access'
|
||||
import { syncGitHubProfile } from './lib/githubAccount'
|
||||
import { toPublicUser } from './lib/public'
|
||||
import { buildUserSearchResults } from './lib/userSearch'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
const DEFAULT_ROLE = 'user'
|
||||
const ADMIN_HANDLE = 'steipete'
|
||||
const MAX_USER_LIST_LIMIT = 200
|
||||
const MAX_USER_SEARCH_SCAN = 5_000
|
||||
const MIN_USER_SEARCH_SCAN = 500
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.id('users') },
|
||||
@@ -33,10 +37,9 @@ export const searchInternal = internalQuery({
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('Unauthorized')
|
||||
assertAdmin(actor)
|
||||
|
||||
const limit = Math.min(Math.max(args.limit ?? 20, 1), 200)
|
||||
const users = await ctx.db.query('users').order('desc').collect()
|
||||
const result = buildUserSearchResults(users, args.query)
|
||||
const items = result.items.slice(0, limit).map((user) => ({
|
||||
const limit = clampInt(args.limit ?? 20, 1, MAX_USER_LIST_LIMIT)
|
||||
const result = await queryUsersForAdminList(ctx, { limit, search: args.query })
|
||||
const items = result.items.map((user) => ({
|
||||
userId: user._id,
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
@@ -268,14 +271,41 @@ export const list = query({
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertAdmin(user)
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200)
|
||||
const query = args.search?.trim().toLowerCase()
|
||||
const users = await ctx.db.query('users').order('desc').collect()
|
||||
const result = buildUserSearchResults(users, query)
|
||||
return { items: result.items.slice(0, limit), total: result.total }
|
||||
const limit = clampInt(args.limit ?? 50, 1, MAX_USER_LIST_LIMIT)
|
||||
return queryUsersForAdminList(ctx, { limit, search: args.search })
|
||||
},
|
||||
})
|
||||
|
||||
function normalizeSearchQuery(search?: string) {
|
||||
const trimmed = search?.trim().toLowerCase()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
function computeUserSearchScanLimit(limit: number) {
|
||||
return clampInt(limit * 10, MIN_USER_SEARCH_SCAN, MAX_USER_SEARCH_SCAN)
|
||||
}
|
||||
|
||||
async function queryUsersForAdminList(
|
||||
ctx: { db: { query: (table: 'users') => { order: (order: 'desc') => { take: (n: number) => Promise<Doc<'users'>[]> } } } },
|
||||
args: { limit: number; search?: string },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search)
|
||||
const orderedUsers = ctx.db.query('users').order('desc')
|
||||
|
||||
if (!normalizedSearch) {
|
||||
const items = await orderedUsers.take(args.limit)
|
||||
return { items, total: items.length }
|
||||
}
|
||||
|
||||
const scannedUsers = await orderedUsers.take(computeUserSearchScanLimit(args.limit))
|
||||
const result = buildUserSearchResults(scannedUsers, normalizedSearch)
|
||||
return { items: result.items.slice(0, args.limit), total: result.total }
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max)
|
||||
}
|
||||
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -396,8 +426,16 @@ async function banUserWithActor(
|
||||
if (reason && reason.length > 500) {
|
||||
throw new Error('Reason too long (max 500 chars)')
|
||||
}
|
||||
if (target.deletedAt || target.deactivatedAt) {
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
|
||||
if (target.deactivatedAt) {
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0, deletedComments: { skillComments: 0, soulComments: 0 } }
|
||||
}
|
||||
if (target.deletedAt) {
|
||||
const deletedComments = await softDeleteUserCommentsForBan(ctx, {
|
||||
userId: targetUserId,
|
||||
deletedBy: actor._id,
|
||||
deletedAt: target.deletedAt,
|
||||
})
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0, deletedComments }
|
||||
}
|
||||
|
||||
const banSkillsResult = (await ctx.runMutation(
|
||||
@@ -422,6 +460,12 @@ async function banUserWithActor(
|
||||
}
|
||||
}
|
||||
|
||||
const deletedComments = await softDeleteUserCommentsForBan(ctx, {
|
||||
userId: targetUserId,
|
||||
deletedBy: actor._id,
|
||||
deletedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.patch(targetUserId, {
|
||||
deletedAt: now,
|
||||
role: 'user',
|
||||
@@ -436,11 +480,22 @@ async function banUserWithActor(
|
||||
action: 'user.ban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { hiddenSkills: hiddenCount, reason: reason || undefined },
|
||||
metadata: {
|
||||
hiddenSkills: hiddenCount,
|
||||
deletedSkillComments: deletedComments.skillComments,
|
||||
deletedSoulComments: deletedComments.soulComments,
|
||||
reason: reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
|
||||
return {
|
||||
ok: true as const,
|
||||
alreadyBanned: false,
|
||||
deletedSkills: hiddenCount,
|
||||
deletedComments,
|
||||
scheduledSkills,
|
||||
}
|
||||
}
|
||||
|
||||
async function unbanUserWithActor(
|
||||
@@ -611,6 +666,12 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
}
|
||||
}
|
||||
|
||||
const deletedComments = await softDeleteUserCommentsForBan(ctx, {
|
||||
userId: args.ownerUserId,
|
||||
deletedBy: args.ownerUserId,
|
||||
deletedAt: now,
|
||||
})
|
||||
|
||||
// Ban the user
|
||||
await ctx.db.patch(args.ownerUserId, {
|
||||
deletedAt: now,
|
||||
@@ -634,6 +695,8 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
sha256hash: args.sha256hash,
|
||||
slug: args.slug,
|
||||
hiddenSkills: hiddenCount,
|
||||
deletedSkillComments: deletedComments.skillComments,
|
||||
deletedSoulComments: deletedComments.soulComments,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
@@ -642,6 +705,60 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
||||
)
|
||||
|
||||
return { ok: true, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
|
||||
return {
|
||||
ok: true,
|
||||
alreadyBanned: false,
|
||||
deletedSkills: hiddenCount,
|
||||
deletedComments,
|
||||
scheduledSkills,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function softDeleteUserCommentsForBan(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: Id<'users'>; deletedBy: Id<'users'>; deletedAt: number },
|
||||
) {
|
||||
let skillComments = 0
|
||||
let soulComments = 0
|
||||
|
||||
const comments = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||
.collect()
|
||||
for (const comment of comments) {
|
||||
if (comment.softDeletedAt) continue
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: args.deletedAt,
|
||||
deletedBy: args.deletedBy,
|
||||
})
|
||||
await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' })
|
||||
skillComments += 1
|
||||
}
|
||||
|
||||
const soulCommentDocs = await ctx.db
|
||||
.query('soulComments')
|
||||
.withIndex('by_user', (q) => q.eq('userId', args.userId))
|
||||
.collect()
|
||||
const soulCommentCounts = new Map<Id<'souls'>, number>()
|
||||
for (const comment of soulCommentDocs) {
|
||||
if (comment.softDeletedAt) continue
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: args.deletedAt,
|
||||
deletedBy: args.deletedBy,
|
||||
})
|
||||
soulCommentCounts.set(comment.soulId, (soulCommentCounts.get(comment.soulId) ?? 0) + 1)
|
||||
soulComments += 1
|
||||
}
|
||||
|
||||
for (const [soulId, count] of soulCommentCounts.entries()) {
|
||||
const soul = await ctx.db.get(soulId)
|
||||
if (!soul) continue
|
||||
await ctx.db.patch(soulId, {
|
||||
stats: { ...soul.stats, comments: Math.max(0, soul.stats.comments - count) },
|
||||
updatedAt: args.deletedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return { skillComments, soulComments }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { __test } from './vt'
|
||||
|
||||
describe('vt activation fallback', () => {
|
||||
it('activates only VT-pending hidden skills', () => {
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'scanner.vt.pending',
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan.stale',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate quality or scanner-hidden skills', () => {
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'quality.low',
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'scanner.llm.malicious',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not activate blocked or already-active skills', () => {
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
moderationFlags: ['blocked.malware'],
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
__test.shouldActivateWhenVtUnavailable({
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'pending.scan',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vt AV engine fallback verdicts', () => {
|
||||
it('maps engine verdicts in severity order', () => {
|
||||
expect(
|
||||
__test.statusFromAvStats({
|
||||
malicious: 1,
|
||||
suspicious: 2,
|
||||
harmless: 10,
|
||||
undetected: 40,
|
||||
}),
|
||||
).toBe('malicious')
|
||||
|
||||
expect(
|
||||
__test.statusFromAvStats({
|
||||
malicious: 0,
|
||||
suspicious: 1,
|
||||
harmless: 10,
|
||||
undetected: 40,
|
||||
}),
|
||||
).toBe('suspicious')
|
||||
|
||||
expect(
|
||||
__test.statusFromAvStats({
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 1,
|
||||
undetected: 40,
|
||||
}),
|
||||
).toBe('clean')
|
||||
})
|
||||
|
||||
it('keeps undetected-only results pending', () => {
|
||||
expect(
|
||||
__test.statusFromAvStats({
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 40,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
+183
-17
@@ -1,6 +1,7 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { action, internalAction, internalMutation } from './_generated/server'
|
||||
import { buildDeterministicZip } from './lib/skillZip'
|
||||
|
||||
@@ -121,6 +122,8 @@ type VTFileResponse = {
|
||||
}
|
||||
}
|
||||
|
||||
type VTAnalysisStats = NonNullable<VTFileResponse['data']['attributes']['last_analysis_stats']>
|
||||
|
||||
type ScanQueueHealth = {
|
||||
queueSize: number
|
||||
staleCount: number
|
||||
@@ -136,6 +139,13 @@ type PendingScanSkill = {
|
||||
checkCount: number
|
||||
}
|
||||
|
||||
type SkillActivationCandidate = {
|
||||
moderationStatus?: string
|
||||
moderationReason?: string
|
||||
moderationFlags?: string[]
|
||||
softDeletedAt?: number
|
||||
}
|
||||
|
||||
type PollPendingScansResult = {
|
||||
processed: number
|
||||
updated: number
|
||||
@@ -228,6 +238,32 @@ type SyncModerationReasonsResult = {
|
||||
done: boolean
|
||||
}
|
||||
|
||||
const VT_PENDING_REASONS = new Set(['pending.scan', 'scanner.vt.pending', 'pending.scan.stale'])
|
||||
|
||||
function shouldActivateWhenVtUnavailable(skill: SkillActivationCandidate | null | undefined) {
|
||||
if (!skill || skill.softDeletedAt) return false
|
||||
if (skill.moderationFlags?.includes('blocked.malware')) return false
|
||||
if (skill.moderationStatus === 'active') return false
|
||||
const reason = skill.moderationReason
|
||||
return typeof reason === 'string' && VT_PENDING_REASONS.has(reason)
|
||||
}
|
||||
|
||||
function statusFromAvStats(stats?: VTAnalysisStats | null): 'malicious' | 'suspicious' | 'clean' | null {
|
||||
if (!stats) return null
|
||||
if (stats.malicious > 0) return 'malicious'
|
||||
if (stats.suspicious > 0) return 'suspicious'
|
||||
// Keep this aligned with fetchResults: undetected-only should stay pending.
|
||||
if (stats.harmless > 0) return 'clean'
|
||||
return null
|
||||
}
|
||||
|
||||
async function activateSkillWhenVtUnavailable(ctx: ActionCtx, skillId: Id<'skills'>) {
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId })
|
||||
if (!shouldActivateWhenVtUnavailable(skill)) return
|
||||
|
||||
await ctx.runMutation(internal.skills.setSkillModerationStatusActiveInternal, { skillId })
|
||||
}
|
||||
|
||||
export const fetchResults = action({
|
||||
args: {
|
||||
sha256hash: v.optional(v.string()),
|
||||
@@ -269,15 +305,8 @@ export const fetchResults = action({
|
||||
if (aiResult?.verdict) {
|
||||
// Prioritize AI Analysis (Code Insight)
|
||||
status = verdictToStatus(normalizeVerdict(aiResult.verdict))
|
||||
} else if (stats) {
|
||||
// Fallback to AV engines
|
||||
if (stats.malicious > 0) {
|
||||
status = 'malicious'
|
||||
} else if (stats.suspicious > 0) {
|
||||
status = 'suspicious'
|
||||
} else if (stats.harmless > 0) {
|
||||
status = 'clean'
|
||||
}
|
||||
} else {
|
||||
status = statusFromAvStats(stats) ?? 'pending'
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -305,7 +334,13 @@ export const scanWithVirusTotal = internalAction({
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY
|
||||
if (!apiKey) {
|
||||
console.log('VT_API_KEY not configured, skipping scan')
|
||||
console.log('VT_API_KEY not configured, skipping scan — activating skill')
|
||||
const version = await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: args.versionId,
|
||||
})
|
||||
if (version) {
|
||||
await activateSkillWhenVtUnavailable(ctx, version.skillId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -524,6 +559,7 @@ export const pollPendingScans = internalAction({
|
||||
versionId,
|
||||
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
|
||||
})
|
||||
await activateSkillWhenVtUnavailable(ctx, skillId)
|
||||
staled++
|
||||
}
|
||||
continue
|
||||
@@ -534,9 +570,40 @@ export const pollPendingScans = internalAction({
|
||||
)
|
||||
|
||||
if (!aiResult) {
|
||||
// No Code Insight - trigger a rescan to get it
|
||||
// No Code Insight - check AV engine stats as fallback
|
||||
const stats = vtResult.data.attributes.last_analysis_stats
|
||||
const status = statusFromAvStats(stats)
|
||||
let source = 'engines'
|
||||
|
||||
if (status) {
|
||||
// We have a verdict from AV engines - update the skill
|
||||
console.log(
|
||||
`[vt:pollPendingScans] Hash ${sha256hash} verdict from AV engines: ${status}`,
|
||||
)
|
||||
|
||||
// Cache VT analysis in version
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
|
||||
// VT finalizes moderation visibility for newly published versions.
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
// No verdict from engines either - trigger a rescan to get Code Insight
|
||||
console.log(
|
||||
`[vt:pollPendingScans] Hash ${sha256hash} has no Code Insight, requesting rescan`,
|
||||
`[vt:pollPendingScans] Hash ${sha256hash} has no Code Insight or engine stats, requesting rescan`,
|
||||
)
|
||||
await requestRescan(apiKey, sha256hash)
|
||||
// Check if we've exceeded max attempts — write stale vtAnalysis so it
|
||||
@@ -549,6 +616,7 @@ export const pollPendingScans = internalAction({
|
||||
versionId,
|
||||
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
|
||||
})
|
||||
await activateSkillWhenVtUnavailable(ctx, skillId)
|
||||
staled++
|
||||
}
|
||||
continue
|
||||
@@ -650,6 +718,11 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
statusFromAvStats,
|
||||
shouldActivateWhenVtUnavailable,
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill function to process ALL pending skills at once
|
||||
* Run manually to clear backlog
|
||||
@@ -672,6 +745,8 @@ export const backfillPendingScans = internalAction({
|
||||
internal.skills.getPendingScanSkillsInternal,
|
||||
{
|
||||
limit: 10000,
|
||||
exhaustive: true,
|
||||
skipRecentMinutes: 0,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -702,7 +777,25 @@ export const backfillPendingScans = internalAction({
|
||||
)
|
||||
|
||||
if (!aiResult) {
|
||||
// No Code Insight - check AV engine stats as fallback
|
||||
const stats = vtResult.data.attributes.last_analysis_stats
|
||||
const status = statusFromAvStats(stats)
|
||||
|
||||
if (status) {
|
||||
// We have a verdict from AV engines - update the skill
|
||||
console.log(`[vt:backfill] Hash ${sha256hash} verdict from AV engines: ${status}`)
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
// No verdict from engines either - trigger a rescan
|
||||
if (triggerRescans) {
|
||||
console.log(`[vt:backfill] Hash ${sha256hash} has no Code Insight or engine stats, requesting rescan`)
|
||||
await requestRescan(apiKey, sha256hash)
|
||||
rescansRequested++
|
||||
}
|
||||
@@ -787,7 +880,7 @@ export const rescanActiveSkills = internalAction({
|
||||
`[vt:rescan] Processing batch of ${batch.skills.length} skills (cursor=${cursor}, accumulated=${accTotal})`,
|
||||
)
|
||||
|
||||
for (const { versionId, sha256hash, slug } of batch.skills) {
|
||||
for (const { versionId, sha256hash, slug, wasFlagged } of batch.skills) {
|
||||
try {
|
||||
const vtResult = await checkExistingFile(apiKey, sha256hash)
|
||||
|
||||
@@ -801,14 +894,56 @@ export const rescanActiveSkills = internalAction({
|
||||
)
|
||||
|
||||
if (!aiResult) {
|
||||
// No Code Insight - check AV engine stats as fallback
|
||||
const stats = vtResult.data.attributes.last_analysis_stats
|
||||
const status = statusFromAvStats(stats)
|
||||
let source = 'engines'
|
||||
|
||||
if (!status) {
|
||||
// No verdict from engines either - keep as pending
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
vtAnalysis: {
|
||||
status: 'pending',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
accUnchanged++
|
||||
continue
|
||||
}
|
||||
|
||||
// We have a verdict from AV engines - continue with normal flow
|
||||
console.log(`[vt:rescan] ${slug} verdict from AV engines: ${status}`)
|
||||
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
vtAnalysis: {
|
||||
status: 'pending',
|
||||
status,
|
||||
source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
accUnchanged++
|
||||
|
||||
if (status === 'malicious' || status === 'suspicious') {
|
||||
console.warn(`[vt:rescan] ${slug}: verdict changed to ${status}!`)
|
||||
accFlaggedSkills.push({ slug, status })
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
status,
|
||||
})
|
||||
accUpdated++
|
||||
} else if (wasFlagged && status === 'clean') {
|
||||
// Verdict improved from suspicious → clean: clear the stale moderation flag
|
||||
console.log(`[vt:rescan] ${slug}: verdict improved to clean, clearing suspicious flag`)
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
accUpdated++
|
||||
} else {
|
||||
accUnchanged++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -834,6 +969,15 @@ export const rescanActiveSkills = internalAction({
|
||||
status,
|
||||
})
|
||||
accUpdated++
|
||||
} else if (wasFlagged && status === 'clean') {
|
||||
// Verdict improved from suspicious → clean: clear the stale moderation flag
|
||||
console.log(`[vt:rescan] ${slug}: verdict improved to clean, clearing suspicious flag`)
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
accUpdated++
|
||||
} else {
|
||||
accUnchanged++
|
||||
}
|
||||
@@ -1073,8 +1217,30 @@ export const backfillActiveSkillsVTCache = internalAction({
|
||||
)
|
||||
|
||||
if (!aiResult) {
|
||||
console.log(`[vt:backfillActive] ${slug}: no Code Insight yet`)
|
||||
noResults++
|
||||
// No Code Insight - check AV engine stats as fallback
|
||||
const stats = vtResult.data.attributes.last_analysis_stats
|
||||
const status = statusFromAvStats(stats)
|
||||
let source = 'engines'
|
||||
|
||||
if (!status) {
|
||||
console.log(`[vt:backfillActive] ${slug}: no Code Insight or engine stats yet`)
|
||||
noResults++
|
||||
continue
|
||||
}
|
||||
|
||||
// We have a verdict from AV engines - update the version
|
||||
console.log(`[vt:backfillActive] ${slug}: updated with ${status} (from AV engines)`)
|
||||
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
sha256hash,
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -18,12 +18,41 @@ OpenAPI: `/api/v1/openapi.json`
|
||||
|
||||
## Rate limits
|
||||
|
||||
Per IP + per API key:
|
||||
Auth-aware enforcement:
|
||||
|
||||
- Anonymous requests: per IP.
|
||||
- Authenticated requests (valid Bearer token): per user bucket.
|
||||
- Missing/invalid token falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
|
||||
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (on 429).
|
||||
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` (on 429).
|
||||
|
||||
Semantics:
|
||||
|
||||
- `X-RateLimit-Reset`: Unix epoch seconds (absolute reset time)
|
||||
- `RateLimit-Reset`: delay seconds until reset
|
||||
- `Retry-After`: delay seconds to wait on `429`
|
||||
|
||||
Example `429`:
|
||||
|
||||
```http
|
||||
HTTP/2 429
|
||||
x-ratelimit-limit: 20
|
||||
x-ratelimit-remaining: 0
|
||||
x-ratelimit-reset: 1771404540
|
||||
ratelimit-limit: 20
|
||||
ratelimit-remaining: 0
|
||||
ratelimit-reset: 34
|
||||
retry-after: 34
|
||||
```
|
||||
|
||||
Client handling:
|
||||
|
||||
- Prefer `Retry-After` when present.
|
||||
- Otherwise use `RateLimit-Reset` or derive delay from `X-RateLimit-Reset`.
|
||||
- Add jitter to retries.
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
||||
+38
-4
@@ -29,6 +29,34 @@ Env equivalents:
|
||||
- `CLAWHUB_REGISTRY` (legacy `CLAWDHUB_REGISTRY`)
|
||||
- `CLAWHUB_WORKDIR` (legacy `CLAWDHUB_WORKDIR`)
|
||||
|
||||
### HTTP proxy
|
||||
|
||||
The CLI respects standard HTTP proxy environment variables for systems behind
|
||||
corporate proxies or restricted networks:
|
||||
|
||||
- `HTTPS_PROXY` / `https_proxy`
|
||||
- `HTTP_PROXY` / `http_proxy`
|
||||
- `NO_PROXY` / `no_proxy`
|
||||
|
||||
When any of these variables is set, the CLI routes outbound requests through
|
||||
the specified proxy. `HTTPS_PROXY` is used for HTTPS requests, `HTTP_PROXY`
|
||||
for plain HTTP. `NO_PROXY` / `no_proxy` is respected to bypass the proxy for
|
||||
specific hosts or domains.
|
||||
|
||||
This is required on systems where direct outbound connections are blocked
|
||||
(e.g. Docker containers, Hetzner VPS with proxy-only internet, corporate
|
||||
firewalls).
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export HTTPS_PROXY=http://proxy.example.com:3128
|
||||
export NO_PROXY=localhost,127.0.0.1
|
||||
clawhub search "my query"
|
||||
```
|
||||
|
||||
When no proxy variable is set, behavior is unchanged (direct connections).
|
||||
|
||||
## Config file
|
||||
|
||||
Stores your API token + cached registry URL.
|
||||
@@ -86,6 +114,12 @@ Stores your API token + cached registry URL.
|
||||
- `<workdir>/.clawhub/lock.json` (legacy `.clawdhub`)
|
||||
- `<skill>/.clawhub/origin.json` (legacy `.clawdhub`)
|
||||
|
||||
### `uninstall <slug>`
|
||||
|
||||
- Removes `<workdir>/<dir>/<slug>` and deletes the lockfile entry.
|
||||
- Interactive: asks for confirmation.
|
||||
- Non-interactive (`--no-input`): requires `--yes`.
|
||||
|
||||
### `list`
|
||||
|
||||
- Reads `<workdir>/.clawhub/lock.json` (legacy `.clawdhub`).
|
||||
@@ -105,24 +139,24 @@ Stores your API token + cached registry URL.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
- Soft-delete a skill (moderator/admin only).
|
||||
- Soft-delete a skill (owner, moderator, or admin).
|
||||
- Calls `DELETE /api/v1/skills/{slug}`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `undelete <slug>`
|
||||
|
||||
- Restore a hidden skill (moderator/admin only).
|
||||
- Restore a hidden skill (owner, moderator, or admin).
|
||||
- Calls `POST /api/v1/skills/{slug}/undelete`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `hide <slug>`
|
||||
|
||||
- Hide a skill (moderator/admin only).
|
||||
- Hide a skill (owner, moderator, or admin).
|
||||
- Alias for `delete`.
|
||||
|
||||
### `unhide <slug>`
|
||||
|
||||
- Unhide a skill (moderator/admin only).
|
||||
- Unhide a skill (owner, moderator, or admin).
|
||||
- Alias for `undelete`.
|
||||
|
||||
### `ban-user <handleOrId>`
|
||||
|
||||
@@ -78,3 +78,21 @@ Then:
|
||||
clawhub login --site https://<site>
|
||||
clawhub whoami
|
||||
```
|
||||
|
||||
Rate-limit sanity checks:
|
||||
|
||||
```bash
|
||||
curl -i "https://<site>/api/v1/download?slug=gifgrep"
|
||||
```
|
||||
|
||||
Confirm headers are present:
|
||||
|
||||
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||
- `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`
|
||||
- `Retry-After` on `429`
|
||||
|
||||
Proxy/IP caveat:
|
||||
|
||||
- Default IP source is `cf-connecting-ip`.
|
||||
- For non-Cloudflare trusted proxy setups, set `TRUST_FORWARDED_IPS=true`.
|
||||
- If proxy headers are not forwarded/trusted correctly, multiple users may collapse into one IP and hit false-positive rate limits.
|
||||
|
||||
+47
-6
@@ -15,7 +15,11 @@ OpenAPI: `/api/v1/openapi.json`.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Enforced per IP + per API key:
|
||||
Enforcement model:
|
||||
|
||||
- Anonymous requests: enforced per IP.
|
||||
- Authenticated requests (valid Bearer token): enforced per user bucket.
|
||||
- If token is missing/invalid, behavior falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
@@ -23,12 +27,43 @@ Enforced per IP + per API key:
|
||||
|
||||
Headers:
|
||||
|
||||
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (when limited)
|
||||
- Legacy compatibility: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||
- Standardized: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`
|
||||
- On `429`: `Retry-After`
|
||||
|
||||
Header semantics:
|
||||
|
||||
- `X-RateLimit-Reset`: absolute Unix epoch seconds
|
||||
- `RateLimit-Reset`: seconds until reset (delay)
|
||||
- `Retry-After`: seconds to wait before retry (delay) on `429`
|
||||
|
||||
Example `429` response:
|
||||
|
||||
```http
|
||||
HTTP/2 429
|
||||
content-type: text/plain; charset=utf-8
|
||||
x-ratelimit-limit: 20
|
||||
x-ratelimit-remaining: 0
|
||||
x-ratelimit-reset: 1771404540
|
||||
ratelimit-limit: 20
|
||||
ratelimit-remaining: 0
|
||||
ratelimit-reset: 34
|
||||
retry-after: 34
|
||||
|
||||
Rate limit exceeded
|
||||
```
|
||||
|
||||
Client guidance:
|
||||
|
||||
- If `Retry-After` exists, wait that many seconds before retry.
|
||||
- Use jittered backoff to avoid synchronized retries.
|
||||
- If `Retry-After` is missing, fallback to `RateLimit-Reset` (or compute from `X-RateLimit-Reset`).
|
||||
|
||||
IP source:
|
||||
|
||||
- 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).
|
||||
- Set `TRUST_FORWARDED_IPS=true` to opt in to `x-forwarded-for`, `x-real-ip`, or `fly-client-ip` (non-Cloudflare deployments).
|
||||
- If you run behind a reverse proxy/load balancer, ensure real client IP headers are preserved and trusted correctly, or rate limits may be too strict due to shared proxy IPs.
|
||||
|
||||
## Public endpoints (no auth)
|
||||
|
||||
@@ -65,7 +100,7 @@ Notes:
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "items": [{ "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" } }], "nextCursor": null }
|
||||
{ "items": [{ "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "metadata": { "os": ["macos"], "systems": ["aarch64-darwin"] } }], "nextCursor": null }
|
||||
```
|
||||
|
||||
### `GET /api/v1/skills/{slug}`
|
||||
@@ -73,9 +108,15 @@ Response:
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } }
|
||||
{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "metadata": { "os": ["macos"], "systems": ["aarch64-darwin"] }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `metadata.os`: OS restrictions declared in skill frontmatter (e.g. `["macos"]`, `["linux"]`). `null` if not declared.
|
||||
- `metadata.systems`: Nix system targets (e.g. `["aarch64-darwin", "x86_64-linux"]`). `null` if not declared.
|
||||
- `metadata` is `null` if the skill has no platform metadata.
|
||||
|
||||
### `GET /api/v1/skills/{slug}/versions`
|
||||
|
||||
Query params:
|
||||
@@ -154,7 +195,7 @@ Publishes a new version.
|
||||
|
||||
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||
|
||||
Soft-delete / restore a skill (moderator/admin only).
|
||||
Soft-delete / restore a skill (owner, moderator, or admin).
|
||||
|
||||
Status codes:
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ Install a skill into `./skills/<slug>` (if Clawdbot is configured, installs into
|
||||
```bash
|
||||
bun clawhub install <slug>
|
||||
bun clawhub list
|
||||
bun clawhub uninstall <slug> --yes
|
||||
```
|
||||
|
||||
You can also install into any folder:
|
||||
|
||||
+35
-11
@@ -10,32 +10,55 @@ read_when:
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills.
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills/comments.
|
||||
- moderator: hide/restore skills, view hidden skills, unhide, soft-delete, ban users (except admins).
|
||||
- admin: all moderator actions + hard delete skills, change owners, change roles.
|
||||
|
||||
## Reporting + auto-hide
|
||||
|
||||
- Reports are unique per user + skill.
|
||||
- Reports are unique per user + target (skill/comment).
|
||||
- Report reason required (trimmed, max 500 chars). Abuse of reporting may result in account bans.
|
||||
- Per-user cap: 20 **active** reports.
|
||||
- Active = skill exists, not soft-deleted, not `moderationStatus = removed`,
|
||||
- Active skill report = skill exists, not soft-deleted, not `moderationStatus = removed`,
|
||||
and the owner is not banned.
|
||||
- Auto-hide: when unique reports exceed 3 (4th report), the skill is:
|
||||
- soft-deleted (`softDeletedAt`)
|
||||
- `moderationStatus = hidden`
|
||||
- `moderationReason = auto.reports`
|
||||
- embeddings visibility set to `deleted`
|
||||
- audit log entry: `skill.auto_hide`
|
||||
- Active comment report = comment exists, not soft-deleted, parent skill still active,
|
||||
and the comment author is not banned/deactivated.
|
||||
- Auto-hide: when unique reports exceed 3 (4th report):
|
||||
- skill report flow:
|
||||
- soft-delete skill (`softDeletedAt`)
|
||||
- set `moderationStatus = hidden`
|
||||
- set `moderationReason = auto.reports`
|
||||
- set embeddings visibility `deleted`
|
||||
- audit log entry: `skill.auto_hide`
|
||||
- comment report flow:
|
||||
- soft-delete comment (`softDeletedAt`)
|
||||
- decrement comment stat via `uncomment` stat event
|
||||
- audit log entry: `comment.auto_hide`
|
||||
- Public queries hide non-active moderation statuses; staff can still access via
|
||||
staff-only queries and unhide/restore/delete/ban.
|
||||
- Skills directory supports an optional "Hide suspicious" filter to exclude
|
||||
active-but-flagged (`flagged.suspicious`) entries from browse/search results.
|
||||
|
||||
## AI comment scam backfill
|
||||
|
||||
- Moderators/admins can run a comment backfill scanner to classify scam comments with OpenAI.
|
||||
- Scanner stores per-comment moderation metadata:
|
||||
- `scamScanVerdict`: `not_scam | likely_scam | certain_scam`
|
||||
- `scamScanConfidence`: `low | medium | high`
|
||||
- explanation/evidence/model/check timestamp fields on `comments`.
|
||||
- Auto-ban trigger is intentionally strict:
|
||||
- only `certain_scam` with `high` confidence can trigger account ban.
|
||||
- moderator/admin accounts are never auto-banned by this pipeline.
|
||||
- Ban reason is bounded to 500 chars and includes concise evidence + comment/skill IDs.
|
||||
- CLI run examples:
|
||||
- one-shot: `npx convex run commentModeration:backfillCommentScamModeration '{"batchSize":25,"maxBatches":20}'`
|
||||
- background chain: `npx convex run commentModeration:scheduleCommentScamModeration '{"batchSize":25}'`
|
||||
|
||||
## Bans
|
||||
|
||||
- Banning a user:
|
||||
- hard-deletes all owned skills
|
||||
- soft-deletes all authored skill comments + soul comments
|
||||
- revokes API tokens
|
||||
- sets `deletedAt` on the user
|
||||
- Admins can manually unban (`deletedAt` + `banReason` cleared); revoked API tokens
|
||||
@@ -58,11 +81,12 @@ read_when:
|
||||
|
||||
## Upload gate (GitHub account age)
|
||||
|
||||
- Skill + soul publish actions require GitHub account age ≥ 7 days.
|
||||
- Skill + soul publish actions require GitHub account age ≥ 14 days.
|
||||
- Skill + soul comment creation also requires GitHub account age ≥ 14 days.
|
||||
- Lookup uses GitHub `created_at` fetched by the immutable GitHub numeric ID (`providerAccountId`)
|
||||
and caches on the user:
|
||||
- `githubCreatedAt` (source of truth)
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
- Gate applies to web uploads, CLI publish, GitHub import, and comments.
|
||||
- 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
|
||||
|
||||
+3
-2
@@ -125,7 +125,8 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- Default role `user`; bootstrap `steipete` to `admin` on first login.
|
||||
- Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
|
||||
- Role changes are admin-only and audited.
|
||||
- Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
|
||||
- Reporting: any user can report skills/comments; per-user cap 20 active reports; targets auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
|
||||
- Commenting (skills + souls) requires GitHub account age ≥ 14 days.
|
||||
|
||||
## Upload flow (50MB per version)
|
||||
1) Client requests upload session.
|
||||
@@ -136,7 +137,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- file extensions/text content
|
||||
- SKILL.md exists and frontmatter parseable
|
||||
- version uniqueness
|
||||
- GitHub account age ≥ 7 days
|
||||
- GitHub account age ≥ 14 days
|
||||
5) Server stores files + metadata, sets `latest` tag, updates stats.
|
||||
|
||||
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
|
||||
|
||||
@@ -18,6 +18,35 @@ read_when:
|
||||
- Token missing or revoked: check your config file (`CLAWHUB_CONFIG_PATH` override?).
|
||||
- Ensure requests include `Authorization: Bearer ...` (CLI does this automatically).
|
||||
|
||||
## CLI/API returns `Rate limit exceeded` (429)
|
||||
|
||||
- Read headers in the response:
|
||||
- `Retry-After` = wait seconds before retry
|
||||
- `RateLimit-Remaining` + `RateLimit-Limit` = current budget
|
||||
- `RateLimit-Reset` (or `X-RateLimit-Reset`) = reset timing
|
||||
- The CLI now includes retry hints in 429 errors (retry delay + remaining budget).
|
||||
- If many users share one egress IP (NAT/proxy), IP limit can be hit even with valid tokens.
|
||||
- For non-Cloudflare deploys behind trusted proxies, set `TRUST_FORWARDED_IPS=true` so forwarded client IPs can be used.
|
||||
|
||||
## `search` / `install` fails with `fetch failed` behind a proxy
|
||||
|
||||
If your system requires an HTTP proxy for outbound connections (e.g. corporate
|
||||
firewalls, Docker containers with proxy-only internet, Hetzner VPS), the CLI
|
||||
will fail with:
|
||||
|
||||
```
|
||||
✖ fetch failed
|
||||
Error: fetch failed
|
||||
```
|
||||
|
||||
**Fix:** Set the standard proxy environment variables:
|
||||
|
||||
```bash
|
||||
export HTTPS_PROXY=http://proxy.example.com:3128
|
||||
clawhub search "my query"
|
||||
```
|
||||
|
||||
The CLI respects `HTTPS_PROXY`, `HTTP_PROXY`, `https_proxy`, and `http_proxy`.
|
||||
## `publish` fails with `OPENAI_API_KEY is not configured`
|
||||
|
||||
- Set `OPENAI_API_KEY` in the Convex environment (not only locally).
|
||||
|
||||
@@ -42,8 +42,8 @@ test('header menu routes render', async ({ page }) => {
|
||||
}
|
||||
|
||||
if (label === 'Search') {
|
||||
await expect(page).toHaveURL(/\/?(\?|$)/)
|
||||
await expect(page.locator('h1', { hasText: 'ClawHub' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/skills(\?|$)/)
|
||||
await expect(page.locator('h1', { hasText: 'Skills' })).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -88,10 +88,24 @@ test('skills search paginates exact results', async ({ page }) => {
|
||||
await expect(page.getByText('Skill 0')).toBeVisible()
|
||||
await expect(page.getByText('Scroll to load more')).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits.length,
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
const initialLimit = await page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits[0] ?? 0,
|
||||
)
|
||||
expect(initialLimit).toBeGreaterThan(0)
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
await expect(page.getByText('Skill 75')).toBeVisible()
|
||||
await expect(page.getByText(`Skill ${initialLimit + 5}`)).toBeVisible()
|
||||
const limits = await page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits,
|
||||
)
|
||||
expect(limits).toEqual([50, 100])
|
||||
expect(Math.max(...limits)).toBeGreaterThan(initialLimit)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
import { cmdInspect } from './cli/commands/inspect.js'
|
||||
import { cmdBanUser, cmdSetRole } from './cli/commands/moderation.js'
|
||||
import { cmdPublish } from './cli/commands/publish.js'
|
||||
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
|
||||
import {
|
||||
cmdExplore,
|
||||
cmdInstall,
|
||||
cmdList,
|
||||
cmdSearch,
|
||||
cmdUninstall,
|
||||
cmdUpdate,
|
||||
} from './cli/commands/skills.js'
|
||||
import { cmdStarSkill } from './cli/commands/star.js'
|
||||
import { cmdSync } from './cli/commands/sync.js'
|
||||
import { cmdUnstarSkill } from './cli/commands/unstar.js'
|
||||
@@ -198,6 +205,16 @@ program
|
||||
await cmdUpdate(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('uninstall')
|
||||
.description('Uninstall a skill')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdUninstall(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('List installed skills (from lockfile)')
|
||||
@@ -261,7 +278,7 @@ program
|
||||
|
||||
program
|
||||
.command('delete')
|
||||
.description('Soft-delete a skill (moderator/admin only)')
|
||||
.description('Soft-delete a skill (owner, moderator, or admin)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -271,7 +288,7 @@ program
|
||||
|
||||
program
|
||||
.command('hide')
|
||||
.description('Hide a skill (moderator/admin only)')
|
||||
.description('Hide a skill (owner, moderator, or admin)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -281,7 +298,7 @@ program
|
||||
|
||||
program
|
||||
.command('undelete')
|
||||
.description('Restore a hidden skill (moderator/admin only)')
|
||||
.description('Restore a hidden skill (owner, moderator, or admin)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -291,7 +308,7 @@ program
|
||||
|
||||
program
|
||||
.command('unhide')
|
||||
.description('Unhide a skill (moderator/admin only)')
|
||||
.description('Unhide a skill (owner, moderator, or admin)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveHome } from '../homedir.js'
|
||||
import { resolveClawdbotDefaultWorkspace, resolveClawdbotSkillRoots } from './clawdbotConfig.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
@@ -177,6 +178,40 @@ describe('resolveClawdbotSkillRoots', () => {
|
||||
expect(labels[resolve(openclawStateDir, 'skills')]).toBe('OpenClaw: Shared skills')
|
||||
})
|
||||
|
||||
it('uses $HOME over os.homedir() for tilde expansion', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'clawhub-home-override-'))
|
||||
const customHome = join(base, 'custom-home')
|
||||
const stateDir = join(base, 'state')
|
||||
const configPath = join(base, 'clawdbot.json')
|
||||
const openclawStateDir = join(base, 'openclaw-state')
|
||||
|
||||
process.env.HOME = customHome
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir
|
||||
process.env.CLAWDBOT_CONFIG_PATH = configPath
|
||||
process.env.OPENCLAW_STATE_DIR = openclawStateDir
|
||||
process.env.OPENCLAW_CONFIG_PATH = join(openclawStateDir, 'openclaw.json')
|
||||
|
||||
const config = `{
|
||||
agents: {
|
||||
defaults: { workspace: "~/my-workspace" },
|
||||
},
|
||||
}`
|
||||
await writeFile(configPath, config, 'utf8')
|
||||
|
||||
const workspace = await resolveClawdbotDefaultWorkspace()
|
||||
expect(workspace).toBe(resolve(customHome, 'my-workspace'))
|
||||
expect(resolveHome()).toBe(customHome)
|
||||
})
|
||||
|
||||
it('normalizes trailing separators in $HOME', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'clawhub-home-trailing-'))
|
||||
const customHome = join(base, 'custom-home')
|
||||
|
||||
process.env.HOME = `${customHome}/`
|
||||
|
||||
expect(resolveHome()).toBe(customHome)
|
||||
})
|
||||
|
||||
it('supports OpenClaw configuration files', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'clawhub-openclaw-'))
|
||||
const stateDir = join(base, 'openclaw-state')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import JSON5 from 'json5'
|
||||
import { resolveHome } from '../homedir.js'
|
||||
|
||||
type ClawdbotConfig = {
|
||||
agent?: { workspace?: string }
|
||||
@@ -95,7 +95,7 @@ export async function resolveClawdbotDefaultWorkspace(): Promise<string | null>
|
||||
function resolveClawdbotStateDir() {
|
||||
const override = process.env.CLAWDBOT_STATE_DIR?.trim()
|
||||
if (override) return resolveUserPath(override)
|
||||
return join(homedir(), '.clawdbot')
|
||||
return join(resolveHome(), '.clawdbot')
|
||||
}
|
||||
|
||||
function resolveClawdbotConfigPath() {
|
||||
@@ -107,7 +107,7 @@ function resolveClawdbotConfigPath() {
|
||||
function resolveOpenclawStateDir() {
|
||||
const override = process.env.OPENCLAW_STATE_DIR?.trim()
|
||||
if (override) return resolveUserPath(override)
|
||||
return join(homedir(), '.openclaw')
|
||||
return join(resolveHome(), '.openclaw')
|
||||
}
|
||||
|
||||
function resolveOpenclawConfigPath() {
|
||||
@@ -120,7 +120,7 @@ function resolveUserPath(input: string) {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('~')) {
|
||||
return resolve(trimmed.replace(/^~(?=$|[\\/])/, homedir()))
|
||||
return resolve(trimmed.replace(/^~(?=$|[\\/])/, resolveHome()))
|
||||
}
|
||||
return resolve(trimmed)
|
||||
}
|
||||
|
||||
@@ -16,28 +16,28 @@ const deleteLabels: SkillActionLabels = {
|
||||
verb: 'Delete',
|
||||
progress: 'Deleting',
|
||||
past: 'Deleted',
|
||||
promptSuffix: 'soft delete, requires moderator/admin',
|
||||
promptSuffix: 'soft delete, owner/moderator/admin',
|
||||
}
|
||||
|
||||
const undeleteLabels: SkillActionLabels = {
|
||||
verb: 'Undelete',
|
||||
progress: 'Undeleting',
|
||||
past: 'Undeleted',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
promptSuffix: 'owner/moderator/admin',
|
||||
}
|
||||
|
||||
const hideLabels: SkillActionLabels = {
|
||||
verb: 'Hide',
|
||||
progress: 'Hiding',
|
||||
past: 'Hidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
promptSuffix: 'owner/moderator/admin',
|
||||
}
|
||||
|
||||
const unhideLabels: SkillActionLabels = {
|
||||
verb: 'Unhide',
|
||||
progress: 'Unhiding',
|
||||
past: 'Unhidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
promptSuffix: 'owner/moderator/admin',
|
||||
}
|
||||
|
||||
export async function cmdDeleteSkill(
|
||||
|
||||
@@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockFetchText = vi.fn()
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith('/') ? registry : `${registry}/`
|
||||
const relative = path.startsWith('/') ? path.slice(1) : path
|
||||
return new URL(relative, base)
|
||||
})
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
@@ -120,6 +126,45 @@ describe('cmdInspect', () => {
|
||||
expect(url.searchParams.get('version')).toBeNull()
|
||||
})
|
||||
|
||||
it('prints security summary when version security metadata exists', async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
summary: null,
|
||||
tags: { latest: '2.0.0' },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
skill: { slug: 'demo', displayName: 'Demo' },
|
||||
version: {
|
||||
version: '2.0.0',
|
||||
createdAt: 3,
|
||||
changelog: 'init',
|
||||
files: [],
|
||||
security: {
|
||||
status: 'suspicious',
|
||||
hasWarnings: true,
|
||||
checkedAt: 1_700_000_000_000,
|
||||
model: 'gpt-5.2',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { version: '2.0.0' })
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith('Security: SUSPICIOUS')
|
||||
expect(mockLog).toHaveBeenCalledWith('Warnings: yes')
|
||||
expect(mockLog).toHaveBeenCalledWith('Checked: 2023-11-14T22:13:20.000Z')
|
||||
expect(mockLog).toHaveBeenCalledWith('Model: gpt-5.2')
|
||||
})
|
||||
|
||||
it('rejects when both version and tag are provided', async () => {
|
||||
await expect(
|
||||
cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest, fetchText } from '../../http.js'
|
||||
import { apiRequest, fetchText, registryUrl } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillResponseSchema,
|
||||
@@ -27,6 +27,13 @@ type FileEntry = {
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
type SecurityStatus = {
|
||||
status: 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error'
|
||||
hasWarnings: boolean
|
||||
checkedAt: number | null
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) fail('Slug required')
|
||||
@@ -78,7 +85,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null
|
||||
if (options.versions) {
|
||||
const limit = clampLimit(options.limit ?? 25, 25)
|
||||
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
|
||||
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
spinner.text = `Fetching versions (${limit})`
|
||||
versionsList = await apiRequest(
|
||||
@@ -90,7 +97,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
|
||||
let fileContent: string | null = null
|
||||
if (options.file) {
|
||||
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
|
||||
const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
|
||||
url.searchParams.set('path', options.file)
|
||||
if (options.version) {
|
||||
url.searchParams.set('version', options.version)
|
||||
@@ -130,6 +137,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
|
||||
if (shouldPrintMeta && versionResult?.version) {
|
||||
printVersionSummary(versionResult.version)
|
||||
printSecuritySummary(versionResult.version)
|
||||
}
|
||||
|
||||
if (versionsList?.items && Array.isArray(versionsList.items)) {
|
||||
@@ -258,6 +266,50 @@ function formatVersionLine(item: unknown) {
|
||||
return `${version} ${createdAt}${snippet}`
|
||||
}
|
||||
|
||||
function printSecuritySummary(version: unknown) {
|
||||
if (!version || typeof version !== 'object') return
|
||||
const sec = normalizeSecurity((version as { security?: unknown }).security)
|
||||
if (!sec) return
|
||||
console.log(`Security: ${sec.status.toUpperCase()}`)
|
||||
if (sec.hasWarnings) {
|
||||
console.log('Warnings: yes')
|
||||
}
|
||||
if (typeof sec.checkedAt === 'number') {
|
||||
console.log(`Checked: ${formatTimestamp(sec.checkedAt)}`)
|
||||
}
|
||||
if (sec.model) {
|
||||
console.log(`Model: ${sec.model}`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecurity(security: unknown): SecurityStatus | null {
|
||||
if (!security || typeof security !== 'object') return null
|
||||
const value = security as {
|
||||
status?: unknown
|
||||
hasWarnings?: unknown
|
||||
checkedAt?: unknown
|
||||
model?: unknown
|
||||
}
|
||||
if (
|
||||
value.status !== 'clean' &&
|
||||
value.status !== 'suspicious' &&
|
||||
value.status !== 'malicious' &&
|
||||
value.status !== 'pending' &&
|
||||
value.status !== 'error'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (typeof value.hasWarnings !== 'boolean') return null
|
||||
const checkedAt = typeof value.checkedAt === 'number' ? value.checkedAt : null
|
||||
const model = typeof value.model === 'string' ? value.model : null
|
||||
return {
|
||||
status: value.status,
|
||||
hasWarnings: value.hasWarnings,
|
||||
checkedAt,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileLine(file: FileEntry) {
|
||||
const size = file.size === null ? '?' : formatBytes(file.size)
|
||||
const sha = file.sha256 ?? '?'
|
||||
|
||||
@@ -12,9 +12,15 @@ vi.mock('../registry.js', () => ({
|
||||
}))
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith('/') ? registry : `${registry}/`
|
||||
const relative = path.startsWith('/') ? path.slice(1) : path
|
||||
return new URL(relative, base)
|
||||
})
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../ui.js', () => ({
|
||||
@@ -116,7 +122,7 @@ describe('cmdBanUser', () => {
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
path: expect.stringContaining('/api/v1/users?'),
|
||||
url: expect.stringContaining('/api/v1/users?'),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isCancel, select } from '@clack/prompts'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { apiRequest, registryUrl } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1BanUserResponseSchema,
|
||||
@@ -192,12 +192,12 @@ async function resolveUserIdentifier(
|
||||
}
|
||||
|
||||
async function searchUsers(registry: string, token: string, query: string) {
|
||||
const url = new URL(ApiRoutes.users, registry)
|
||||
const url = registryUrl(ApiRoutes.users, registry)
|
||||
url.searchParams.set('q', query.trim())
|
||||
url.searchParams.set('limit', '10')
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${url.pathname}?${url.searchParams.toString()}`, token },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1UserSearchResponseSchema,
|
||||
)
|
||||
return parseArk(ApiV1UserSearchResponseSchema, result, 'User search response')
|
||||
|
||||
@@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockDownloadZip = vi.fn()
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith('/') ? registry : `${registry}/`
|
||||
const relative = path.startsWith('/') ? path.slice(1) : path
|
||||
return new URL(relative, base)
|
||||
})
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
@@ -29,14 +35,16 @@ const mockSpinner = {
|
||||
isSpinning: false,
|
||||
text: '',
|
||||
}
|
||||
const mockIsInteractive = vi.fn(() => false)
|
||||
const mockPromptConfirm = vi.fn(async () => false)
|
||||
vi.mock('../ui.js', () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message)
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => false),
|
||||
isInteractive: mockIsInteractive,
|
||||
promptConfirm: mockPromptConfirm,
|
||||
}))
|
||||
|
||||
vi.mock('../../skills.js', () => ({
|
||||
@@ -55,7 +63,8 @@ vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const { clampLimit, cmdExplore, cmdInstall, cmdSearch, cmdUninstall, cmdUpdate, formatExploreLine } =
|
||||
await import('./skills')
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
@@ -113,6 +122,16 @@ describe('explore helpers', () => {
|
||||
})
|
||||
|
||||
describe('cmdExplore', () => {
|
||||
it('passes optional auth token to apiRequest', async () => {
|
||||
mockGetOptionalAuthToken.mockResolvedValue('tkn')
|
||||
mockApiRequest.mockResolvedValue({ items: [] })
|
||||
|
||||
await cmdExplore(makeOpts(), { limit: 25 })
|
||||
|
||||
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(requestArgs?.token).toBe('tkn')
|
||||
})
|
||||
|
||||
it('clamps limit and handles empty results', async () => {
|
||||
mockApiRequest.mockResolvedValue({ items: [] })
|
||||
|
||||
@@ -170,6 +189,18 @@ describe('cmdExplore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdSearch', () => {
|
||||
it('passes optional auth token to apiRequest', async () => {
|
||||
mockGetOptionalAuthToken.mockResolvedValue('tkn')
|
||||
mockApiRequest.mockResolvedValue({ results: [] })
|
||||
|
||||
await cmdSearch(makeOpts(), 'demo')
|
||||
|
||||
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(requestArgs?.token).toBe('tkn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdUpdate', () => {
|
||||
it('uses path-based skill lookup when no local fingerprint is available', async () => {
|
||||
mockApiRequest.mockResolvedValue({ latestVersion: { version: '1.0.0' } })
|
||||
@@ -220,3 +251,143 @@ describe('cmdInstall', () => {
|
||||
expect(zipArgs?.token).toBe('tkn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdUninstall', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
|
||||
await expect(cmdUninstall(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('prompts when interactive and proceeds on confirm', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
mockIsInteractive.mockReturnValue(true)
|
||||
mockPromptConfirm.mockResolvedValue(true)
|
||||
|
||||
await cmdUninstall(makeOpts(), 'demo', {}, true)
|
||||
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith('Uninstall demo?')
|
||||
expect(rm).toHaveBeenCalledWith('/work/skills/demo', { recursive: true, force: true })
|
||||
expect(writeLockfile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints Cancelled and does not remove when prompt declines', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
mockIsInteractive.mockReturnValue(true)
|
||||
mockPromptConfirm.mockResolvedValue(false)
|
||||
|
||||
await cmdUninstall(makeOpts(), 'demo', {}, true)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith('Cancelled.')
|
||||
expect(rm).not.toHaveBeenCalled()
|
||||
expect(writeLockfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unsafe slugs', async () => {
|
||||
await expect(cmdUninstall(makeOpts(), '../evil', { yes: true }, false)).rejects.toThrow(
|
||||
/invalid slug/i,
|
||||
)
|
||||
await expect(cmdUninstall(makeOpts(), 'demo/evil', { yes: true }, false)).rejects.toThrow(
|
||||
/invalid slug/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails when skill is not installed', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
|
||||
|
||||
await expect(cmdUninstall(makeOpts(), 'missing', {}, false)).rejects.toThrow(
|
||||
'Not installed: missing',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes skill directory and lockfile entry with --yes flag', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUninstall(makeOpts(), 'demo', { yes: true }, false)
|
||||
|
||||
expect(rm).toHaveBeenCalledWith('/work/skills/demo', { recursive: true, force: true })
|
||||
expect(writeLockfile).toHaveBeenCalledWith('/work', {
|
||||
version: 1,
|
||||
skills: {},
|
||||
})
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith('Uninstalled demo')
|
||||
})
|
||||
|
||||
it('does not update lockfile if remove fails', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(rm).mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(cmdUninstall(makeOpts(), 'demo', { yes: true }, false)).rejects.toThrow('nope')
|
||||
|
||||
expect(writeLockfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates lockfile after removing directory', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUninstall(makeOpts(), 'demo', { yes: true }, false)
|
||||
|
||||
const rmMock = vi.mocked(rm)
|
||||
const writeLockfileMock = vi.mocked(writeLockfile)
|
||||
expect(rmMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
writeLockfileMock.mock.invocationCallOrder[0],
|
||||
)
|
||||
})
|
||||
|
||||
it('removes skill and updates lockfile keeping other skills', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
demo: { version: '1.0.0', installedAt: 123 },
|
||||
other: { version: '2.0.0', installedAt: 456 },
|
||||
},
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUninstall(makeOpts(), 'demo', { yes: true }, false)
|
||||
|
||||
expect(rm).toHaveBeenCalledWith('/work/skills/demo', { recursive: true, force: true })
|
||||
expect(writeLockfile).toHaveBeenCalledWith('/work', {
|
||||
version: 1,
|
||||
skills: { other: { version: '2.0.0', installedAt: 456 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('trims slug whitespace', async () => {
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 123 } },
|
||||
})
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdUninstall(makeOpts(), ' demo ', { yes: true }, false)
|
||||
|
||||
expect(rm).toHaveBeenCalledWith('/work/skills/demo', { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import semver from 'semver'
|
||||
import { apiRequest, downloadZip } from '../../http.js'
|
||||
import { apiRequest, downloadZip, registryUrl } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
@@ -23,20 +23,35 @@ import type { GlobalOpts, ResolveResult } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
import { getOptionalAuthToken } from '../authToken.js'
|
||||
|
||||
function normalizeSkillSlugOrFail(raw: string) {
|
||||
const slug = raw.trim()
|
||||
if (!slug) fail('Slug required')
|
||||
// Safety: never allow path traversal or nested paths to become filesystem operations.
|
||||
if (slug.includes('/') || slug.includes('\\') || slug.includes('..')) {
|
||||
fail(`Invalid slug: ${slug}`)
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
function isSafeSkillSlug(slug: string) {
|
||||
return Boolean(slug) && !slug.includes('/') && !slug.includes('\\') && !slug.includes('..')
|
||||
}
|
||||
|
||||
export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) {
|
||||
if (!query) fail('Query required')
|
||||
|
||||
const token = await getOptionalAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Searching')
|
||||
try {
|
||||
const url = new URL(ApiRoutes.search, registry)
|
||||
const url = registryUrl(ApiRoutes.search, registry)
|
||||
url.searchParams.set('q', query)
|
||||
if (typeof limit === 'number' && Number.isFinite(limit)) {
|
||||
url.searchParams.set('limit', String(limit))
|
||||
}
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SearchResponseSchema,
|
||||
)
|
||||
|
||||
@@ -59,8 +74,7 @@ export async function cmdInstall(
|
||||
versionFlag?: string,
|
||||
force = false,
|
||||
) {
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) fail('Slug required')
|
||||
const trimmed = normalizeSkillSlugOrFail(slug)
|
||||
|
||||
const token = await getOptionalAuthToken()
|
||||
|
||||
@@ -139,19 +153,19 @@ export async function cmdUpdate(
|
||||
options: { all?: boolean; version?: string; force?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg?.trim()
|
||||
const slug = slugArg ? normalizeSkillSlugOrFail(slugArg) : undefined
|
||||
const all = Boolean(options.all)
|
||||
if (!slug && !all) fail('Provide <slug> or --all')
|
||||
if (slug && all) fail('Use either <slug> or --all')
|
||||
if (options.version && !slug) fail('--version requires a single <slug>')
|
||||
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const allowPrompt = isInteractive() && inputAllowed
|
||||
|
||||
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)
|
||||
const slugs = slug ? [slug] : Object.keys(lock.skills).filter(isSafeSkillSlug)
|
||||
if (slugs.length === 0) {
|
||||
console.log('No installed skills.')
|
||||
return
|
||||
@@ -295,6 +309,45 @@ export async function cmdList(opts: GlobalOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdUninstall(
|
||||
opts: GlobalOpts,
|
||||
slug: string,
|
||||
options: { yes?: boolean } = {},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const trimmed = normalizeSkillSlugOrFail(slug)
|
||||
|
||||
const lock = await readLockfile(opts.workdir)
|
||||
if (!lock.skills[trimmed]) {
|
||||
fail(`Not installed: ${trimmed}`)
|
||||
}
|
||||
|
||||
const allowPrompt = isInteractive() && inputAllowed
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const confirm = await promptConfirm(`Uninstall ${trimmed}?`)
|
||||
if (!confirm) {
|
||||
console.log('Cancelled.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Uninstalling ${trimmed}`)
|
||||
try {
|
||||
const target = join(opts.dir, trimmed)
|
||||
|
||||
await rm(target, { recursive: true, force: true })
|
||||
|
||||
delete lock.skills[trimmed]
|
||||
await writeLockfile(opts.workdir, lock)
|
||||
|
||||
spinner.succeed(`Uninstalled ${trimmed}`)
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
type ExploreSort = 'newest' | 'downloads' | 'rating' | 'installs' | 'installsAllTime' | 'trending'
|
||||
type ApiExploreSort =
|
||||
| 'updated'
|
||||
@@ -308,17 +361,18 @@ export async function cmdExplore(
|
||||
opts: GlobalOpts,
|
||||
options: { limit?: number; sort?: string; json?: boolean } = {},
|
||||
) {
|
||||
const token = await getOptionalAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching latest skills')
|
||||
try {
|
||||
const url = new URL(ApiRoutes.skills, registry)
|
||||
const url = registryUrl(ApiRoutes.skills, registry)
|
||||
const boundedLimit = clampLimit(options.limit ?? 25)
|
||||
const { apiSort } = resolveExploreSort(options.sort)
|
||||
url.searchParams.set('limit', String(boundedLimit))
|
||||
if (apiSort !== 'updated') url.searchParams.set('sort', apiSort)
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SkillListResponseSchema,
|
||||
)
|
||||
|
||||
@@ -413,7 +467,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
|
||||
const url = new URL(ApiRoutes.resolve, registry)
|
||||
const url = registryUrl(ApiRoutes.resolve, registry)
|
||||
url.searchParams.set('slug', slug)
|
||||
url.searchParams.set('hash', hash)
|
||||
return apiRequest(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolveHome } from '../../homedir.js'
|
||||
import { isCancel, multiselect } from '@clack/prompts'
|
||||
import semver from 'semver'
|
||||
import { apiRequest, downloadZip } from '../../http.js'
|
||||
@@ -338,7 +338,7 @@ export function printSection(title: string, body?: string) {
|
||||
}
|
||||
|
||||
function abbreviatePath(value: string) {
|
||||
const home = homedir()
|
||||
const home = resolveHome()
|
||||
if (value.startsWith(home)) return `~${value.slice(home.length)}`
|
||||
return value
|
||||
}
|
||||
@@ -348,7 +348,7 @@ function rootTelemetryId(value: string) {
|
||||
}
|
||||
|
||||
function formatRootLabel(value: string) {
|
||||
const home = homedir()
|
||||
const home = resolveHome()
|
||||
if (value === home) return '~'
|
||||
|
||||
const normalized = value.replaceAll('\\', '/')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { resolveHome } from '../homedir.js'
|
||||
import { sanitizeSlug, titleCase } from './slug.js'
|
||||
|
||||
export type SkillFolder = {
|
||||
@@ -30,7 +30,7 @@ export async function findSkillFolders(root: string): Promise<SkillFolder[]> {
|
||||
}
|
||||
|
||||
export function getFallbackSkillRoots(workdir: string) {
|
||||
const home = homedir()
|
||||
const home = resolveHome()
|
||||
const roots = [
|
||||
// adjacent repo installs
|
||||
resolve(workdir, '..', 'clawdis', 'skills'),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSpawn = vi.fn()
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
}))
|
||||
|
||||
const { openInBrowser } = await import('./ui')
|
||||
|
||||
type ErrorHandler = (error: NodeJS.ErrnoException) => void
|
||||
|
||||
function createMockChild() {
|
||||
let onError: ErrorHandler | null = null
|
||||
const child = {
|
||||
on: vi.fn((event: string, handler: ErrorHandler) => {
|
||||
if (event === 'error') onError = handler
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
emitError: (error: NodeJS.ErrnoException) => onError?.(error),
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
describe('openInBrowser', () => {
|
||||
it('prints manual URL instructions when browser opener is missing', () => {
|
||||
const child = createMockChild()
|
||||
mockSpawn.mockReturnValueOnce(child)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
openInBrowser('https://clawhub.ai')
|
||||
child.emitError(Object.assign(new Error('not found'), { code: 'ENOENT' }))
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Could not open browser automatically.')
|
||||
expect(logSpy).toHaveBeenCalledWith('Please open this URL manually:')
|
||||
expect(logSpy).toHaveBeenCalledWith(' https://clawhub.ai')
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not print manual instructions for non-ENOENT errors', () => {
|
||||
const child = createMockChild()
|
||||
mockSpawn.mockReturnValueOnce(child)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
openInBrowser('https://clawhub.ai')
|
||||
child.emitError(Object.assign(new Error('permission denied'), { code: 'EACCES' }))
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalledWith('Could not open browser automatically.')
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,20 @@ export function openInBrowser(url: string) {
|
||||
: ['xdg-open', url]
|
||||
const [command, ...commandArgs] = args
|
||||
if (!command) return
|
||||
|
||||
const child = spawn(command, commandArgs, { stdio: 'ignore', detached: true })
|
||||
|
||||
child.on('error', (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
console.log('')
|
||||
console.log('Could not open browser automatically.')
|
||||
console.log('Please open this URL manually:')
|
||||
console.log('')
|
||||
console.log(` ${url}`)
|
||||
console.log('')
|
||||
}
|
||||
})
|
||||
|
||||
child.unref()
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user