mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 01:42:07 +00:00
Compare commits
14
Commits
shadow/rewrite
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96e9ffdcdc | ||
|
|
eb4601141e | ||
|
|
39686b3b8d | ||
|
|
a24d3e9809 | ||
|
|
405c74a4ef | ||
|
|
ee828046b8 | ||
|
|
789082bc00 | ||
|
|
3e4c2450cd | ||
|
|
ea2f51d2ba | ||
|
|
7b2bdbd08f | ||
|
|
f654dc9325 | ||
|
|
d78c105570 | ||
|
|
a32498ea7d | ||
|
|
5d6ee7adf3 |
+11
-2
@@ -3,20 +3,29 @@
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Admin: ban users and delete owned skills from management console.
|
||||
- Moderation: auto-hide skills after 4 unique reports; per-user report cap; moderators can ban users.
|
||||
- Uploads: require GitHub accounts to be at least 7 days old for skill + soul publish/import.
|
||||
- CLI: add `inspect` to fetch skill metadata/files without installing.
|
||||
- CLI: add moderation commands for hide/unhide/delete and ban users.
|
||||
- Management: add filters for reported skills and users.
|
||||
|
||||
### Changed
|
||||
- Deps: update dependencies to latest available versions.
|
||||
- Reporting: require reasons, show them in management console, warn about abuse bans.
|
||||
|
||||
### Fixed
|
||||
- Bans: batch hard-delete cleanup to avoid Convex read limits on large skills.
|
||||
|
||||
## 0.4.0 - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Web: show published skills on user profiles (thanks @njoylab, #20).
|
||||
- CLI: include OpenClaw + Moltbot fallback skill roots for sync scans.
|
||||
- CLI: include ClawHub + Moltbot fallback skill roots for sync scans.
|
||||
- CLI: support OpenClaw configuration files (`OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`).
|
||||
|
||||
### Changed
|
||||
- Brand: rebrand to OpenClaw and publish CLI as `clawhub` (legacy `clawdhub` supported).
|
||||
- Brand: rebrand to ClawHub and publish CLI as `clawhub` (legacy `clawdhub` supported).
|
||||
- Domain: default site/registry now `https://clawhub.ai`; `.well-known/clawhub.json` preferred.
|
||||
- Theme: persist theme under `clawhub-theme` (legacy key still read).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# OpenClaw
|
||||
# ClawHub
|
||||
|
||||
<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>
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
OpenClaw is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
ClawHub is the **public skill registry for 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.
|
||||
|
||||
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
|
||||
@@ -27,7 +27,7 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
|
||||
- Entry point is host-based: `onlycrabs.ai`.
|
||||
- On the onlycrabs.ai host, the home page and nav default to souls.
|
||||
- On OpenClaw, souls live under `/souls`.
|
||||
- On ClawHub, souls live under `/souls`.
|
||||
- Soul bundles only accept `SOUL.md` for now (no extra files).
|
||||
|
||||
## How it works (high level)
|
||||
@@ -37,9 +37,10 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
|
||||
- API schema + routes: `packages/schema` (`clawhub-schema`).
|
||||
|
||||
|
||||
## Telemetry
|
||||
|
||||
OpenClaw tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
Disable via:
|
||||
|
||||
```bash
|
||||
@@ -95,7 +96,7 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
|
||||
|
||||
## Nix plugins (nixmode skills)
|
||||
|
||||
OpenClaw can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
ClawHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
Nix package bundle to install. A nix plugin is different from a regular skill pack: it bundles the
|
||||
skill pack, the CLI binary, and its config flags/requirements together.
|
||||
|
||||
|
||||
@@ -15,50 +15,50 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.6",
|
||||
"@tanstack/react-router-devtools": "^1.151.6",
|
||||
"@tanstack/react-start": "^1.152.0",
|
||||
"@tanstack/router-plugin": "^1.151.6",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-router": "^1.157.18",
|
||||
"@tanstack/react-router-devtools": "^1.157.18",
|
||||
"@tanstack/react-start": "^1.157.18",
|
||||
"@tanstack/router-plugin": "^1.157.18",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.6",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "^3.0.1-alpha.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite-tsconfig-paths": "^6.0.4",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"yaml": "^2.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tanstack/devtools-vite": "^0.4.1",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@tanstack/devtools-vite": "^0.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/react": "^19.2.8",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"only-allow": "^1.2.1",
|
||||
"oxlint": "^1.39.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
@@ -252,7 +252,7 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
|
||||
"@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
@@ -380,17 +380,17 @@
|
||||
|
||||
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QROrowwlrApI1fEScMknGWKM6GTM/Z2xwMnDqvSaEmzNazBsDUlE08Jasw610hFEsYAVU2K5sp/YaCa9ORdP4A=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FU4e+w09D+2rkCVdL7I7zMuQOJ2tuapVhBGPGY66VAct2FUwFDVmgU+rNJ2hHIdc9uHg24v+FD8PcfFYpask8Q=="],
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IhdhiC183s5wdFDZSQC8PaFFq1QROiVT5ahz7ysgEKVnkNDjy82ieM7ZKiUfm2ncXNX2RcFGSSZrQO6plR+VAQ=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7sm1d920HfFsC3hIP7SJVm11WhYufA8qnLQQVk7odTpSzVUAT1jtG8LdfFigzgb38zHszQbsqJ7OjAgIW/OgmA=="],
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-KJmBg10Z1uGpJqxDzETXOytYyeVrKUepo8rCXeVkRlZ2QzZqMElgalFN4BI3ccgIPkQpzzu4SVzWNFz7yiKavQ=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-eoJfdmHcpG9k8fufb8yL3rC3HC6QELoTEfs56lmGaRIHHmd1aj4MWDbGCqdRqPEp7oC5fVvFxi7wDkA1MDf99Q=="],
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-P6I3dSSpoEnjFzTMlrbcBHNbErSxceZmcVUslBxrrIUH1NSVS1XfSz6S75vT2Gay7Jv6LI7zTTVAk4cSqkfe+w=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-t7jGK0vBApuAGvOnCPTxsdX+1e9nMdvqU3zHCJWQ7yUDaJxki0bCy4zbKfUgVo8ePeVRgIKWwqLFBOVTXQ5AMQ=="],
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-G0eAW3S7cp/vP7Kx6e7+Ze7WfNgSt1tc/rOexfLKnnIi+9BelyOa2wF9bWFPpxk3n3AdkBwKttU1/adDZlD87Q=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-6ellG0zcWnj2b6Mr7fl19x+nlFIWGWoKCBlYnqNZ4CaziRYGpYx7PLwHhPJq331w7zzRRSnYqhyTrVluYjZADQ=="],
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-prgQEBiwp4TAxarh6dYbVOKw6riRJ6hB49vDD6DxQlOZQky7xHQ9qTec5/rf0JTUZ16YaJ9YfHycbJS3QVpTYw=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.3", "", { "os": "win32", "cpu": "x64" }, "sha512-rzvfaRJPK9eRYVWMXCt8JtvOsVFAsqScgsFhnXzsipU6W1Te0g+b4q068o7hZ3NRTjJxNgFJj8ayOkZ6NbX0tA=="],
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-5xXTzZIT/1meWMmS60Q+FYWvWncc6iTfC8tyQt7GDfPUoqQvE5WVgHm1QjDSJvxTD+6AHphpCqdhXq/KtxagRw=="],
|
||||
|
||||
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.42.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ui5CdAcDsXPQwZQEXOOSWsilJWhgj9jqHCvYBm2tDE8zfwZZuF9q58+hGKH1x5y0SV4sRlyobB2Quq6uU6EgeA=="],
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.0", "", { "dependencies": { "playwright": "1.58.0" }, "bin": { "playwright": "cli.js" } }, "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg=="],
|
||||
"@playwright/test": ["@playwright/test@1.58.1", "", { "dependencies": { "playwright": "1.58.1" }, "bin": { "playwright": "cli.js" } }, "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
@@ -566,7 +566,7 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tanstack/devtools": ["@tanstack/devtools@0.10.4", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-GR/HMWe+eAZgSm/mOeuWMs/cXy3pEcrdMBU+OH0c6Qv1IXYv/xqru4aCSJPe+2/eJXng5ioqCsoVt9MztyU1mg=="],
|
||||
"@tanstack/devtools": ["@tanstack/devtools@0.10.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-aptV4sMcdEn/zB8zqNqKSKi8pLzfB7BhdP2MuVmyfWgBDYNchqJjhviaxEXW3tJTolbWwc30o+jszwqxOIcIaA=="],
|
||||
|
||||
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0" } }, "sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA=="],
|
||||
|
||||
@@ -576,43 +576,43 @@
|
||||
|
||||
"@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.4", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg=="],
|
||||
|
||||
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.4.1", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-PkMOomcWnl/pUkCqIjqL/csjPHtkMVBirDpJVOZR7XJZDxo5CuD7B+3KsujFCF4Dsn6QYlae97gCZvxi/CB76Q=="],
|
||||
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.5.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-Ew+ZdTnmTlVjm4q+/XY/dolx/E1BWMYpiRDyU/MXqHf5epri4MLl5C4UZJaO+ZuUCsKPpsW+ufoM99E2Z4rhug=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
||||
|
||||
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.3", "", { "dependencies": { "@tanstack/devtools": "0.10.4" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-SJTYWXWZkbWznwUwZ11awinPGB5StVIVyJXT0BFM1zUgjuajRwT8xRHl1oXVzVqqjJP5kfj89jkbFrcQPpq7Ng=="],
|
||||
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.4", "", { "dependencies": { "@tanstack/devtools": "0.10.5" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-6wQf8gVKDks1VL+LI5SS4XWK8dQLIjcDF3iMZfidyesWJNmodWbWlRkdgCmK5SpDSbcygjbp3p+LG2nE/SZ1bQ=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.18", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qs//HcVhEZ0K2/Sqejol0vOWaFIh4EoYTQQix9FhHOyWvdUpGoTJS0+g/qxEnZZm7r9QNOrnyrYZ5CDAqnII6g=="],
|
||||
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.16", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.16" }, "peerDependencies": { "@tanstack/react-router": "^1.157.16", "@tanstack/router-core": "^1.157.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-g6ekyzumfLBX6T5e+Vu2r37Z2CFJKrWRFqIy3vZ6A3x7OcuPV8uXNjyrLSiT/IsGTiF8YzwI4nWJa4fyd7NlCw=="],
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.18", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.18" }, "peerDependencies": { "@tanstack/react-router": "^1.157.18", "@tanstack/router-core": "^1.157.18", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-JHSOnwj8wkim1AppgPT1Jp+NtmiiJ4yLZ//Vo0sfrQSgOzlZgdFg4OdQP+9rYzuj3BNz+XoDdokXQhJxiSqSww=="],
|
||||
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/react-start-client": "1.157.16", "@tanstack/react-start-server": "1.157.16", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-plugin-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-FO6UYjsZyNaC0ickSSvClqfVZemp9/HWnbRJQU2dOKYQsI+wnznhLp9IkgG90iFBLcuMAWhcNHMiIuz603GJBg=="],
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/react-start-client": "1.157.18", "@tanstack/react-start-server": "1.157.18", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-plugin-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-ytKblzB34SHmm/7euujl2rZvEjzIsvICFzzKHd7DETi11FSih/WIMU4RKErMtIIc8R2NETh9MPGFLb+XFDsU6A=="],
|
||||
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-r3XTxYPJXZ/szhbloxqT6CQtsoEjw8DjbnZh/3ZsQv2PLKTOl925cy7YVdQc2cWZyXtn5e19Ig78R+8tsoTpig=="],
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-8bk6x7skZp62LnEC2PKegmTo4RrOMrah0RY60S3ZUDXAJGs+CF9a+0moidpawUQbWvoO6T413TVOnNFHyPixJQ=="],
|
||||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1YkBss4SUQ+HqVC1yGN/j7VNwjvdHHd3K58fASe0bz+uf7GrkGJlRXPkMJdxJkkmefYHQfyBL+q7o723N4CMYA=="],
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LQg9FjwXJpt2yS1EdEP9r67KE9Qeg/9fWhdso+wl7XqGc8It/IEYh4D9qci3o+TVKS2b+MhIRklM0tVJ/nh1jw=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jGkyA3EEE01Sf6d4goi//poxQNb/Odc/GzpjZSW2zwG+wcXm9hEzcI6vU2IxhAU0dvvwQyQgtU1HXTcXQ/Xg4A=="],
|
||||
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.16", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg=="],
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.18", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.18", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-+eh3XzBUuoGxJr8b9kCLdyJN+zPsAxtNggEvCal7iI8WE6q3ujjUPYiqHNI+MS4thtxaeUdAXlEjak/+fdBPdg=="],
|
||||
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-t6nZdaX+pYWaudwg5Yasu/o8IAK8FPc4Jwq+rZpyaCgeZn895Vc407hxoRss40/hK1jk03b8x349+b1JekiSqA=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.16", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.16", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA=="],
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-1UrRnIhD4Ar0PpXwzIkxD8nfjzmO7oYRh4CkSUO+Xc6aD5poNB62aUWPp3vS5jnXDNSk0vr+N4QAPebjPKw0Hw=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="],
|
||||
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.16", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-O+7H133MWQTkOxmXJNhrLXiOhDcBlxvpEcCd/N25Ga6eyZ7/P5vvFzNkSSxeQNkZV+RiPWnA5B75gT+U+buz3w=="],
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.18", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-DehC8ONA3QTBbaB95sL8ID+lK284ETP8/k9RCifseXOzr5xWKNNGbe3+Fy8OYV1MtHIuOmCMqozzltPp5MTANg=="],
|
||||
|
||||
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.154.7", "", {}, "sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ=="],
|
||||
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.16", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-plugin": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-VmRXuvP5flryUAHeBM4Xb06n544qLtyA2cwmlQLRTUYtQiQEAdd9CvCGy8CPAly3f7eeXKqC7aX0v3MwWkLR8w=="],
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.18", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-plugin": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-qUVfdEoLf/WYUB1WASR1hcxmlGvIBKiLEIFnpG/Kd/E01BwtHhDjv6WsEusg0n2WrWnYT2/6tuJRDPyXObV7IA=="],
|
||||
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-storage-context": "1.157.16", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-PEltFleYfiqz6+KcmzNXxc1lXgT7VDNKP6G6i1TirdHBDbRJ9CIY+ASLPlhrRwqwA2PL9PpFjXZl8u5bH/+Q9A=="],
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-storage-context": "1.157.18", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-0ixErUvQsVM9SwOOpjyUOpS9KZBDRv1aoM2+qnSGR3DxZUricy/XbCaDAMxReN/0aJQzo47Y5gjpSWaGWlImWw=="],
|
||||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16" } }, "sha512-56izE0oihAw2YRwYUEds2H+uO5dyT2CahXCgWX62+l+FHou09M9mSep68n1lBKPdphC2ZU3cPV7wnvgeraJWHg=="],
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18" } }, "sha512-OqueMS78bULFTDw37uUR8s3yWdX9JVzW4uh/y8V9Iv3oEa6yAgGC2cfOy4My70UkkggAUhoVNopQZamPtL+EBQ=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
|
||||
|
||||
@@ -650,7 +650,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||
|
||||
@@ -788,7 +788,7 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
@@ -872,7 +872,7 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@2.0.1-rc.8", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-IIMQG7qnXx1Ls75suuMHH4xtcvTFxsUguDIZB+dgdYr1RftLj59FkeWF1dOr+jnejDs8Eo+ZKV1CMqogFeqGRQ=="],
|
||||
"h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
|
||||
"h3-v2": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
|
||||
@@ -942,7 +942,7 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
|
||||
"jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
@@ -1126,7 +1126,7 @@
|
||||
|
||||
"oxlint": ["oxlint@1.42.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.42.0", "@oxlint/darwin-x64": "1.42.0", "@oxlint/linux-arm64-gnu": "1.42.0", "@oxlint/linux-arm64-musl": "1.42.0", "@oxlint/linux-x64-gnu": "1.42.0", "@oxlint/linux-x64-musl": "1.42.0", "@oxlint/win32-arm64": "1.42.0", "@oxlint/win32-x64": "1.42.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnspC/lrp8FgKNaONLLn14dm+W5t0SSlus6V5NJpgI2YNT1tkFYZt4fBf14ESxf9AAh98WBASnW5f0gtw462Lg=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.3", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.3", "@oxlint-tsgolint/darwin-x64": "0.11.3", "@oxlint-tsgolint/linux-arm64": "0.11.3", "@oxlint-tsgolint/linux-x64": "0.11.3", "@oxlint-tsgolint/win32-arm64": "0.11.3", "@oxlint-tsgolint/win32-x64": "0.11.3" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-zkuGXJzE5WIoGQ6CHG3GbxncPNrvUG9giTKdXMqKrlieCRxa9hGMvMJM+7DFxKSaryVAEFrTQJNrGJHpeMmFPg=="],
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.4", "@oxlint-tsgolint/darwin-x64": "0.11.4", "@oxlint-tsgolint/linux-arm64": "0.11.4", "@oxlint-tsgolint/linux-x64": "0.11.4", "@oxlint-tsgolint/win32-arm64": "0.11.4", "@oxlint-tsgolint/win32-x64": "0.11.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-VyQc+69TxQwUdsEPiVFN7vNZdDVO/FHaEcHltnWs3O6rvwxv67uADlknQQO714sbRdEahOjgO5dFf+K9ili0gg=="],
|
||||
|
||||
"p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="],
|
||||
|
||||
@@ -1146,9 +1146,9 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.58.0", "", { "dependencies": { "playwright-core": "1.58.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ=="],
|
||||
"playwright": ["playwright@1.58.1", "", { "dependencies": { "playwright-core": "1.58.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw=="],
|
||||
"playwright-core": ["playwright-core@1.58.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
@@ -1348,9 +1348,9 @@
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
|
||||
"whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="],
|
||||
|
||||
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
|
||||
|
||||
@@ -1402,15 +1402,17 @@
|
||||
|
||||
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"html-encoding-sniffer/@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
"jsdom/undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
|
||||
Vendored
+2
@@ -28,6 +28,7 @@ import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_badges from "../lib/badges.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
@@ -89,6 +90,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/badges": typeof lib_badges;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
soulsPostRouterV1Http,
|
||||
starsDeleteRouterV1Http,
|
||||
starsPostRouterV1Http,
|
||||
usersPostRouterV1Http,
|
||||
whoamiV1Http,
|
||||
} from './httpApiV1'
|
||||
|
||||
@@ -101,6 +102,12 @@ http.route({
|
||||
handler: whoamiV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.users}/`,
|
||||
method: 'POST',
|
||||
handler: usersPostRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.souls,
|
||||
method: 'GET',
|
||||
|
||||
@@ -524,6 +524,43 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response2.status).toBe(200)
|
||||
})
|
||||
|
||||
it('ban user requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://example.com/api/v1/users/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('ban user succeeds with handle', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 2 })
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.deletedSkills).toBe(2)
|
||||
})
|
||||
|
||||
it('stars require auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
@@ -526,6 +526,64 @@ async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
export const whoamiV1Http = httpAction(whoamiV1Handler)
|
||||
|
||||
async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/users/')
|
||||
if (segments.length !== 1 || segments[0] !== 'ban') {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = (await request.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return text('Invalid JSON', 400, rate.headers)
|
||||
}
|
||||
|
||||
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
|
||||
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
|
||||
if (!handleRaw && !userIdRaw) {
|
||||
return text('Missing userId or handle', 400, rate.headers)
|
||||
}
|
||||
|
||||
let actorUserId: Id<'users'>
|
||||
try {
|
||||
const auth = await requireApiTokenUser(ctx, request)
|
||||
actorUserId = auth.userId
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
|
||||
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
|
||||
if (!targetUserId) {
|
||||
const handle = handleRaw.toLowerCase()
|
||||
const user = await ctx.runQuery(api.users.getByHandle, { handle })
|
||||
if (!user?._id) return text('User not found', 404, rate.headers)
|
||||
targetUserId = user._id
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Ban failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return text(message, 404, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler)
|
||||
|
||||
async function parseMultipartPublish(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
@@ -1169,4 +1227,5 @@ export const __handlers = {
|
||||
starsPostRouterV1Handler,
|
||||
starsDeleteRouterV1Handler,
|
||||
whoamiV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* @vitest-environment node */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { internal } from '../_generated/api'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
|
||||
vi.mock('../_generated/api', () => ({
|
||||
internal: {
|
||||
users: {
|
||||
getByIdInternal: Symbol('getByIdInternal'),
|
||||
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
describe('requireGitHubAccountAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses cached githubCreatedAt when fresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects accounts younger than 7 days', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'newbie',
|
||||
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refreshes githubCreatedAt when cache is stale', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
expect.objectContaining({ headers: { 'User-Agent': 'clawhub' } }),
|
||||
)
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
|
||||
userId: 'users:1',
|
||||
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
|
||||
githubFetchedAt: now.getTime(),
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('throws when GitHub lookup fails', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ConvexError } from 'convex/values'
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
|
||||
const GITHUB_API = 'https://api.github.com'
|
||||
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
type GitHubUser = {
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt) throw new ConvexError('User not found')
|
||||
|
||||
const handle = user.handle?.trim()
|
||||
if (!handle) throw new ConvexError('GitHub handle required')
|
||||
|
||||
const now = Date.now()
|
||||
let createdAt = user.githubCreatedAt ?? null
|
||||
const fetchedAt = user.githubFetchedAt ?? 0
|
||||
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
|
||||
|
||||
if (stale) {
|
||||
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
|
||||
headers: { 'User-Agent': 'clawhub' },
|
||||
})
|
||||
if (!response.ok) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
const payload = (await response.json()) as GitHubUser
|
||||
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN
|
||||
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
createdAt = parsed
|
||||
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
|
||||
userId,
|
||||
githubCreatedAt: createdAt,
|
||||
githubFetchedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!createdAt) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
const ageMs = now - createdAt
|
||||
if (ageMs < MIN_ACCOUNT_AGE_MS) {
|
||||
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${
|
||||
remainingDays === 1 ? '' : 's'
|
||||
}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from '../_generated/server'
|
||||
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
|
||||
import { generateChangelogForPublish } from './changelog'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
import type { PublicUser } from './public'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
@@ -67,6 +68,9 @@ export async function publishVersionForUser(
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError('Version must be valid semver')
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const suppliedChangelog = args.changelog.trim()
|
||||
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
getFrontmatterMetadata,
|
||||
@@ -90,6 +91,9 @@ export async function publishSoulVersionForUser(
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError('Version must be valid semver')
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const suppliedChangelog = args.changelog.trim()
|
||||
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildDiscordPayload(
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
text: 'OpenClaw',
|
||||
text: 'ClawHub',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
@@ -89,9 +89,9 @@ export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
|
||||
function buildDescription(event: WebhookEvent, skill: WebhookSkillPayload) {
|
||||
const summary = (skill.summary ?? '').trim()
|
||||
if (summary) return truncate(summary, 200)
|
||||
if (event === 'skill.highlighted') return 'Newly highlighted skill on OpenClaw.'
|
||||
if (skill.version) return `New version v${skill.version} published on OpenClaw.`
|
||||
return 'New skill published on OpenClaw.'
|
||||
if (event === 'skill.highlighted') return 'Newly highlighted skill on ClawHub.'
|
||||
if (skill.version) return `New version v${skill.version} published on ClawHub.`
|
||||
return 'New skill published on ClawHub.'
|
||||
}
|
||||
|
||||
function parseBoolean(value?: string) {
|
||||
|
||||
+34
-26
@@ -17,6 +17,8 @@ const users = defineTable({
|
||||
displayName: v.optional(v.string()),
|
||||
bio: v.optional(v.string()),
|
||||
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
|
||||
githubCreatedAt: v.optional(v.number()),
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.optional(v.number()),
|
||||
updatedAt: v.optional(v.number()),
|
||||
@@ -29,6 +31,7 @@ const skills = defineTable({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
resourceId: v.optional(v.string()),
|
||||
ownerUserId: v.id('users'),
|
||||
canonicalSkillId: v.optional(v.id('skills')),
|
||||
forkOf: v.optional(
|
||||
@@ -42,32 +45,34 @@ const skills = defineTable({
|
||||
latestVersionId: v.optional(v.id('skillVersions')),
|
||||
tags: v.record(v.string(), v.id('skillVersions')),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
badges: v.object({
|
||||
redactionApproved: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
highlighted: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
official: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
deprecated: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
badges: v.optional(
|
||||
v.object({
|
||||
redactionApproved: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
highlighted: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
official: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
deprecated: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
moderationStatus: v.optional(
|
||||
v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')),
|
||||
),
|
||||
@@ -104,6 +109,8 @@ const skills = defineTable({
|
||||
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
|
||||
.index('by_batch', ['batch'])
|
||||
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
|
||||
.index('by_canonical', ['canonicalSkillId'])
|
||||
.index('by_fork_of', ['forkOf.skillId'])
|
||||
|
||||
const souls = defineTable({
|
||||
slug: v.string(),
|
||||
@@ -332,6 +339,7 @@ const skillReports = defineTable({
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_skill_createdAt', ['skillId', 'createdAt'])
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_skill_user', ['skillId', 'userId'])
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export type SoulSeed = {
|
||||
}
|
||||
|
||||
export const SOUL_SEED_HANDLE = 'seed'
|
||||
export const SOUL_SEED_DISPLAY_NAME = 'OpenClaw Seed'
|
||||
export const SOUL_SEED_DISPLAY_NAME = 'ClawHub Seed'
|
||||
export const SOUL_SEED_KEY = 'seed:souls-v1'
|
||||
|
||||
// biome-ignore format: seed payload
|
||||
|
||||
+496
-143
@@ -30,12 +30,317 @@ const MAX_LIST_LIMIT = 50
|
||||
const MAX_PUBLIC_LIST_LIMIT = 200
|
||||
const MAX_LIST_BULK_LIMIT = 200
|
||||
const MAX_LIST_TAKE = 1000
|
||||
const HARD_DELETE_BATCH_SIZE = 100
|
||||
const HARD_DELETE_VERSION_BATCH_SIZE = 10
|
||||
const HARD_DELETE_LEADERBOARD_BATCH_SIZE = 25
|
||||
const MAX_ACTIVE_REPORTS_PER_USER = 20
|
||||
const AUTO_HIDE_REPORT_THRESHOLD = 3
|
||||
const MAX_REPORT_REASON_SAMPLE = 5
|
||||
|
||||
function isSkillVersionId(
|
||||
value: Id<'skillVersions'> | null | undefined,
|
||||
): value is Id<'skillVersions'> {
|
||||
return typeof value === 'string' && value.startsWith('skillVersions:')
|
||||
}
|
||||
|
||||
function isUserId(value: Id<'users'> | null | undefined): value is Id<'users'> {
|
||||
return typeof value === 'string' && value.startsWith('users:')
|
||||
}
|
||||
|
||||
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
|
||||
const owner = await ctx.db.get(ownerUserId)
|
||||
return owner?.handle ?? owner?._id ?? null
|
||||
}
|
||||
|
||||
const HARD_DELETE_PHASES = [
|
||||
'versions',
|
||||
'fingerprints',
|
||||
'embeddings',
|
||||
'comments',
|
||||
'reports',
|
||||
'stars',
|
||||
'badges',
|
||||
'dailyStats',
|
||||
'statEvents',
|
||||
'installs',
|
||||
'rootInstalls',
|
||||
'leaderboards',
|
||||
'canonical',
|
||||
'forks',
|
||||
'finalize',
|
||||
] as const
|
||||
|
||||
type HardDeletePhase = (typeof HARD_DELETE_PHASES)[number]
|
||||
|
||||
function isHardDeletePhase(value: string | undefined): value is HardDeletePhase {
|
||||
return Boolean(value) && (HARD_DELETE_PHASES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
async function scheduleHardDelete(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<'skills'>,
|
||||
actorUserId: Id<'users'>,
|
||||
phase: HardDeletePhase,
|
||||
) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId,
|
||||
actorUserId,
|
||||
phase,
|
||||
})
|
||||
}
|
||||
|
||||
async function hardDeleteSkillStep(
|
||||
ctx: MutationCtx,
|
||||
skill: Doc<'skills'>,
|
||||
actorUserId: Id<'users'>,
|
||||
phase: HardDeletePhase,
|
||||
) {
|
||||
const now = Date.now()
|
||||
const patch: Partial<Doc<'skills'>> = {}
|
||||
if (!skill.softDeletedAt) patch.softDeletedAt = now
|
||||
if (skill.moderationStatus !== 'removed') patch.moderationStatus = 'removed'
|
||||
if (!skill.hiddenAt) patch.hiddenAt = now
|
||||
if (!skill.hiddenBy) patch.hiddenBy = actorUserId
|
||||
if (Object.keys(patch).length) {
|
||||
patch.lastReviewedAt = now
|
||||
patch.updatedAt = now
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 'versions': {
|
||||
const versions = await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_VERSION_BATCH_SIZE)
|
||||
for (const version of versions) {
|
||||
await ctx.db.delete(version._id)
|
||||
}
|
||||
if (versions.length === HARD_DELETE_VERSION_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'versions')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'fingerprints')
|
||||
return
|
||||
}
|
||||
case 'fingerprints': {
|
||||
const fingerprints = await ctx.db
|
||||
.query('skillVersionFingerprints')
|
||||
.withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const fingerprint of fingerprints) {
|
||||
await ctx.db.delete(fingerprint._id)
|
||||
}
|
||||
if (fingerprints.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'fingerprints')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'embeddings')
|
||||
return
|
||||
}
|
||||
case 'embeddings': {
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.delete(embedding._id)
|
||||
}
|
||||
if (embeddings.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'embeddings')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'comments')
|
||||
return
|
||||
}
|
||||
case 'comments': {
|
||||
const comments = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const comment of comments) {
|
||||
await ctx.db.delete(comment._id)
|
||||
}
|
||||
if (comments.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'comments')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'reports')
|
||||
return
|
||||
}
|
||||
case 'reports': {
|
||||
const reports = await ctx.db
|
||||
.query('skillReports')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const report of reports) {
|
||||
await ctx.db.delete(report._id)
|
||||
}
|
||||
if (reports.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'reports')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'stars')
|
||||
return
|
||||
}
|
||||
case 'stars': {
|
||||
const stars = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const star of stars) {
|
||||
await ctx.db.delete(star._id)
|
||||
}
|
||||
if (stars.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'stars')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'badges')
|
||||
return
|
||||
}
|
||||
case 'badges': {
|
||||
const badges = await ctx.db
|
||||
.query('skillBadges')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const badge of badges) {
|
||||
await ctx.db.delete(badge._id)
|
||||
}
|
||||
if (badges.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'badges')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'dailyStats')
|
||||
return
|
||||
}
|
||||
case 'dailyStats': {
|
||||
const dailyStats = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_skill_day', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const stat of dailyStats) {
|
||||
await ctx.db.delete(stat._id)
|
||||
}
|
||||
if (dailyStats.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'dailyStats')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'statEvents')
|
||||
return
|
||||
}
|
||||
case 'statEvents': {
|
||||
const statEvents = await ctx.db
|
||||
.query('skillStatEvents')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const statEvent of statEvents) {
|
||||
await ctx.db.delete(statEvent._id)
|
||||
}
|
||||
if (statEvents.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'statEvents')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'installs')
|
||||
return
|
||||
}
|
||||
case 'installs': {
|
||||
const installs = await ctx.db
|
||||
.query('userSkillInstalls')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const install of installs) {
|
||||
await ctx.db.delete(install._id)
|
||||
}
|
||||
if (installs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'installs')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'rootInstalls')
|
||||
return
|
||||
}
|
||||
case 'rootInstalls': {
|
||||
const rootInstalls = await ctx.db
|
||||
.query('userSkillRootInstalls')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const rootInstall of rootInstalls) {
|
||||
await ctx.db.delete(rootInstall._id)
|
||||
}
|
||||
if (rootInstalls.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'rootInstalls')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'leaderboards')
|
||||
return
|
||||
}
|
||||
case 'leaderboards': {
|
||||
const leaderboards = await ctx.db
|
||||
.query('skillLeaderboards')
|
||||
.take(HARD_DELETE_LEADERBOARD_BATCH_SIZE)
|
||||
for (const leaderboard of leaderboards) {
|
||||
const items = leaderboard.items.filter((item) => item.skillId !== skill._id)
|
||||
if (items.length !== leaderboard.items.length) {
|
||||
await ctx.db.patch(leaderboard._id, { items })
|
||||
}
|
||||
}
|
||||
if (leaderboards.length === HARD_DELETE_LEADERBOARD_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'leaderboards')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'canonical')
|
||||
return
|
||||
}
|
||||
case 'canonical': {
|
||||
const canonicalRefs = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_canonical', (q) => q.eq('canonicalSkillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const related of canonicalRefs) {
|
||||
await ctx.db.patch(related._id, {
|
||||
canonicalSkillId: undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (canonicalRefs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'canonical')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'forks')
|
||||
return
|
||||
}
|
||||
case 'forks': {
|
||||
const forkRefs = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_fork_of', (q) => q.eq('forkOf.skillId', skill._id))
|
||||
.take(HARD_DELETE_BATCH_SIZE)
|
||||
for (const related of forkRefs) {
|
||||
await ctx.db.patch(related._id, {
|
||||
forkOf: undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (forkRefs.length === HARD_DELETE_BATCH_SIZE) {
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'forks')
|
||||
return
|
||||
}
|
||||
await scheduleHardDelete(ctx, skill._id, actorUserId, 'finalize')
|
||||
return
|
||||
}
|
||||
case 'finalize': {
|
||||
await ctx.db.delete(skill._id)
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId,
|
||||
action: 'skill.hard_delete',
|
||||
targetType: 'skill',
|
||||
targetId: skill._id,
|
||||
metadata: { slug: skill.slug },
|
||||
createdAt: now,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PublicSkillEntry = {
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
@@ -179,7 +484,7 @@ export const getBySlug = query({
|
||||
.unique()
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id)
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
|
||||
@@ -225,6 +530,62 @@ export const getBySlug = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const getBySlugForStaff = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertModerator(user)
|
||||
|
||||
const skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.unique()
|
||||
if (!skill) return null
|
||||
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id)
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null
|
||||
|
||||
return {
|
||||
skill: { ...skill, badges },
|
||||
latestVersion,
|
||||
owner,
|
||||
forkOf: forkOfSkill
|
||||
? {
|
||||
kind: skill.forkOf?.kind ?? 'fork',
|
||||
version: skill.forkOf?.version ?? null,
|
||||
skill: {
|
||||
slug: forkOfSkill.slug,
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
canonical: canonicalSkill
|
||||
? {
|
||||
skill: {
|
||||
slug: canonicalSkill.slug,
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const getSkillBySlugInternal = internalQuery({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -404,7 +765,39 @@ export const listReportedSkills = query({
|
||||
.filter((skill) => (skill.reportCount ?? 0) > 0)
|
||||
.sort((a, b) => (b.lastReportedAt ?? 0) - (a.lastReportedAt ?? 0))
|
||||
.slice(0, limit)
|
||||
return buildManagementSkillEntries(ctx, reported)
|
||||
const managementEntries = await buildManagementSkillEntries(ctx, reported)
|
||||
const reporterCache = new Map<Id<'users'>, Promise<Doc<'users'> | null>>()
|
||||
|
||||
const getReporter = (reporterId: Id<'users'>) => {
|
||||
const cached = reporterCache.get(reporterId)
|
||||
if (cached) return cached
|
||||
const reporterPromise = ctx.db.get(reporterId)
|
||||
reporterCache.set(reporterId, reporterPromise)
|
||||
return reporterPromise
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
managementEntries.map(async (entry) => {
|
||||
const reports = await ctx.db
|
||||
.query('skillReports')
|
||||
.withIndex('by_skill_createdAt', (q) => q.eq('skillId', entry.skill._id))
|
||||
.order('desc')
|
||||
.take(MAX_REPORT_REASON_SAMPLE)
|
||||
const reportEntries = await Promise.all(
|
||||
reports.map(async (report) => {
|
||||
const reporter = await getReporter(report.userId)
|
||||
const reason = report.reason?.trim()
|
||||
return {
|
||||
reason: reason && reason.length > 0 ? reason : 'No reason provided.',
|
||||
createdAt: report.createdAt,
|
||||
reporterHandle: reporter?.handle ?? reporter?.name ?? null,
|
||||
reporterId: report.userId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { ...entry, reports: reportEntries }
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -427,14 +820,22 @@ export const listDuplicateCandidates = query({
|
||||
}> = []
|
||||
|
||||
for (const skill of entries) {
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
const latestVersion = isSkillVersionId(skill.latestVersionId)
|
||||
? await ctx.db.get(skill.latestVersionId)
|
||||
: null
|
||||
const fingerprint = latestVersion?.fingerprint ?? null
|
||||
if (!fingerprint) continue
|
||||
|
||||
const matchedFingerprints = await ctx.db
|
||||
.query('skillVersionFingerprints')
|
||||
.withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
|
||||
.take(10)
|
||||
let matchedFingerprints: Doc<'skillVersionFingerprints'>[] = []
|
||||
try {
|
||||
matchedFingerprints = await ctx.db
|
||||
.query('skillVersionFingerprints')
|
||||
.withIndex('by_fingerprint', (q) => q.eq('fingerprint', fingerprint))
|
||||
.take(10)
|
||||
} catch (error) {
|
||||
console.error('listDuplicateCandidates: fingerprint lookup failed', error)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchEntries: Array<{ skill: Doc<'skills'>; owner: Doc<'users'> | null }> = []
|
||||
for (const match of matchedFingerprints) {
|
||||
@@ -447,7 +848,7 @@ export const listDuplicateCandidates = query({
|
||||
|
||||
if (matchEntries.length === 0) continue
|
||||
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
const owner = isUserId(skill.ownerUserId) ? await ctx.db.get(skill.ownerUserId) : null
|
||||
results.push({
|
||||
skill,
|
||||
latestVersion,
|
||||
@@ -461,12 +862,39 @@ export const listDuplicateCandidates = query({
|
||||
},
|
||||
})
|
||||
|
||||
async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<'users'>) {
|
||||
const reports = await ctx.db
|
||||
.query('skillReports')
|
||||
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||
.collect()
|
||||
|
||||
let count = 0
|
||||
for (const report of reports) {
|
||||
const skill = await ctx.db.get(report.skillId)
|
||||
if (!skill) continue
|
||||
if (skill.softDeletedAt) continue
|
||||
if (skill.moderationStatus === 'removed') continue
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
if (!owner || owner.deletedAt) continue
|
||||
count += 1
|
||||
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
export const report = mutation({
|
||||
args: { skillId: v.id('skills'), reason: v.optional(v.string()) },
|
||||
args: { skillId: v.id('skills'), reason: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill || skill.softDeletedAt) throw new Error('Skill not found')
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === 'removed') {
|
||||
throw new Error('Skill not found')
|
||||
}
|
||||
const reason = args.reason.trim()
|
||||
if (!reason) {
|
||||
throw new Error('Report reason required.')
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query('skillReports')
|
||||
@@ -474,20 +902,60 @@ export const report = mutation({
|
||||
.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()
|
||||
const reason = args.reason?.trim()
|
||||
await ctx.db.insert('skillReports', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
reason: reason ? reason.slice(0, 500) : undefined,
|
||||
reason: reason.slice(0, 500),
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
reportCount: (skill.reportCount ?? 0) + 1,
|
||||
const nextReportCount = (skill.reportCount ?? 0) + 1
|
||||
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !skill.softDeletedAt
|
||||
const updates: Partial<Doc<'skills'>> = {
|
||||
reportCount: nextReportCount,
|
||||
lastReportedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
if (shouldAutoHide) {
|
||||
Object.assign(updates, {
|
||||
softDeletedAt: now,
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'auto.reports',
|
||||
moderationNotes: 'Auto-hidden after 4 unique reports.',
|
||||
hiddenAt: now,
|
||||
lastReviewedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, updates)
|
||||
|
||||
if (shouldAutoHide) {
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: 'deleted',
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: userId,
|
||||
action: 'skill.auto_hide',
|
||||
targetType: 'skill',
|
||||
targetId: skill._id,
|
||||
metadata: { reportCount: nextReportCount },
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true as const, reported: true, alreadyReported: false }
|
||||
},
|
||||
@@ -1164,135 +1632,20 @@ export const hardDelete = mutation({
|
||||
assertAdmin(user)
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
await hardDeleteSkillStep(ctx, skill, user._id, 'versions')
|
||||
},
|
||||
})
|
||||
|
||||
const versions = await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
|
||||
for (const version of versions) {
|
||||
const versionFingerprints = await ctx.db
|
||||
.query('skillVersionFingerprints')
|
||||
.withIndex('by_version', (q) => q.eq('versionId', version._id))
|
||||
.collect()
|
||||
for (const fingerprint of versionFingerprints) {
|
||||
await ctx.db.delete(fingerprint._id)
|
||||
}
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_version', (q) => q.eq('versionId', version._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.delete(embedding._id)
|
||||
}
|
||||
|
||||
await ctx.db.delete(version._id)
|
||||
}
|
||||
|
||||
const remainingFingerprints = await ctx.db
|
||||
.query('skillVersionFingerprints')
|
||||
.withIndex('by_skill_fingerprint', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const fingerprint of remainingFingerprints) {
|
||||
await ctx.db.delete(fingerprint._id)
|
||||
}
|
||||
|
||||
const remainingEmbeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of remainingEmbeddings) {
|
||||
await ctx.db.delete(embedding._id)
|
||||
}
|
||||
|
||||
const comments = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const comment of comments) {
|
||||
await ctx.db.delete(comment._id)
|
||||
}
|
||||
|
||||
const stars = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const star of stars) {
|
||||
await ctx.db.delete(star._id)
|
||||
}
|
||||
|
||||
const badges = await ctx.db
|
||||
.query('skillBadges')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const badge of badges) {
|
||||
await ctx.db.delete(badge._id)
|
||||
}
|
||||
|
||||
const dailyStats = await ctx.db
|
||||
.query('skillDailyStats')
|
||||
.withIndex('by_skill_day', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const stat of dailyStats) {
|
||||
await ctx.db.delete(stat._id)
|
||||
}
|
||||
|
||||
const statEvents = await ctx.db
|
||||
.query('skillStatEvents')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const statEvent of statEvents) {
|
||||
await ctx.db.delete(statEvent._id)
|
||||
}
|
||||
|
||||
const installs = await ctx.db
|
||||
.query('userSkillInstalls')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const install of installs) {
|
||||
await ctx.db.delete(install._id)
|
||||
}
|
||||
|
||||
const rootInstalls = await ctx.db
|
||||
.query('userSkillRootInstalls')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const rootInstall of rootInstalls) {
|
||||
await ctx.db.delete(rootInstall._id)
|
||||
}
|
||||
|
||||
const leaderboards = await ctx.db.query('skillLeaderboards').collect()
|
||||
for (const leaderboard of leaderboards) {
|
||||
const items = leaderboard.items.filter((item) => item.skillId !== skill._id)
|
||||
if (items.length !== leaderboard.items.length) {
|
||||
await ctx.db.patch(leaderboard._id, { items })
|
||||
}
|
||||
}
|
||||
|
||||
const relatedSkills = await ctx.db.query('skills').collect()
|
||||
for (const related of relatedSkills) {
|
||||
if (related._id === skill._id) continue
|
||||
if (related.canonicalSkillId === skill._id || related.forkOf?.skillId === skill._id) {
|
||||
await ctx.db.patch(related._id, {
|
||||
canonicalSkillId:
|
||||
related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId,
|
||||
forkOf: related.forkOf?.skillId === skill._id ? undefined : related.forkOf,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.delete(skill._id)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'skill.hard_delete',
|
||||
targetType: 'skill',
|
||||
targetId: skill._id,
|
||||
metadata: { slug: skill.slug },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
export const hardDeleteInternal = internalMutation({
|
||||
args: { skillId: v.id('skills'), actorUserId: v.id('users'), phase: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt) throw new Error('User not found')
|
||||
assertAdmin(actor)
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
const phase = isHardDeletePhase(args.phase) ? args.phase : 'versions'
|
||||
await hardDeleteSkillStep(ctx, skill, actor._id, phase)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+92
-2
@@ -1,8 +1,10 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import { internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertAdmin, requireUser } from './lib/access'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertAdmin, assertModerator, requireUser } from './lib/access'
|
||||
import { toPublicUser } from './lib/public'
|
||||
|
||||
const DEFAULT_ROLE = 'user'
|
||||
@@ -18,6 +20,21 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
})
|
||||
|
||||
export const updateGithubMetaInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
githubCreatedAt: v.number(),
|
||||
githubFetchedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.userId, {
|
||||
githubCreatedAt: args.githubCreatedAt,
|
||||
githubFetchedAt: args.githubFetchedAt,
|
||||
updatedAt: args.githubFetchedAt,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -120,3 +137,76 @@ export const setRole = mutation({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const banUser = mutation({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
return banUserWithActor(ctx, user, args.userId)
|
||||
},
|
||||
})
|
||||
|
||||
export const banUserInternal = internalMutation({
|
||||
args: { actorUserId: v.id('users'), targetUserId: v.id('users') },
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt) throw new Error('User not found')
|
||||
return banUserWithActor(ctx, actor, args.targetUserId)
|
||||
},
|
||||
})
|
||||
|
||||
async function banUserWithActor(ctx: MutationCtx, actor: Doc<'users'>, targetUserId: Id<'users'>) {
|
||||
assertModerator(actor)
|
||||
|
||||
if (targetUserId === actor._id) throw new Error('Cannot ban yourself')
|
||||
|
||||
const target = await ctx.db.get(targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
if (target.role === 'admin' && actor.role !== 'admin') {
|
||||
throw new Error('Forbidden')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (target.deletedAt) {
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
|
||||
}
|
||||
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
|
||||
.collect()
|
||||
|
||||
for (const skill of skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: skill._id,
|
||||
actorUserId: actor._id,
|
||||
})
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
.query('apiTokens')
|
||||
.withIndex('by_user', (q) => q.eq('userId', targetUserId))
|
||||
.collect()
|
||||
for (const token of tokens) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now })
|
||||
}
|
||||
|
||||
await ctx.db.patch(targetUserId, {
|
||||
deletedAt: now,
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: targetUserId })
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'user.ban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { deletedSkills: skills.length },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ Reading order (new contributor):
|
||||
Feature/ops docs (already present):
|
||||
|
||||
- `docs/spec.md`: product + implementation spec (data model + flows).
|
||||
- `docs/security.md`: moderation, reporting, bans, upload gating.
|
||||
- `docs/telemetry.md`: what `clawhub sync` reports; opt-out.
|
||||
- `docs/webhook.md`: Discord webhook events/payload.
|
||||
- `docs/diffing.md`: version-to-version diff UI spec.
|
||||
|
||||
+41
-1
@@ -61,11 +61,22 @@ Stores your API token + cached registry URL.
|
||||
|
||||
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
|
||||
- Flags:
|
||||
- `--limit <n>` (1–200, default: 25)
|
||||
- `--limit <n>` (1-200, default: 25)
|
||||
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
|
||||
- `--json` (machine-readable output)
|
||||
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
|
||||
|
||||
### `inspect <slug>`
|
||||
|
||||
- Fetches skill metadata and version files without installing.
|
||||
- `--version <version>`: inspect a specific version (default: latest).
|
||||
- `--tag <tag>`: inspect a tagged version (e.g. `latest`).
|
||||
- `--versions`: list version history (first page).
|
||||
- `--limit <n>`: max versions to list (1-200).
|
||||
- `--files`: list files for the selected version.
|
||||
- `--file <path>`: fetch raw file content (text files only; 200KB limit).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
### `install <slug>`
|
||||
|
||||
- Resolves latest version via `/api/v1/skills/<slug>`.
|
||||
@@ -92,6 +103,35 @@ Stores your API token + cached registry URL.
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
- Soft-delete a skill (moderator/admin only).
|
||||
- Calls `DELETE /api/v1/skills/{slug}`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `undelete <slug>`
|
||||
|
||||
- Restore a hidden skill (moderator/admin only).
|
||||
- Calls `POST /api/v1/skills/{slug}/undelete`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `hide <slug>`
|
||||
|
||||
- Hide a skill (moderator/admin only).
|
||||
- Alias for `delete`.
|
||||
|
||||
### `unhide <slug>`
|
||||
|
||||
- Unhide a skill (moderator/admin only).
|
||||
- Alias for `undelete`.
|
||||
|
||||
### `ban-user <handleOrId>`
|
||||
|
||||
- Ban a user and delete owned skills (moderator/admin only).
|
||||
- Calls `POST /api/v1/users/ban`.
|
||||
- `--id` treats the argument as a user id instead of a handle.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `sync`
|
||||
|
||||
- Scans for local skill folders and publishes new/changed ones.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Deploy
|
||||
|
||||
OpenClaw is two deployables:
|
||||
ClawHub is two deployables:
|
||||
|
||||
- Web app (TanStack Start) → typically Vercel.
|
||||
- Convex backend → Convex deployment (serves `/api/...` routes).
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ read_when:
|
||||
## Goals
|
||||
- Compare any file between two versions.
|
||||
- Default compare: `latest` vs `previous` (SemVer precedence).
|
||||
- UX feels native to OpenClaw (theme + typography + motion).
|
||||
- UX feels native to ClawHub (theme + typography + motion).
|
||||
- Inline or side-by-side toggle.
|
||||
- Public access.
|
||||
|
||||
|
||||
+23
-1
@@ -143,7 +143,29 @@ Publishes a new version.
|
||||
|
||||
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||
|
||||
Soft-delete / restore a skill (owner/admin only).
|
||||
Soft-delete / restore a skill (moderator/admin only).
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
Ban a user and hard-delete owned skills (moderator/admin only).
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{ "handle": "user_handle" }
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{ "userId": "users_..." }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true, "alreadyBanned": false, "deletedSkills": 3 }
|
||||
```
|
||||
|
||||
### `POST /api/v1/stars/{slug}` / `DELETE /api/v1/stars/{slug}`
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Example (starter):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OpenClaw",
|
||||
"name": "ClawHub",
|
||||
"logo": "public/logo.svg",
|
||||
"navigation": [
|
||||
{ "group": "Start", "pages": ["docs/README", "docs/quickstart"] },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
summary: 'Security + moderation controls (reports, bans, upload gating).'
|
||||
read_when:
|
||||
- Working on moderation or abuse controls
|
||||
- Reviewing upload restrictions
|
||||
- Troubleshooting hidden/removed skills
|
||||
---
|
||||
|
||||
# Security + Moderation
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills.
|
||||
- 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.
|
||||
- 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`,
|
||||
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`
|
||||
- Public queries hide non-active moderation statuses; staff can still access via
|
||||
staff-only queries and unhide/restore/delete/ban.
|
||||
|
||||
## Bans
|
||||
|
||||
- Banning a user:
|
||||
- hard-deletes all owned skills
|
||||
- revokes API tokens
|
||||
- sets `deletedAt` on the user
|
||||
- Moderators cannot ban admins; nobody can ban themselves.
|
||||
- Report counters effectively reset because deleted/banned skills are no longer
|
||||
considered active in the per-user report cap.
|
||||
|
||||
## Upload gate (GitHub account age)
|
||||
|
||||
- Skill + soul publish actions require GitHub account age ≥ 7 days.
|
||||
- Lookup uses GitHub `created_at` and caches on the user:
|
||||
- `githubCreatedAt` (source of truth)
|
||||
- `githubFetchedAt` (fetch timestamp)
|
||||
- Cache TTL: 24 hours.
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
+7
-5
@@ -1,12 +1,12 @@
|
||||
---
|
||||
summary: "OpenClaw spec: skills registry, versioning, vector search, moderation"
|
||||
summary: "ClawHub spec: skills registry, versioning, vector search, moderation"
|
||||
read_when:
|
||||
- Bootstrapping OpenClaw
|
||||
- Bootstrapping ClawHub
|
||||
- Implementing schema/auth/search/versioning
|
||||
- Reviewing API and upload/download flows
|
||||
---
|
||||
|
||||
# OpenClaw — product + implementation spec (v1)
|
||||
# ClawHub — product + implementation spec (v1)
|
||||
|
||||
## Goals
|
||||
- onlycrabs.ai mode for sharing `SOUL.md` bundles (host-based entry point).
|
||||
@@ -123,8 +123,9 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
## Auth + roles
|
||||
- Convex Auth with GitHub OAuth App.
|
||||
- Default role `user`; bootstrap `steipete` to `admin` on first login.
|
||||
- Management console: moderators can hide/restore skills + mark duplicates; admins can change owners, approve badges, and hard-delete.
|
||||
- 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).
|
||||
|
||||
## Upload flow (50MB per version)
|
||||
1) Client requests upload session.
|
||||
@@ -135,9 +136,10 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- file extensions/text content
|
||||
- SKILL.md exists and frontmatter parseable
|
||||
- version uniqueness
|
||||
- GitHub account age ≥ 7 days
|
||||
5) Server stores files + metadata, sets `latest` tag, updates stats.
|
||||
|
||||
Soul upload flow: same as skills, but only `SOUL.md` is allowed in the bundle.
|
||||
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
|
||||
Seed data lives in `convex/seed.ts` for local dev.
|
||||
|
||||
## Versioning + tags
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Telemetry
|
||||
|
||||
OpenClaw uses **minimal telemetry** to compute **install counts** (what’s actually in use) and to power better sorting/filtering.
|
||||
ClawHub uses **minimal telemetry** to compute **install counts** (what’s actually in use) and to power better sorting/filtering.
|
||||
This is based on the CLI `clawhub sync` command.
|
||||
|
||||
## When telemetry is collected
|
||||
@@ -70,7 +70,7 @@ This is evaluated lazily (on the next telemetry report) to avoid background jobs
|
||||
|
||||
## Transparency + user controls
|
||||
|
||||
OpenClaw provides a private “Installed” tab on your own profile:
|
||||
ClawHub provides a private “Installed” tab on your own profile:
|
||||
|
||||
- Shows the exact roots + installed skills we store.
|
||||
- Includes a **JSON export** view.
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ read_when:
|
||||
|
||||
# Webhooks (Discord)
|
||||
|
||||
OpenClaw can post Discord embeds when skills are published or highlighted.
|
||||
ClawHub can post Discord embeds when skills are published or highlighted.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -44,7 +44,7 @@ Discord receives a JSON payload with a single embed:
|
||||
{ "name": "Owner", "value": "@owner", "inline": true },
|
||||
{ "name": "Tags", "value": "latest, discord", "inline": false }
|
||||
],
|
||||
"footer": { "text": "OpenClaw" }
|
||||
"footer": { "text": "ClawHub" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ test('header menu routes render', async ({ page }) => {
|
||||
|
||||
if (label === 'Search') {
|
||||
await expect(page).toHaveURL(/\/?(\?|$)/)
|
||||
await expect(page.locator('h1', { hasText: 'OpenClaw' })).toBeVisible()
|
||||
await expect(page.locator('h1', { hasText: 'ClawHub' })).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+24
-24
@@ -35,49 +35,49 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.6",
|
||||
"@tanstack/react-router-devtools": "^1.151.6",
|
||||
"@tanstack/react-start": "^1.152.0",
|
||||
"@tanstack/router-plugin": "^1.151.6",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-router": "^1.157.18",
|
||||
"@tanstack/react-router-devtools": "^1.157.18",
|
||||
"@tanstack/react-start": "^1.157.18",
|
||||
"@tanstack/router-plugin": "^1.157.18",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.6",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "^3.0.1-alpha.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite-tsconfig-paths": "^6.0.4",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tanstack/devtools-vite": "^0.4.1",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@tanstack/devtools-vite": "^0.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/react": "^19.2.8",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"only-allow": "^1.2.1",
|
||||
"oxlint": "^1.39.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `clawhub`
|
||||
|
||||
OpenClaw CLI — install, update, search, and publish agent skills as folders.
|
||||
ClawHub CLI — install, update, search, and publish agent skills as folders.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.4.0",
|
||||
"description": "OpenClaw CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('browserAuth', () => {
|
||||
const response = await fetch(server.redirectUri)
|
||||
expect(response.status).toBe(200)
|
||||
const text = await response.text()
|
||||
expect(text).toContain('OpenClaw CLI Login')
|
||||
expect(text).toContain('ClawHub CLI Login')
|
||||
server.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ const CALLBACK_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenClaw CLI Login</title>
|
||||
<title>ClawHub CLI Login</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; padding: 24px; }
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Command } from 'commander'
|
||||
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
|
||||
import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
|
||||
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
|
||||
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
|
||||
import {
|
||||
cmdDeleteSkill,
|
||||
cmdHideSkill,
|
||||
cmdUndeleteSkill,
|
||||
cmdUnhideSkill,
|
||||
} from './cli/commands/delete.js'
|
||||
import { cmdInspect } from './cli/commands/inspect.js'
|
||||
import { cmdBanUser } from './cli/commands/moderation.js'
|
||||
import { cmdPublish } from './cli/commands/publish.js'
|
||||
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
|
||||
import { cmdStarSkill } from './cli/commands/star.js'
|
||||
@@ -20,7 +27,7 @@ import { readGlobalConfig } from './config.js'
|
||||
const program = new Command()
|
||||
.name('clawhub')
|
||||
.description(
|
||||
`${styleTitle(`OpenClaw CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
|
||||
`${styleTitle(`ClawHub CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
|
||||
'install, update, search, and publish agent skills.',
|
||||
)}`,
|
||||
)
|
||||
@@ -221,6 +228,22 @@ program
|
||||
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
|
||||
})
|
||||
|
||||
program
|
||||
.command('inspect')
|
||||
.description('Fetch skill metadata and files without installing')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--version <version>', 'Version to inspect')
|
||||
.option('--tag <tag>', 'Tag to inspect (default: latest)')
|
||||
.option('--versions', 'List version history (first page)')
|
||||
.option('--limit <n>', 'Max versions to list (1-200)', (value) => Number.parseInt(value, 10))
|
||||
.option('--files', 'List files for the selected version')
|
||||
.option('--file <path>', 'Fetch raw file content (text <= 200KB)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdInspect(opts, slug, options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('publish')
|
||||
.description('Publish skill from folder')
|
||||
@@ -238,7 +261,7 @@ program
|
||||
|
||||
program
|
||||
.command('delete')
|
||||
.description('Soft-delete a skill (owner/admin only)')
|
||||
.description('Soft-delete a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -246,9 +269,19 @@ program
|
||||
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('hide')
|
||||
.description('Hide a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdHideSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('undelete')
|
||||
.description('Restore a soft-deleted skill (owner/admin only)')
|
||||
.description('Restore a hidden skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -256,6 +289,27 @@ program
|
||||
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('unhide')
|
||||
.description('Unhide a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdUnhideSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('ban-user')
|
||||
.description('Ban a user and delete owned skills (moderator/admin only)')
|
||||
.argument('<handleOrId>', 'User handle (default) or user id')
|
||||
.option('--id', 'Treat argument as user id')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (handleOrId, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdBanUser(opts, handleOrId, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('star')
|
||||
.description('Add a skill to your highlights')
|
||||
|
||||
@@ -37,7 +37,8 @@ export async function cmdLoginFlow(
|
||||
|
||||
const result = await receiver.waitForResult()
|
||||
const registry = result.registry?.trim() || opts.registry
|
||||
await cmdLogin({ ...opts, registry }, result.token, inputAllowed)
|
||||
const registrySource = result.registry?.trim() ? 'cli' : opts.registrySource
|
||||
await cmdLogin({ ...opts, registry, registrySource }, result.token, inputAllowed)
|
||||
}
|
||||
|
||||
export async function cmdLogin(
|
||||
@@ -47,7 +48,7 @@ export async function cmdLogin(
|
||||
) {
|
||||
if (!tokenFlag && !inputAllowed) fail('Token required (use --token or remove --no-input)')
|
||||
|
||||
const token = tokenFlag || (await promptHidden('OpenClaw token: '))
|
||||
const token = tokenFlag || (await promptHidden('ClawHub token: '))
|
||||
if (!token) fail('Token required')
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('../ui.js', () => ({
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
const { cmdDeleteSkill, cmdUndeleteSkill } = await import('./delete')
|
||||
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import('./delete')
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
@@ -49,6 +49,8 @@ describe('delete/undelete', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
await expect(cmdDeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdUndeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdHideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdUnhideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('calls delete endpoint with --yes', async () => {
|
||||
@@ -70,4 +72,20 @@ describe('delete/undelete', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('supports hide/unhide aliases', async () => {
|
||||
mockApiRequest.mockResolvedValue({ ok: true })
|
||||
await cmdHideSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||
await cmdUnhideSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,41 @@ import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
type SkillActionLabels = {
|
||||
verb: string
|
||||
progress: string
|
||||
past: string
|
||||
promptSuffix?: string
|
||||
}
|
||||
|
||||
const deleteLabels: SkillActionLabels = {
|
||||
verb: 'Delete',
|
||||
progress: 'Deleting',
|
||||
past: 'Deleted',
|
||||
promptSuffix: 'soft delete, requires moderator/admin',
|
||||
}
|
||||
|
||||
const undeleteLabels: SkillActionLabels = {
|
||||
verb: 'Undelete',
|
||||
progress: 'Undeleting',
|
||||
past: 'Undeleted',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
const hideLabels: SkillActionLabels = {
|
||||
verb: 'Hide',
|
||||
progress: 'Hiding',
|
||||
past: 'Hidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
const unhideLabels: SkillActionLabels = {
|
||||
verb: 'Unhide',
|
||||
progress: 'Unhiding',
|
||||
past: 'Unhidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
@@ -17,6 +52,7 @@ export async function cmdDeleteSkill(
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
labels: SkillActionLabels = deleteLabels,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
@@ -24,20 +60,20 @@ export async function cmdDeleteSkill(
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Delete ${slug}? (soft delete)`)
|
||||
const ok = await promptConfirm(formatPrompt(labels, slug))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Deleting ${slug}`)
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token },
|
||||
ApiV1DeleteResponseSchema,
|
||||
)
|
||||
spinner.succeed(`OK. Deleted ${slug}`)
|
||||
spinner.succeed(`OK. ${labels.past} ${slug}`)
|
||||
return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response')
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
@@ -50,6 +86,7 @@ export async function cmdUndeleteSkill(
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
labels: SkillActionLabels = undeleteLabels,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
@@ -57,13 +94,13 @@ export async function cmdUndeleteSkill(
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Undelete ${slug}?`)
|
||||
const ok = await promptConfirm(formatPrompt(labels, slug))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Undeleting ${slug}`)
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
@@ -74,10 +111,33 @@ export async function cmdUndeleteSkill(
|
||||
},
|
||||
ApiV1DeleteResponseSchema,
|
||||
)
|
||||
spinner.succeed(`OK. Undeleted ${slug}`)
|
||||
spinner.succeed(`OK. ${labels.past} ${slug}`)
|
||||
return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response')
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdHideSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return cmdDeleteSkill(opts, slugArg, options, inputAllowed, hideLabels)
|
||||
}
|
||||
|
||||
export async function cmdUnhideSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return cmdUndeleteSkill(opts, slugArg, options, inputAllowed, unhideLabels)
|
||||
}
|
||||
|
||||
function formatPrompt(labels: SkillActionLabels, slug: string) {
|
||||
const suffix = labels.promptSuffix ? ` (${labels.promptSuffix})` : ''
|
||||
return `${labels.verb} ${slug}?${suffix}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiRoutes } from '../../schema/index.js'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockFetchText = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: '',
|
||||
}
|
||||
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)),
|
||||
}))
|
||||
|
||||
const { cmdInspect } = await import('./inspect')
|
||||
|
||||
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawhub.ai',
|
||||
registry: 'https://clawhub.ai',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLog.mockClear()
|
||||
mockWrite.mockClear()
|
||||
})
|
||||
|
||||
describe('cmdInspect', () => {
|
||||
it('fetches latest version files when --files is set', async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
summary: null,
|
||||
tags: { latest: '1.2.3' },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
skill: { slug: 'demo', displayName: 'Demo' },
|
||||
version: { version: '1.2.3', createdAt: 3, changelog: 'init', files: [] },
|
||||
})
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { files: true })
|
||||
|
||||
const firstArgs = mockApiRequest.mock.calls[0]?.[1]
|
||||
const secondArgs = mockApiRequest.mock.calls[1]?.[1]
|
||||
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
|
||||
expect(secondArgs?.path).toBe(
|
||||
`${ApiRoutes.skills}/${encodeURIComponent('demo')}/versions/${encodeURIComponent('1.2.3')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses tag param when fetching a file', 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: [] },
|
||||
})
|
||||
mockFetchText.mockResolvedValue('content')
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { file: 'SKILL.md', tag: 'latest' })
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1]
|
||||
const url = new URL(String(fetchArgs?.url))
|
||||
expect(url.pathname).toBe('/api/v1/skills/demo/file')
|
||||
expect(url.searchParams.get('path')).toBe('SKILL.md')
|
||||
expect(url.searchParams.get('tag')).toBe('latest')
|
||||
expect(url.searchParams.get('version')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects when both version and tag are provided', async () => {
|
||||
await expect(
|
||||
cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }),
|
||||
).rejects.toThrow('Use either --version or --tag')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { apiRequest, fetchText } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
} from '../../schema/index.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError } from '../ui.js'
|
||||
|
||||
type InspectOptions = {
|
||||
version?: string
|
||||
tag?: string
|
||||
versions?: boolean
|
||||
limit?: number
|
||||
files?: boolean
|
||||
file?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
type FileEntry = {
|
||||
path: string
|
||||
size: number | null
|
||||
sha256: string | null
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) fail('Slug required')
|
||||
if (options.version && options.tag) fail('Use either --version or --tag')
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching skill')
|
||||
try {
|
||||
const skillResult = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
if (!skillResult.skill) {
|
||||
spinner.fail('Skill not found')
|
||||
return
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
const tags = normalizeTags(skill.tags)
|
||||
const latestVersion = skillResult.latestVersion?.version ?? tags.latest ?? null
|
||||
const taggedVersion = options.tag ? (tags[options.tag] ?? null) : null
|
||||
if (options.tag && !taggedVersion) {
|
||||
spinner.fail(`Unknown tag "${options.tag}"`)
|
||||
return
|
||||
}
|
||||
const requestedVersion = options.version ?? taggedVersion ?? null
|
||||
|
||||
let versionResult: { version: unknown; skill: unknown } | null = null
|
||||
if (options.files || options.file || options.version || options.tag) {
|
||||
const targetVersion = requestedVersion ?? latestVersion
|
||||
if (!targetVersion) fail('Could not resolve latest version')
|
||||
spinner.text = `Fetching ${trimmed}@${targetVersion}`
|
||||
versionResult = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: 'GET',
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
|
||||
targetVersion,
|
||||
)}`,
|
||||
},
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
spinner.text = `Fetching versions (${limit})`
|
||||
versionsList = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
let fileContent: string | null = null
|
||||
if (options.file) {
|
||||
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
|
||||
url.searchParams.set('path', options.file)
|
||||
if (options.version) {
|
||||
url.searchParams.set('version', options.version)
|
||||
} else if (options.tag) {
|
||||
url.searchParams.set('tag', options.tag)
|
||||
} else if (latestVersion) {
|
||||
url.searchParams.set('version', latestVersion)
|
||||
}
|
||||
spinner.text = `Fetching ${options.file}`
|
||||
fileContent = await fetchText(registry, { url: url.toString() })
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
const output = {
|
||||
skill: skillResult.skill,
|
||||
latestVersion: skillResult.latestVersion,
|
||||
owner: skillResult.owner,
|
||||
version: versionResult?.version ?? null,
|
||||
versions: versionsList?.items ?? null,
|
||||
file: options.file ? { path: options.file, content: fileContent } : null,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(output, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
const shouldPrintMeta = !options.file || options.files || options.versions || options.version
|
||||
if (shouldPrintMeta) {
|
||||
printSkillSummary({
|
||||
skill,
|
||||
latestVersion: skillResult.latestVersion,
|
||||
owner: skillResult.owner,
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldPrintMeta && versionResult?.version) {
|
||||
printVersionSummary(versionResult.version)
|
||||
}
|
||||
|
||||
if (versionsList?.items && Array.isArray(versionsList.items)) {
|
||||
if (versionsList.items.length === 0) {
|
||||
console.log('No versions found.')
|
||||
} else {
|
||||
console.log('Versions:')
|
||||
for (const item of versionsList.items) {
|
||||
console.log(formatVersionLine(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (versionResult?.version) {
|
||||
const files = normalizeFiles((versionResult.version as { files?: unknown }).files)
|
||||
if (options.files) {
|
||||
if (files.length === 0) {
|
||||
console.log('No files found.')
|
||||
} else {
|
||||
console.log('Files:')
|
||||
for (const file of files) {
|
||||
console.log(formatFileLine(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.file && fileContent !== null) {
|
||||
if (shouldPrintMeta) console.log(`\n${options.file}:\n`)
|
||||
process.stdout.write(fileContent)
|
||||
if (!fileContent.endsWith('\n')) process.stdout.write('\n')
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function printSkillSummary(result: {
|
||||
skill: {
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string | null
|
||||
tags?: unknown
|
||||
stats?: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
latestVersion?: { version: string; createdAt: number; changelog: string } | null
|
||||
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null
|
||||
}) {
|
||||
const { skill } = result
|
||||
console.log(`${skill.slug} ${skill.displayName}`)
|
||||
if (skill.summary) console.log(`Summary: ${skill.summary}`)
|
||||
const owner = result.owner?.handle || result.owner?.displayName
|
||||
if (owner) console.log(`Owner: ${owner}`)
|
||||
console.log(`Created: ${formatTimestamp(skill.createdAt)}`)
|
||||
console.log(`Updated: ${formatTimestamp(skill.updatedAt)}`)
|
||||
if (result.latestVersion?.version) {
|
||||
console.log(`Latest: ${result.latestVersion.version}`)
|
||||
}
|
||||
const tags = normalizeTags(skill.tags)
|
||||
const tagEntries = Object.entries(tags)
|
||||
if (tagEntries.length > 0) {
|
||||
console.log(`Tags: ${tagEntries.map(([tag, version]) => `${tag}=${version}`).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printVersionSummary(version: unknown) {
|
||||
if (!version || typeof version !== 'object') return
|
||||
const entry = version as { version?: unknown; createdAt?: unknown; changelog?: unknown }
|
||||
const value = typeof entry.version === 'string' ? entry.version : null
|
||||
if (!value) return
|
||||
console.log(`Selected: ${value}`)
|
||||
if (typeof entry.createdAt === 'number') {
|
||||
console.log(`Selected At: ${formatTimestamp(entry.createdAt)}`)
|
||||
}
|
||||
if (typeof entry.changelog === 'string' && entry.changelog.trim()) {
|
||||
console.log(`Changelog: ${truncate(entry.changelog, 120)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): Record<string, string> {
|
||||
if (!tags || typeof tags !== 'object') return {}
|
||||
const entries = Object.entries(tags as Record<string, unknown>)
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, version] of entries) {
|
||||
if (typeof version === 'string') resolved[tag] = version
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function normalizeFiles(files: unknown): FileEntry[] {
|
||||
if (!Array.isArray(files)) return []
|
||||
return files
|
||||
.map((file) => {
|
||||
if (!file || typeof file !== 'object') return null
|
||||
const entry = file as {
|
||||
path?: unknown
|
||||
size?: unknown
|
||||
sha256?: unknown
|
||||
contentType?: unknown
|
||||
}
|
||||
if (typeof entry.path !== 'string') return null
|
||||
const size = typeof entry.size === 'number' ? entry.size : Number(entry.size)
|
||||
const sha256 = typeof entry.sha256 === 'string' ? entry.sha256 : null
|
||||
const contentType = typeof entry.contentType === 'string' ? entry.contentType : null
|
||||
return {
|
||||
path: entry.path,
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
sha256,
|
||||
contentType,
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is FileEntry => Boolean(entry))
|
||||
}
|
||||
|
||||
function formatVersionLine(item: unknown) {
|
||||
if (!item || typeof item !== 'object') return '-'
|
||||
const entry = item as { version?: unknown; createdAt?: unknown; changelog?: unknown }
|
||||
const version = typeof entry.version === 'string' ? entry.version : '?'
|
||||
const createdAt =
|
||||
typeof entry.createdAt === 'number' ? formatTimestamp(entry.createdAt) : 'unknown'
|
||||
const changelog = typeof entry.changelog === 'string' ? entry.changelog : ''
|
||||
const snippet = changelog ? ` ${truncate(changelog, 80)}` : ''
|
||||
return `${version} ${createdAt}${snippet}`
|
||||
}
|
||||
|
||||
function formatFileLine(file: FileEntry) {
|
||||
const size = file.size === null ? '?' : formatBytes(file.size)
|
||||
const sha = file.sha256 ?? '?'
|
||||
const type = file.contentType ? ` ${file.contentType}` : ''
|
||||
return `${file.path} ${size} ${sha}${type}`
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number) {
|
||||
if (!Number.isFinite(timestamp)) return 'unknown'
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes)) return '?'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10
|
||||
return `${rounded}${units[index]}`
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, fallback: number) {
|
||||
if (!Number.isFinite(limit)) return fallback
|
||||
return Math.min(Math.max(1, Math.round(limit)), 200)
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number) {
|
||||
if (str.length <= maxLen) return str
|
||||
return `${str.slice(0, maxLen - 3)}...`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
}))
|
||||
|
||||
vi.mock('../registry.js', () => ({
|
||||
getRegistry: vi.fn(async () => 'https://clawhub.ai'),
|
||||
}))
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}))
|
||||
|
||||
vi.mock('../ui.js', () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message)
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
const { cmdBanUser } = await import('./moderation')
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawhub.ai',
|
||||
registry: 'https://clawhub.ai',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cmdBanUser', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
await expect(cmdBanUser(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('posts handle payload', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 })
|
||||
await cmdBanUser(makeOpts(), 'hightower6eu', { yes: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { handle: 'hightower6eu' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts user id payload when --id is set', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
await cmdBanUser(makeOpts(), 'user_123', { yes: true, id: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { userId: 'user_123' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1BanUserResponseSchema, parseArk } from '../../schema/index.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdBanUser(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
options: { yes?: boolean; id?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const raw = identifierArg.trim()
|
||||
if (!raw) fail('Handle or user id required')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const usesId = Boolean(options.id)
|
||||
const handle = usesId ? null : normalizeHandle(raw)
|
||||
const label = usesId ? raw : `@${handle}`
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Ban ${label}? (requires moderator/admin; deletes owned skills)`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Banning ${label}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: 'POST',
|
||||
path: `${ApiRoutes.users}/ban`,
|
||||
token,
|
||||
body: usesId ? { userId: raw } : { handle },
|
||||
},
|
||||
ApiV1BanUserResponseSchema,
|
||||
)
|
||||
const parsed = parseArk(ApiV1BanUserResponseSchema, result, 'Ban user response')
|
||||
if (parsed.alreadyBanned) {
|
||||
spinner.succeed(`OK. ${label} already banned`)
|
||||
return parsed
|
||||
}
|
||||
spinner.succeed(`OK. Banned ${label} (${formatDeletedSkills(parsed.deletedSkills)})`)
|
||||
return parsed
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHandle(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
function formatDeletedSkills(count: number) {
|
||||
if (!Number.isFinite(count)) return 'deleted skills unknown'
|
||||
if (count === 1) return 'deleted 1 skill'
|
||||
return `deleted ${count} skills`
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
|
||||
|
||||
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
intro('OpenClaw sync')
|
||||
intro('ClawHub sync')
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
|
||||
@@ -127,6 +127,36 @@ export async function apiRequestForm<T>(
|
||||
return json as T
|
||||
}
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string }
|
||||
|
||||
export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
|
||||
return pRetry(
|
||||
async () => {
|
||||
if (isBun) {
|
||||
return await fetchTextViaCurl(url, args)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'text/plain' }
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
const message = text || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
}
|
||||
return text
|
||||
},
|
||||
{ retries: 2 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
|
||||
const url = new URL(ApiRoutes.download, registry)
|
||||
url.searchParams.set('slug', args.slug)
|
||||
@@ -254,6 +284,43 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
const headers = ['-H', 'Accept: text/plain']
|
||||
if (args.token) {
|
||||
headers.push('-H', `Authorization: Bearer ${args.token}`)
|
||||
}
|
||||
const curlArgs = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--max-time',
|
||||
String(REQUEST_TIMEOUT_SECONDS),
|
||||
'--write-out',
|
||||
'\n%{http_code}',
|
||||
'-X',
|
||||
'GET',
|
||||
...headers,
|
||||
url,
|
||||
]
|
||||
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'curl failed')
|
||||
}
|
||||
const output = result.stdout ?? ''
|
||||
const splitAt = output.lastIndexOf('\n')
|
||||
if (splitAt === -1) throw new Error('curl response missing status')
|
||||
const body = output.slice(0, splitAt)
|
||||
const status = Number(output.slice(splitAt + 1).trim())
|
||||
if (!Number.isFinite(status)) throw new Error('curl response missing status')
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(body || `HTTP ${status}`)
|
||||
}
|
||||
throw new AbortError(body || `HTTP ${status}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchBinaryViaCurl(url: string) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-'))
|
||||
const filePath = join(tempDir, 'payload.bin')
|
||||
|
||||
@@ -18,5 +18,6 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -215,6 +215,12 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1BanUserResponseSchema = type({
|
||||
ok: 'true',
|
||||
alreadyBanned: 'boolean',
|
||||
deletedSkills: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# clawhub-schema
|
||||
|
||||
Shared runtime schemas (ArkType) for OpenClaw.
|
||||
Shared runtime schemas (ArkType) for ClawHub.
|
||||
|
||||
Vendored
+1
@@ -17,5 +17,6 @@ export declare const ApiRoutes: {
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly stars: "/api/v1/stars";
|
||||
readonly souls: "/api/v1/souls";
|
||||
readonly users: "/api/v1/users";
|
||||
readonly whoami: "/api/v1/whoami";
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -17,6 +17,7 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
};
|
||||
//# sourceMappingURL=routes.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
|
||||
@@ -18,5 +18,6 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "OpenClaw API",
|
||||
"title": "ClawHub API",
|
||||
"version": "1.0.0",
|
||||
"description": "Public REST API for skills. Rate limits: read 120/min per IP + 600/min per key; write 30/min per IP + 120/min per key."
|
||||
},
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
fill="#F6EFE4"
|
||||
font-size="92"
|
||||
font-weight="700"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">OpenClaw</text>
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="114" y="332"
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -116,8 +116,8 @@ function wrapText(value: string, maxWidth: number, fontSize: number, maxLines: n
|
||||
}
|
||||
|
||||
export function buildSkillOgSvg(params: SkillOgSvgParams) {
|
||||
const rawTitle = params.title.trim() || 'OpenClaw Skill'
|
||||
const rawDescription = params.description.trim() || 'Published on OpenClaw.'
|
||||
const rawTitle = params.title.trim() || 'ClawHub Skill'
|
||||
const rawDescription = params.description.trim() || 'Published on ClawHub.'
|
||||
|
||||
const cardX = 72
|
||||
const cardY = 96
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows a loading indicator while loading', () => {
|
||||
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return undefined
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
@@ -50,26 +53,33 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows not found when skill query resolves to null', async () => {
|
||||
useQueryMock.mockImplementationOnce(() => null) // getBySlug
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="missing-skill" />)
|
||||
expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('redirects legacy routes to canonical owner/slug', async () => {
|
||||
useQueryMock.mockImplementationOnce(() => ({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
|
||||
}))
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
if (args && typeof args === 'object' && 'skillId' in args) return []
|
||||
return {
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
|
||||
}
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
@@ -83,4 +93,38 @@ describe('SkillDetailPage', () => {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows report abuse note for authenticated users', async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: 'users:1', role: 'user' },
|
||||
})
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
if (args && typeof args === 'object' && 'skillId' in args) return []
|
||||
if (args && typeof args === 'object' && 'slug' in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {}, files: [] },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Reports require a reason\. Abuse may result in a ban\./i),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Menu, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
import { isModerator } from '../lib/roles'
|
||||
import { getOpenClawSiteUrl, getSiteMode, getSiteName } from '../lib/site'
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
|
||||
import { applyTheme, useThemeMode } from '../lib/theme'
|
||||
import { startThemeTransition } from '../lib/theme-transition'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
@@ -25,7 +25,7 @@ export default function Header() {
|
||||
const siteMode = getSiteMode()
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode])
|
||||
const isSoulMode = siteMode === 'souls'
|
||||
const clawdHubUrl = getOpenClawSiteUrl()
|
||||
const clawHubUrl = getClawHubSiteUrl()
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
|
||||
const handle = me?.handle ?? me?.displayName ?? 'user'
|
||||
@@ -59,7 +59,7 @@ export default function Header() {
|
||||
<span className="brand-name">{siteName}</span>
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{isSoulMode ? <a href={clawdHubUrl}>OpenClaw</a> : null}
|
||||
{isSoulMode ? <a href={clawHubUrl}>ClawHub</a> : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
@@ -133,7 +133,7 @@ export default function Header() {
|
||||
<DropdownMenuContent align="end">
|
||||
{isSoulMode ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={clawdHubUrl}>OpenClaw</a>
|
||||
<a href={clawHubUrl}>ClawHub</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -19,9 +19,9 @@ type SkillDetailPageProps = {
|
||||
}
|
||||
|
||||
type SkillBySlugResult = {
|
||||
skill: PublicSkill
|
||||
skill: Doc<'skills'> | PublicSkill
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
owner: PublicUser | null
|
||||
owner: Doc<'users'> | PublicUser | null
|
||||
forkOf: {
|
||||
kind: 'fork' | 'duplicate'
|
||||
version: string | null
|
||||
@@ -36,6 +36,32 @@ type SkillBySlugResult = {
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (error && typeof error === 'object' && 'data' in error) {
|
||||
const data = (error as { data?: unknown }).data
|
||||
if (typeof data === 'string' && data.trim()) return data.trim()
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'message' in data &&
|
||||
typeof (data as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim()
|
||||
if (message) return message
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const cleaned = error.message
|
||||
.replace(/\[CONVEX[^\]]*\]\s*/g, '')
|
||||
.replace(/\[Request ID:[^\]]*\]\s*/g, '')
|
||||
.replace(/^Server Error Called by client\s*/i, '')
|
||||
.replace(/^ConvexError:\s*/i, '')
|
||||
.trim()
|
||||
if (cleaned && cleaned !== 'Server Error') return cleaned
|
||||
}
|
||||
return 'Unable to submit report. Please try again.'
|
||||
}
|
||||
|
||||
export function SkillDetailPage({
|
||||
slug,
|
||||
canonicalOwner,
|
||||
@@ -43,7 +69,14 @@ export function SkillDetailPage({
|
||||
}: SkillDetailPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated, me } = useAuthStatus()
|
||||
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
|
||||
const isStaff = isModerator(me)
|
||||
const staffResult = useQuery(api.skills.getBySlugForStaff, isStaff ? { slug } : 'skip') as
|
||||
| SkillBySlugResult
|
||||
| undefined
|
||||
const publicResult = useQuery(api.skills.getBySlug, !isStaff ? { slug } : 'skip') as
|
||||
| SkillBySlugResult
|
||||
| undefined
|
||||
const result = isStaff ? staffResult : publicResult
|
||||
const toggleStar = useMutation(api.stars.toggle)
|
||||
const reportSkill = useMutation(api.skills.report)
|
||||
const addComment = useMutation(api.comments.add)
|
||||
@@ -80,7 +113,6 @@ export function SkillDetailPage({
|
||||
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
|
||||
|
||||
const canManage = canManageSkill(me, skill)
|
||||
const isStaff = isModerator(me)
|
||||
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? null
|
||||
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
|
||||
@@ -104,6 +136,26 @@ export function SkillDetailPage({
|
||||
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
|
||||
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
|
||||
: null
|
||||
const staffSkill = isStaff && skill ? (skill as Doc<'skills'>) : null
|
||||
const moderationStatus =
|
||||
staffSkill?.moderationStatus ?? (staffSkill?.softDeletedAt ? 'hidden' : undefined)
|
||||
const isHidden = moderationStatus === 'hidden' || Boolean(staffSkill?.softDeletedAt)
|
||||
const isRemoved = moderationStatus === 'removed'
|
||||
const isAutoHidden = isHidden && staffSkill?.moderationReason === 'auto.reports'
|
||||
const staffVisibilityTag = isRemoved
|
||||
? 'Removed'
|
||||
: isAutoHidden
|
||||
? 'Auto-hidden'
|
||||
: isHidden
|
||||
? 'Hidden'
|
||||
: null
|
||||
const staffModerationNote = staffVisibilityTag
|
||||
? isAutoHidden
|
||||
? 'Auto-hidden after 4+ unique reports.'
|
||||
: isRemoved
|
||||
? 'Removed from public view.'
|
||||
: 'Hidden from public view.'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsCanonicalRedirect || !ownerParam) return
|
||||
@@ -207,6 +259,9 @@ export function SkillDetailPage({
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
@@ -246,6 +301,11 @@ export function SkillDetailPage({
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<div className={`tag${isAutoHidden || isRemoved ? ' tag-accent' : ''}`}>
|
||||
{staffVisibilityTag}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
@@ -262,12 +322,19 @@ export function SkillDetailPage({
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const reason = window.prompt('Report this skill? Add a reason if you want.')
|
||||
const reason = window.prompt(
|
||||
'Report this skill? A reason is required. Abuse may result in a ban.',
|
||||
)
|
||||
if (reason === null) return
|
||||
const trimmedReason = reason.trim()
|
||||
if (!trimmedReason) {
|
||||
window.alert('Report reason required.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await reportSkill({
|
||||
skillId: skill._id,
|
||||
reason: reason.trim() || undefined,
|
||||
reason: trimmedReason,
|
||||
})
|
||||
if (result.reported) {
|
||||
window.alert('Thanks — your report has been submitted.')
|
||||
@@ -276,7 +343,7 @@ export function SkillDetailPage({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to report skill', error)
|
||||
window.alert('Unable to submit report. Please try again.')
|
||||
window.alert(formatReportError(error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -289,6 +356,11 @@ export function SkillDetailPage({
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<div className="section-subtitle" style={{ margin: '6px 0 0' }}>
|
||||
Reports require a reason. Abuse may result in a ban.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="skill-hero-cta">
|
||||
<div className="skill-version-pill">
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ describe('og helpers', () => {
|
||||
summary: 'Forecasts for your area.',
|
||||
version: '1.2.3',
|
||||
})
|
||||
expect(meta.title).toBe('Weather — OpenClaw')
|
||||
expect(meta.title).toBe('Weather — ClawHub')
|
||||
expect(meta.description).toBe('Forecasts for your area.')
|
||||
expect(meta.url).toContain('/steipete/weather')
|
||||
expect(meta.owner).toBe('steipete')
|
||||
@@ -48,8 +48,8 @@ describe('og helpers', () => {
|
||||
|
||||
it('uses defaults when owner and summary are missing', () => {
|
||||
const meta = buildSkillMeta({ slug: 'parser' })
|
||||
expect(meta.title).toBe('parser — OpenClaw')
|
||||
expect(meta.description).toMatch(/OpenClaw — a fast skill registry/i)
|
||||
expect(meta.title).toBe('parser — ClawHub')
|
||||
expect(meta.description).toMatch(/ClawHub — a fast skill registry/i)
|
||||
expect(meta.url).toContain('/unknown/parser')
|
||||
expect(meta.owner).toBeNull()
|
||||
expect(meta.image).toContain('slug=parser')
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { getOpenClawSiteUrl, getOnlyCrabsSiteUrl } from './site'
|
||||
import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
|
||||
|
||||
type SkillMetaSource = {
|
||||
slug: string
|
||||
@@ -33,13 +33,13 @@ type SoulMeta = {
|
||||
owner: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_DESCRIPTION = 'OpenClaw — a fast skill registry for agents, with vector search.'
|
||||
const DEFAULT_DESCRIPTION = 'ClawHub — a fast skill registry for agents, with vector search.'
|
||||
const DEFAULT_SOUL_DESCRIPTION = 'SoulHub — the home for SOUL.md bundles and personal system lore.'
|
||||
const OG_SKILL_IMAGE_LAYOUT_VERSION = '5'
|
||||
const OG_SOUL_IMAGE_LAYOUT_VERSION = '1'
|
||||
|
||||
export function getSiteUrl() {
|
||||
return getOpenClawSiteUrl()
|
||||
return getClawHubSiteUrl()
|
||||
}
|
||||
|
||||
export function getSoulSiteUrl() {
|
||||
@@ -103,9 +103,9 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
|
||||
const displayName = clean(source.displayName) || clean(source.slug)
|
||||
const summary = clean(source.summary)
|
||||
const version = clean(source.version)
|
||||
const title = `${displayName} — OpenClaw`
|
||||
const title = `${displayName} — ClawHub`
|
||||
const description =
|
||||
summary || (owner ? `Agent skill by @${owner} on OpenClaw.` : DEFAULT_DESCRIPTION)
|
||||
summary || (owner ? `Agent skill by @${owner} on ClawHub.` : DEFAULT_DESCRIPTION)
|
||||
const ownerPath = owner || ownerId || 'unknown'
|
||||
const url = `${siteUrl}/${ownerPath}/${source.slug}`
|
||||
const imageParams = new URLSearchParams()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
detectSiteMode,
|
||||
detectSiteModeFromUrl,
|
||||
getOpenClawSiteUrl,
|
||||
getClawHubSiteUrl,
|
||||
getOnlyCrabsHost,
|
||||
getOnlyCrabsSiteUrl,
|
||||
getSiteDescription,
|
||||
@@ -42,9 +42,9 @@ afterEach(() => {
|
||||
|
||||
describe('site helpers', () => {
|
||||
it('returns default and env configured site URLs', () => {
|
||||
expect(getOpenClawSiteUrl()).toBe('https://clawhub.ai')
|
||||
expect(getClawHubSiteUrl()).toBe('https://clawhub.ai')
|
||||
withMetaEnv({ VITE_SITE_URL: 'https://example.com' }, () => {
|
||||
expect(getOpenClawSiteUrl()).toBe('https://example.com')
|
||||
expect(getClawHubSiteUrl()).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,10 +118,10 @@ describe('site helpers', () => {
|
||||
})
|
||||
|
||||
it('derives site metadata from mode', () => {
|
||||
expect(getSiteName('skills')).toBe('OpenClaw')
|
||||
expect(getSiteName('skills')).toBe('ClawHub')
|
||||
expect(getSiteName('souls')).toBe('SoulHub')
|
||||
|
||||
expect(getSiteDescription('skills')).toContain('OpenClaw')
|
||||
expect(getSiteDescription('skills')).toContain('ClawHub')
|
||||
expect(getSiteDescription('souls')).toContain('SoulHub')
|
||||
|
||||
expect(getSiteUrlForMode('skills')).toBe('https://clawhub.ai')
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ const DEFAULT_CLAWHUB_SITE_URL = 'https://clawhub.ai'
|
||||
const DEFAULT_ONLYCRABS_SITE_URL = 'https://onlycrabs.ai'
|
||||
const DEFAULT_ONLYCRABS_HOST = 'onlycrabs.ai'
|
||||
|
||||
export function getOpenClawSiteUrl() {
|
||||
export function getClawHubSiteUrl() {
|
||||
return import.meta.env.VITE_SITE_URL ?? DEFAULT_CLAWHUB_SITE_URL
|
||||
}
|
||||
|
||||
@@ -70,15 +70,15 @@ export function getSiteMode(): SiteMode {
|
||||
}
|
||||
|
||||
export function getSiteName(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls' ? 'SoulHub' : 'OpenClaw'
|
||||
return mode === 'souls' ? 'SoulHub' : 'ClawHub'
|
||||
}
|
||||
|
||||
export function getSiteDescription(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls'
|
||||
? 'SoulHub — the home for SOUL.md bundles and personal system lore.'
|
||||
: 'OpenClaw — a fast skill registry for agents, with vector search.'
|
||||
: 'ClawHub — a fast skill registry for agents, with vector search.'
|
||||
}
|
||||
|
||||
export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getOpenClawSiteUrl()
|
||||
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getClawHubSiteUrl()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function SkillsHome() {
|
||||
<div className="hero-inner">
|
||||
<div className="hero-copy fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
<h1 className="hero-title">OpenClaw, the skill dock for sharp agents.</h1>
|
||||
<h1 className="hero-title">ClawHub, the skill dock for sharp agents.</h1>
|
||||
<p className="hero-subtitle">
|
||||
Upload AgentSkills bundles, version them like npm, and make them searchable with
|
||||
vectors. No gatekeeping, just signal.
|
||||
|
||||
+183
-27
@@ -18,6 +18,17 @@ type ManagementSkillEntry = {
|
||||
owner: Doc<'users'> | null
|
||||
}
|
||||
|
||||
type ReportReasonEntry = {
|
||||
reason: string
|
||||
createdAt: number
|
||||
reporterHandle: string | null
|
||||
reporterId: Id<'users'>
|
||||
}
|
||||
|
||||
type ReportedSkillEntry = ManagementSkillEntry & {
|
||||
reports: ReportReasonEntry[]
|
||||
}
|
||||
|
||||
type RecentVersionEntry = {
|
||||
version: Doc<'skillVersions'>
|
||||
skill: Doc<'skills'> | null
|
||||
@@ -64,14 +75,14 @@ function Management() {
|
||||
| undefined
|
||||
const selectedSlug = search.skill?.trim()
|
||||
const selectedSkill = useQuery(
|
||||
api.skills.getBySlug,
|
||||
api.skills.getBySlugForStaff,
|
||||
staff && selectedSlug ? { slug: selectedSlug } : 'skip',
|
||||
) as SkillBySlugResult | undefined
|
||||
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : 'skip') as
|
||||
| RecentVersionEntry[]
|
||||
| undefined
|
||||
const reportedSkills = useQuery(api.skills.listReportedSkills, staff ? { limit: 25 } : 'skip') as
|
||||
| ManagementSkillEntry[]
|
||||
| ReportedSkillEntry[]
|
||||
| undefined
|
||||
const duplicateCandidates = useQuery(
|
||||
api.skills.listDuplicateCandidates,
|
||||
@@ -79,6 +90,7 @@ function Management() {
|
||||
) as DuplicateCandidateEntry[] | undefined
|
||||
|
||||
const setRole = useMutation(api.users.setRole)
|
||||
const banUser = useMutation(api.users.banUser)
|
||||
const setBatch = useMutation(api.skills.setBatch)
|
||||
const setSoftDeleted = useMutation(api.skills.setSoftDeleted)
|
||||
const hardDelete = useMutation(api.skills.hardDelete)
|
||||
@@ -89,6 +101,10 @@ function Management() {
|
||||
|
||||
const [selectedDuplicate, setSelectedDuplicate] = useState('')
|
||||
const [selectedOwner, setSelectedOwner] = useState('')
|
||||
const [reportSearch, setReportSearch] = useState('')
|
||||
const [reportSearchDebounced, setReportSearchDebounced] = useState('')
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState('')
|
||||
|
||||
const selectedSkillId = selectedSkill?.skill?._id ?? null
|
||||
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null
|
||||
@@ -100,6 +116,16 @@ function Management() {
|
||||
setSelectedOwner(String(selectedOwnerUserId))
|
||||
}, [selectedCanonicalSlug, selectedOwnerUserId, selectedSkillId])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250)
|
||||
return () => clearTimeout(handle)
|
||||
}, [reportSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setUserSearchDebounced(userSearch), 250)
|
||||
return () => clearTimeout(handle)
|
||||
}, [userSearch])
|
||||
|
||||
if (!staff) {
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -116,6 +142,46 @@ function Management() {
|
||||
)
|
||||
}
|
||||
|
||||
const reportQuery = reportSearchDebounced.trim().toLowerCase()
|
||||
const filteredReportedSkills = reportQuery
|
||||
? reportedSkills.filter((entry) => {
|
||||
const reportReasons = (entry.reports ?? []).map((report) => report.reason).join(' ')
|
||||
const reporterHandles = (entry.reports ?? [])
|
||||
.map((report) => report.reporterHandle)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const haystack = [
|
||||
entry.skill.displayName,
|
||||
entry.skill.slug,
|
||||
entry.owner?.handle,
|
||||
entry.owner?.name,
|
||||
reportReasons,
|
||||
reporterHandles,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(reportQuery)
|
||||
})
|
||||
: reportedSkills
|
||||
const reportCountLabel =
|
||||
filteredReportedSkills.length === 0 && reportedSkills.length > 0
|
||||
? 'No matching reports.'
|
||||
: 'No reports yet.'
|
||||
const reportSummary = `Showing ${filteredReportedSkills.length} of ${reportedSkills.length}`
|
||||
|
||||
const userQuery = userSearchDebounced.trim().toLowerCase()
|
||||
const filteredUsers = userQuery
|
||||
? (users ?? []).filter((user) => {
|
||||
const haystack = [user.handle, user.name, user.role, user._id]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(userQuery)
|
||||
})
|
||||
: (users ?? [])
|
||||
const userSummary = `Showing ${filteredUsers.length} of ${(users ?? []).length}`
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
@@ -125,16 +191,29 @@ function Management() {
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Reported skills
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search reported skills"
|
||||
value={reportSearch}
|
||||
onChange={(event) => setReportSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="management-count">{reportSummary}</div>
|
||||
</div>
|
||||
<div className="management-list">
|
||||
{reportedSkills.length === 0 ? (
|
||||
<div className="stat">No reports yet.</div>
|
||||
{filteredReportedSkills.length === 0 ? (
|
||||
<div className="stat">{reportCountLabel}</div>
|
||||
) : (
|
||||
reportedSkills.map((entry) => {
|
||||
const { skill, latestVersion, owner } = entry
|
||||
filteredReportedSkills.map((entry) => {
|
||||
const { skill, latestVersion, owner, reports } = entry
|
||||
const ownerParam = resolveOwnerParam(
|
||||
owner?.handle ?? null,
|
||||
owner?._id ?? skill.ownerUserId,
|
||||
)
|
||||
const reportEntries = reports ?? []
|
||||
return (
|
||||
<div key={skill._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
@@ -148,6 +227,26 @@ function Management() {
|
||||
? ` · last ${formatTimestamp(skill.lastReportedAt)}`
|
||||
: ''}
|
||||
</div>
|
||||
{reportEntries.length > 0 ? (
|
||||
<div className="management-sublist">
|
||||
{reportEntries.map((report) => (
|
||||
<div
|
||||
key={`${report.reporterId}-${report.createdAt}`}
|
||||
className="management-report-item"
|
||||
>
|
||||
<span className="management-report-meta">
|
||||
{formatTimestamp(report.createdAt)}
|
||||
{report.reporterHandle ? ` · @${report.reporterHandle}` : ''}
|
||||
</span>
|
||||
<span>{report.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
No report reasons yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<button
|
||||
@@ -211,6 +310,11 @@ function Management() {
|
||||
const isOfficial = isSkillOfficial(skill)
|
||||
const isDeprecated = isSkillDeprecated(skill)
|
||||
const badges = getSkillBadges(skill)
|
||||
const ownerUserId = skill.ownerUserId ?? selectedOwnerUserId
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? 'user'
|
||||
const isOwnerAdmin = owner?.role === 'admin'
|
||||
const canBanOwner =
|
||||
staff && ownerUserId && ownerUserId !== me?._id && (admin || !isOwnerAdmin)
|
||||
|
||||
return (
|
||||
<div key={skill._id} className="management-item">
|
||||
@@ -324,6 +428,22 @@ function Management() {
|
||||
Hard delete
|
||||
</button>
|
||||
) : null}
|
||||
{staff ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={!canBanOwner}
|
||||
onClick={() => {
|
||||
if (!ownerUserId || ownerUserId === me?._id) return
|
||||
if (!window.confirm(`Ban @${ownerHandle} and delete their skills?`)) {
|
||||
return
|
||||
}
|
||||
void banUser({ userId: ownerUserId })
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
) : null}
|
||||
{admin ? (
|
||||
<>
|
||||
<button
|
||||
@@ -494,29 +614,65 @@ function Management() {
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Users
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search users"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="management-count">{userSummary}</div>
|
||||
</div>
|
||||
<div className="management-list">
|
||||
{(users ?? []).map((user) => (
|
||||
<div key={user._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<select
|
||||
value={user.role ?? 'user'}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (value === 'admin' || value === 'moderator' || value === 'user') {
|
||||
void setRole({ userId: user._id, role: value })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="stat">
|
||||
{(users ?? []).length === 0 ? 'No users yet.' : 'No matching users.'}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<div key={user._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<select
|
||||
value={user.role ?? 'user'}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (value === 'admin' || value === 'moderator' || value === 'user') {
|
||||
void setRole({ userId: user._id, role: value })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={user._id === me?._id}
|
||||
onClick={() => {
|
||||
if (user._id === me?._id) return
|
||||
if (
|
||||
!window.confirm(
|
||||
`Ban @${user.handle ?? user.name ?? 'user'} and delete their skills?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
void banUser({ userId: user._id })
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isTextFile,
|
||||
readText,
|
||||
uploadFile,
|
||||
} from './upload/utils'
|
||||
} from './upload/-utils'
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
|
||||
@@ -2687,6 +2687,15 @@ html.theme-transition::view-transition-new(theme) {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.management-search input {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.management-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.management-sublist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -2703,6 +2712,18 @@ html.theme-transition::view-transition-new(theme) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.management-report-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.management-report-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.management-item {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -17,6 +17,29 @@ const convexBrowserPath = join(convexRoot, 'dist/esm/browser/index.js')
|
||||
const convexValuesPath = join(convexRoot, 'dist/esm/values/index.js')
|
||||
const convexAuthReactPath = require.resolve('@convex-dev/auth/react')
|
||||
|
||||
function handleRollupWarning(
|
||||
warning: { code?: string; message: string; id?: string },
|
||||
warn: (warning: { code?: string; message: string; id?: string }) => void,
|
||||
) {
|
||||
if (
|
||||
warning.code === 'MODULE_LEVEL_DIRECTIVE' &&
|
||||
warning.id?.includes('node_modules') &&
|
||||
/use client/i.test(warning.message)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
warning.code === 'UNUSED_EXTERNAL_IMPORT' &&
|
||||
/@tanstack\/start-|@tanstack\/router-core\/ssr\/(client|server)/.test(warning.message)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (warning.code === 'EMPTY_BUNDLE' || /Generated an empty chunk/i.test(warning.message)) {
|
||||
return
|
||||
}
|
||||
warn(warning)
|
||||
}
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: {
|
||||
dedupe: ['convex', '@convex-dev/auth', 'react', 'react-dom'],
|
||||
@@ -34,6 +57,9 @@ const config = defineConfig({
|
||||
devtools(),
|
||||
nitro({
|
||||
serverDir: 'server',
|
||||
rollupConfig: {
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
}),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
@@ -43,6 +69,17 @@ const config = defineConfig({
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
rollupOptions: {
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
||||
Reference in New Issue
Block a user