mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 01:42:07 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3849069d | ||
|
|
ed4c191dfc | ||
|
|
9e704e0a81 | ||
|
|
f05dd556db | ||
|
|
964893a622 | ||
|
|
9a804b951f | ||
|
|
d699087786 | ||
|
|
26b42727fe | ||
|
|
8756b78a4e | ||
|
|
e9c771d55d | ||
|
|
a58f0166fa | ||
|
|
4328d4d700 | ||
|
|
28ee2618c1 | ||
|
|
a4b850ec33 | ||
|
|
7e0b21f7c8 | ||
|
|
71c6705ab1 | ||
|
|
a57769771f | ||
|
|
6a2c131a8a | ||
|
|
67ac157545 | ||
|
|
ef36cfd698 | ||
|
|
e0637ad6aa |
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Admin: add manual unban for banned users (clears `deletedAt` + `banReason`, audit log entry). Revoked API tokens stay revoked.
|
||||
|
||||
### Changed
|
||||
- Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs.
|
||||
- Jobs: run skill stat event processing every 5 minutes (was 15).
|
||||
- API performance: batch resolve skill/soul tags in v1 list/get endpoints (fewer action->query round-trips) (#112) (thanks @mkrokosz).
|
||||
|
||||
### Fixed
|
||||
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
|
||||
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
|
||||
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
|
||||
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).
|
||||
- Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98).
|
||||
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
@@ -791,8 +790,6 @@
|
||||
|
||||
"convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||
|
||||
Vendored
+6
@@ -22,6 +22,7 @@ import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as httpApi from "../httpApi.js";
|
||||
import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as httpPreflight from "../httpPreflight.js";
|
||||
import type * as leaderboards from "../leaderboards.js";
|
||||
import type * as lib_access from "../lib/access.js";
|
||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
@@ -30,8 +31,10 @@ 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_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
@@ -93,6 +96,7 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
httpApi: typeof httpApi;
|
||||
httpApiV1: typeof httpApiV1;
|
||||
httpPreflight: typeof httpPreflight;
|
||||
leaderboards: typeof leaderboards;
|
||||
"lib/access": typeof lib_access;
|
||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||
@@ -101,8 +105,10 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ crons.interval(
|
||||
|
||||
crons.interval(
|
||||
'skill-stat-events',
|
||||
{ minutes: 15 },
|
||||
{ minutes: 5 },
|
||||
internal.skillStatEvents.processSkillStatEventsAction,
|
||||
{},
|
||||
)
|
||||
|
||||
+42
-17
@@ -3,6 +3,7 @@ import { api, internal } from './_generated/api'
|
||||
import { httpAction, internalMutation, mutation } from './_generated/server'
|
||||
import { getOptionalApiTokenUserId } from './lib/apiTokenAuth'
|
||||
import { applyRateLimit, getClientIp } from './lib/httpRateLimit'
|
||||
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
|
||||
import { buildDeterministicZip } from './lib/skillZip'
|
||||
import { hashToken } from './lib/tokens'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
@@ -19,7 +20,10 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
const tagParam = url.searchParams.get('tag')?.trim()
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 })
|
||||
return new Response('Missing slug', {
|
||||
status: 400,
|
||||
headers: corsHeaders(),
|
||||
})
|
||||
}
|
||||
|
||||
const rate = await applyRateLimit(ctx, request, 'download')
|
||||
@@ -27,7 +31,10 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
|
||||
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
|
||||
if (!skillResult?.skill) {
|
||||
return new Response('Skill not found', { status: 404 })
|
||||
return new Response('Skill not found', {
|
||||
status: 404,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
})
|
||||
}
|
||||
|
||||
// Block downloads based on moderation status.
|
||||
@@ -35,20 +42,32 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
if (mod?.isMalwareBlocked) {
|
||||
return new Response(
|
||||
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
|
||||
{ status: 403 },
|
||||
{
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (mod?.isPendingScan) {
|
||||
return new Response(
|
||||
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
|
||||
{ status: 423 },
|
||||
{
|
||||
status: 423,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (mod?.isRemoved) {
|
||||
return new Response('This skill has been removed by a moderator.', { status: 410 })
|
||||
return new Response('This skill has been removed by a moderator.', {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
})
|
||||
}
|
||||
if (mod?.isHiddenByMod) {
|
||||
return new Response('This skill is currently unavailable.', { status: 403 })
|
||||
return new Response('This skill is currently unavailable.', {
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
})
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
@@ -67,10 +86,16 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return new Response('Version not found', { status: 404 })
|
||||
return new Response('Version not found', {
|
||||
status: 404,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
})
|
||||
}
|
||||
if (version.softDeletedAt) {
|
||||
return new Response('Version not available', { status: 410 })
|
||||
return new Response('Version not available', {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
})
|
||||
}
|
||||
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = []
|
||||
@@ -104,11 +129,15 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
|
||||
return new Response(zipBlob, {
|
||||
status: 200,
|
||||
headers: mergeHeaders(rate.headers, {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
}),
|
||||
headers: mergeHeaders(
|
||||
rate.headers,
|
||||
{
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
},
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,7 +223,3 @@ export const __test = {
|
||||
getHourStart,
|
||||
getDownloadIdentityValue,
|
||||
}
|
||||
|
||||
function mergeHeaders(base: HeadersInit, extra: HeadersInit) {
|
||||
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
usersPostRouterV1Http,
|
||||
whoamiV1Http,
|
||||
} from './httpApiV1'
|
||||
import { preflightHandler } from './httpPreflight'
|
||||
|
||||
const http = httpRouter()
|
||||
|
||||
@@ -145,6 +146,12 @@ http.route({
|
||||
handler: soulsDeleteRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
pathPrefix: '/api/',
|
||||
method: 'OPTIONS',
|
||||
handler: preflightHandler,
|
||||
})
|
||||
|
||||
// TODO: remove legacy /api routes after deprecation window.
|
||||
http.route({
|
||||
path: LegacyApiRoutes.download,
|
||||
|
||||
+15
-8
@@ -11,6 +11,7 @@ import type { Id } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { httpAction } from './_generated/server'
|
||||
import { requireApiTokenUser } from './lib/apiTokenAuth'
|
||||
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
|
||||
import { publishVersionForUser } from './skills'
|
||||
|
||||
type SearchSkillEntry = {
|
||||
@@ -241,20 +242,26 @@ export const cliTelemetrySyncHttp = httpAction(cliTelemetrySyncHandler)
|
||||
function json(value: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function text(value: string, status: number) {
|
||||
return new Response(value, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./lib/apiTokenAuth', () => ({
|
||||
requireApiTokenUser: vi.fn(),
|
||||
getOptionalApiTokenUserId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./skills', () => ({
|
||||
publishVersionForUser: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
|
||||
const { getOptionalApiTokenUserId, requireApiTokenUser } = await import('./lib/apiTokenAuth')
|
||||
const { publishVersionForUser } = await import('./skills')
|
||||
const { __handlers } = await import('./httpApiV1')
|
||||
|
||||
@@ -59,6 +60,8 @@ const blockedRate = () => ({
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockReset()
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null)
|
||||
vi.mocked(requireApiTokenUser).mockReset()
|
||||
vi.mocked(publishVersionForUser).mockReset()
|
||||
})
|
||||
@@ -148,7 +151,7 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.match.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills with resolved tags', async () => {
|
||||
it('lists skills with resolved tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
@@ -170,7 +173,10 @@ describe('httpApiV1 handlers', () => {
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionId' in args) return { version: '1.0.0' }
|
||||
// Batch query: versionIds (plural)
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
@@ -183,6 +189,209 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('batches tag resolution across multiple skills into single query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'skill-a',
|
||||
displayName: 'Skill A',
|
||||
summary: 's',
|
||||
tags: { latest: 'versions:1', stable: 'versions:2' },
|
||||
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: 'skills:2',
|
||||
slug: 'skill-b',
|
||||
displayName: 'Skill B',
|
||||
summary: 's',
|
||||
tags: { latest: 'versions:3' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
// Batch query should receive all version IDs from all skills
|
||||
if ('versionIds' in args) {
|
||||
const ids = args.versionIds as string[]
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(ids).toContain('versions:1')
|
||||
expect(ids).toContain('versions:2')
|
||||
expect(ids).toContain('versions:3')
|
||||
return [
|
||||
{ _id: 'versions:1', version: '2.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'versions:2', version: '1.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'versions:3', version: '1.0.0', softDeletedAt: undefined },
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSkillsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
// Verify tags are correctly resolved for each skill
|
||||
expect(json.items[0].tags.latest).toBe('2.0.0')
|
||||
expect(json.items[0].tags.stable).toBe('1.0.0')
|
||||
expect(json.items[1].tags.latest).toBe('1.0.0')
|
||||
// Verify batch query was called exactly once (not per-tag)
|
||||
const batchCalls = runQuery.mock.calls.filter(
|
||||
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
|
||||
)
|
||||
expect(batchCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lists souls with resolved tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'demo-soul',
|
||||
displayName: 'Demo Soul',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSoulsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls?limit=1'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('batches tag resolution across multiple souls into single query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'soul-a',
|
||||
displayName: 'Soul A',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1', stable: 'soulVersions:2' },
|
||||
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:2',
|
||||
slug: 'soul-b',
|
||||
displayName: 'Soul B',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:3' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
const ids = args.versionIds as string[]
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(ids).toContain('soulVersions:1')
|
||||
expect(ids).toContain('soulVersions:2')
|
||||
expect(ids).toContain('soulVersions:3')
|
||||
return [
|
||||
{ _id: 'soulVersions:1', version: '2.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'soulVersions:2', version: '1.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'soulVersions:3', version: '1.0.0', softDeletedAt: undefined },
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSoulsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.items[0].tags.latest).toBe('2.0.0')
|
||||
expect(json.items[0].tags.stable).toBe('1.0.0')
|
||||
expect(json.items[1].tags.latest).toBe('1.0.0')
|
||||
const batchCalls = runQuery.mock.calls.filter(
|
||||
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
|
||||
)
|
||||
expect(batchCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('souls get resolves tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'demo-soul',
|
||||
displayName: 'Demo Soul',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
owner: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.soulsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls/demo-soul'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.soul.tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills supports sort aliases', async () => {
|
||||
const checks: Array<[string, string]> = [
|
||||
['rating', 'stars'],
|
||||
@@ -218,6 +427,52 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('get skill returns pending-scan message for owner api token', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
_id: 'skills:1',
|
||||
slug: 'demo',
|
||||
ownerUserId: 'users:1',
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo'),
|
||||
)
|
||||
expect(response.status).toBe(423)
|
||||
expect(await response.text()).toContain('security scan is pending')
|
||||
})
|
||||
|
||||
it('get skill returns undelete hint for owner soft-deleted skill', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
_id: 'skills:1',
|
||||
slug: 'demo',
|
||||
ownerUserId: 'users:1',
|
||||
softDeletedAt: 1,
|
||||
moderationStatus: 'hidden',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo'),
|
||||
)
|
||||
expect(response.status).toBe(410)
|
||||
expect(await response.text()).toContain('clawhub undelete demo')
|
||||
})
|
||||
|
||||
it('get skill returns payload', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
@@ -241,7 +496,10 @@ describe('httpApiV1 handlers', () => {
|
||||
owner: { handle: 'p', displayName: 'Peter', image: null },
|
||||
}
|
||||
}
|
||||
if ('versionId' in args) return { version: '1.0.0' }
|
||||
// Batch query for tag resolution
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
@@ -549,6 +807,63 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response2.status).toBe(200)
|
||||
})
|
||||
|
||||
it('delete/undelete map forbidden/not-found/unknown to 403/404/500', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
|
||||
const runMutationForbidden = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('Forbidden')
|
||||
})
|
||||
const forbidden = await __handlers.skillsDeleteRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationForbidden }),
|
||||
new Request('https://example.com/api/v1/skills/demo', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(forbidden.status).toBe(403)
|
||||
expect(await forbidden.text()).toBe('Forbidden')
|
||||
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runMutationNotFound = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('Skill not found')
|
||||
})
|
||||
const notFound = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationNotFound }),
|
||||
new Request('https://example.com/api/v1/skills/demo/undelete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
expect(await notFound.text()).toBe('Skill not found')
|
||||
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runMutationUnknown = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('boom')
|
||||
})
|
||||
const unknown = await __handlers.soulsDeleteRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationUnknown }),
|
||||
new Request('https://example.com/api/v1/souls/demo-soul', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(unknown.status).toBe(500)
|
||||
expect(await unknown.text()).toBe('Internal Server Error')
|
||||
})
|
||||
|
||||
it('ban user requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
+187
-222
@@ -3,16 +3,12 @@ import { api, internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { httpAction } from './_generated/server'
|
||||
import { requireApiTokenUser } from './lib/apiTokenAuth'
|
||||
import { hashToken } from './lib/tokens'
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from './lib/apiTokenAuth'
|
||||
import { applyRateLimit, parseBearerToken } from './lib/httpRateLimit'
|
||||
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
|
||||
import { publishVersionForUser } from './skills'
|
||||
import { publishSoulVersionForUser } from './souls'
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
const RATE_LIMITS = {
|
||||
read: { ip: 120, key: 600 },
|
||||
write: { ip: 30, key: 120 },
|
||||
} as const
|
||||
const MAX_RAW_FILE_BYTES = 200 * 1024
|
||||
|
||||
type SearchSkillEntry = {
|
||||
@@ -212,28 +208,29 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
sort,
|
||||
})) as ListSkillsResult
|
||||
|
||||
const items = await Promise.all(
|
||||
result.items.map(async (item) => {
|
||||
const tags = await resolveTags(ctx, item.skill.tags)
|
||||
return {
|
||||
slug: item.skill.slug,
|
||||
displayName: item.skill.displayName,
|
||||
summary: item.skill.summary ?? null,
|
||||
tags,
|
||||
stats: item.skill.stats,
|
||||
createdAt: item.skill.createdAt,
|
||||
updatedAt: item.skill.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
// Batch resolve all tags in a single query instead of N queries
|
||||
const resolvedTagsList = await resolveTagsBatch(
|
||||
ctx,
|
||||
result.items.map((item) => item.skill.tags),
|
||||
)
|
||||
|
||||
const items = result.items.map((item, idx) => ({
|
||||
slug: item.skill.slug,
|
||||
displayName: item.skill.displayName,
|
||||
summary: item.skill.summary ?? null,
|
||||
tags: resolvedTagsList[idx],
|
||||
stats: item.skill.stats,
|
||||
createdAt: item.skill.createdAt,
|
||||
updatedAt: item.skill.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
@@ -251,9 +248,13 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
if (segments.length === 1) {
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
|
||||
if (!result?.skill) return text('Skill not found', 404, rate.headers)
|
||||
if (!result?.skill) {
|
||||
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
|
||||
if (hidden) return text(hidden.message, hidden.status, rate.headers)
|
||||
return text('Skill not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
const tags = await resolveTags(ctx, result.skill.tags)
|
||||
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags])
|
||||
return json(
|
||||
{
|
||||
skill: {
|
||||
@@ -392,7 +393,9 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const isSvg =
|
||||
file.contentType?.toLowerCase().includes('svg') || file.path.toLowerCase().endsWith('.svg')
|
||||
|
||||
const headers = mergeHeaders(rate.headers, {
|
||||
const headers = mergeHeaders(
|
||||
rate.headers,
|
||||
{
|
||||
'Content-Type': file.contentType
|
||||
? `${file.contentType}; charset=utf-8`
|
||||
: 'text/plain; charset=utf-8',
|
||||
@@ -408,13 +411,61 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
|
||||
})
|
||||
},
|
||||
corsHeaders(),
|
||||
)
|
||||
return new Response(textContent, { status: 200, headers })
|
||||
}
|
||||
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
async function describeOwnerVisibleSkillState(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
slug: string,
|
||||
): Promise<{ status: number; message: string } | null> {
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return null
|
||||
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
|
||||
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
|
||||
if (!isOwner) return null
|
||||
|
||||
if (skill.softDeletedAt) {
|
||||
return {
|
||||
status: 410,
|
||||
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.moderationStatus === 'hidden') {
|
||||
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
|
||||
return {
|
||||
status: 423,
|
||||
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
|
||||
}
|
||||
}
|
||||
if (skill.moderationReason === 'quality.low') {
|
||||
return {
|
||||
status: 403,
|
||||
message:
|
||||
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 403,
|
||||
message: `Skill is hidden by moderation${skill.moderationReason ? ` (${skill.moderationReason})` : ''}.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.moderationStatus === 'removed') {
|
||||
return { status: 410, message: 'Skill has been removed by moderation.' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const skillsGetRouterV1Http = httpAction(skillsGetRouterV1Handler)
|
||||
|
||||
async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -487,8 +538,8 @@ async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,8 +560,8 @@ async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
deleted: true,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,147 +819,66 @@ function parsePublishBody(body: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSoulTags(
|
||||
/**
|
||||
* Batch resolve soul version tags to version strings.
|
||||
* Collects all version IDs, fetches them in a single query, then maps back.
|
||||
* Reduces N sequential queries to 1 batch query.
|
||||
*/
|
||||
async function resolveSoulTagsBatch(
|
||||
ctx: ActionCtx,
|
||||
tags: Record<string, Id<'soulVersions'>>,
|
||||
): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, versionId] of Object.entries(tags)) {
|
||||
const version = await ctx.runQuery(api.souls.getVersionById, { versionId })
|
||||
if (version && !version.softDeletedAt) {
|
||||
resolved[tag] = version.version
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
tagsList: Array<Record<string, Id<'soulVersions'>>>,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
return resolveVersionTagsBatch(ctx, tagsList, internal.souls.getVersionsByIdsInternal)
|
||||
}
|
||||
|
||||
async function resolveTags(
|
||||
async function resolveTagsBatch(
|
||||
ctx: ActionCtx,
|
||||
tags: Record<string, Id<'skillVersions'>>,
|
||||
): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, versionId] of Object.entries(tags)) {
|
||||
const version = await ctx.runQuery(api.skills.getVersionById, { versionId })
|
||||
if (version && !version.softDeletedAt) {
|
||||
resolved[tag] = version.version
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
tagsList: Array<Record<string, Id<'skillVersions'>>>,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
return resolveVersionTagsBatch(ctx, tagsList, internal.skills.getVersionsByIdsInternal)
|
||||
}
|
||||
|
||||
async function applyRateLimit(
|
||||
/**
|
||||
* Batch resolve version tags to version strings.
|
||||
* Collects all version IDs, fetches them in a single query, then maps back.
|
||||
*
|
||||
* Notes:
|
||||
* - Uses `internal.*` queries to avoid expanding the public Convex API surface.
|
||||
* - Sorts ids for stable query args (helps caching/log diffs).
|
||||
*/
|
||||
async function resolveVersionTagsBatch<TTable extends 'skillVersions' | 'soulVersions'>(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
kind: 'read' | 'write',
|
||||
): Promise<{ ok: true; headers: HeadersInit } | { ok: false; response: Response }> {
|
||||
const ip = getClientIp(request) ?? 'unknown'
|
||||
const ipResult = await checkRateLimit(ctx, `ip:${ip}`, RATE_LIMITS[kind].ip)
|
||||
const token = parseBearerToken(request)
|
||||
const keyResult = token
|
||||
? await checkRateLimit(ctx, `key:${await hashToken(token)}`, RATE_LIMITS[kind].key)
|
||||
: null
|
||||
tagsList: Array<Record<string, Id<TTable>>>,
|
||||
getVersionsByIdsQuery: unknown,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
const allVersionIds = new Set<Id<TTable>>()
|
||||
for (const tags of tagsList) {
|
||||
for (const versionId of Object.values(tags)) allVersionIds.add(versionId)
|
||||
}
|
||||
|
||||
const chosen = pickMostRestrictive(ipResult, keyResult)
|
||||
const headers = rateHeaders(chosen)
|
||||
if (allVersionIds.size === 0) return tagsList.map(() => ({}))
|
||||
|
||||
if (!ipResult.allowed || (keyResult && !keyResult.allowed)) {
|
||||
return {
|
||||
ok: false,
|
||||
response: text('Rate limit exceeded', 429, headers),
|
||||
const versionIds = [...allVersionIds].sort() as Array<Id<TTable>>
|
||||
const versions =
|
||||
((await ctx.runQuery(getVersionsByIdsQuery as never, { versionIds } as never)) as Array<{
|
||||
_id: Id<TTable>
|
||||
version: string
|
||||
softDeletedAt?: unknown
|
||||
}> | null) ?? []
|
||||
|
||||
const versionMap = new Map<Id<TTable>, string>()
|
||||
for (const v of versions) {
|
||||
if (!v?.softDeletedAt) versionMap.set(v._id, v.version)
|
||||
}
|
||||
|
||||
return tagsList.map((tags) => {
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, versionId] of Object.entries(tags)) {
|
||||
const version = versionMap.get(versionId)
|
||||
if (version) resolved[tag] = version
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, headers }
|
||||
}
|
||||
|
||||
type RateLimitResult = {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
async function checkRateLimit(
|
||||
ctx: ActionCtx,
|
||||
key: string,
|
||||
limit: number,
|
||||
): Promise<RateLimitResult> {
|
||||
// Step 1: Read-only check — no write conflicts for denied requests
|
||||
const status = (await ctx.runQuery(internal.rateLimits.getRateLimitStatusInternal, {
|
||||
key,
|
||||
limit,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
})) as RateLimitResult
|
||||
|
||||
if (!status.allowed) {
|
||||
return status
|
||||
}
|
||||
|
||||
// Step 2: Consume a token (only when allowed, with double-check for races)
|
||||
let result: { allowed: boolean; remaining: number }
|
||||
try {
|
||||
result = (await ctx.runMutation(internal.rateLimits.consumeRateLimitInternal, {
|
||||
key,
|
||||
limit,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
})) as { allowed: boolean; remaining: number }
|
||||
} catch (error) {
|
||||
if (isRateLimitWriteConflict(error)) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
limit: status.limit,
|
||||
resetAt: status.resetAt,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
remaining: result.remaining,
|
||||
limit: status.limit,
|
||||
resetAt: status.resetAt,
|
||||
}
|
||||
}
|
||||
|
||||
function pickMostRestrictive(primary: RateLimitResult, secondary: RateLimitResult | null) {
|
||||
if (!secondary) return primary
|
||||
if (!primary.allowed) return primary
|
||||
if (!secondary.allowed) return secondary
|
||||
return secondary.remaining < primary.remaining ? secondary : primary
|
||||
}
|
||||
|
||||
function rateHeaders(result: RateLimitResult): HeadersInit {
|
||||
const resetSeconds = Math.ceil(result.resetAt / 1000)
|
||||
return {
|
||||
'X-RateLimit-Limit': String(result.limit),
|
||||
'X-RateLimit-Remaining': String(result.remaining),
|
||||
'X-RateLimit-Reset': String(resetSeconds),
|
||||
...(result.allowed ? {} : { 'Retry-After': String(resetSeconds) }),
|
||||
}
|
||||
}
|
||||
|
||||
function getClientIp(request: Request) {
|
||||
const cfHeader = request.headers.get('cf-connecting-ip')
|
||||
if (cfHeader) return splitFirstIp(cfHeader)
|
||||
|
||||
if (!shouldTrustForwardedIps()) return null
|
||||
|
||||
const forwarded =
|
||||
request.headers.get('x-real-ip') ??
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
request.headers.get('fly-client-ip')
|
||||
return splitFirstIp(forwarded)
|
||||
}
|
||||
|
||||
function parseBearerToken(request: Request) {
|
||||
const header = request.headers.get('authorization') ?? request.headers.get('Authorization')
|
||||
if (!header) return null
|
||||
const trimmed = header.trim()
|
||||
if (!trimmed.toLowerCase().startsWith('bearer ')) return null
|
||||
const token = trimmed.slice(7).trim()
|
||||
return token || null
|
||||
return resolved
|
||||
})
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200, headers?: HeadersInit) {
|
||||
@@ -920,6 +890,7 @@ function json(value: unknown, status = 200, headers?: HeadersInit) {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -933,39 +904,11 @@ function text(value: string, status: number, headers?: HeadersInit) {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
|
||||
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
|
||||
}
|
||||
|
||||
function splitFirstIp(header: string | null) {
|
||||
if (!header) return null
|
||||
if (header.includes(',')) return header.split(',')[0]?.trim() || null
|
||||
const trimmed = header.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function shouldTrustForwardedIps() {
|
||||
const value = String(process.env.TRUST_FORWARDED_IPS ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!value) return true
|
||||
if (value === '1' || value === 'true' || value === 'yes') return true
|
||||
if (value === '0' || value === 'false' || value === 'no') return false
|
||||
return false
|
||||
}
|
||||
|
||||
function isRateLimitWriteConflict(error: unknown) {
|
||||
if (!(error instanceof Error)) return false
|
||||
return (
|
||||
error.message.includes('rateLimits') &&
|
||||
error.message.includes('changed while this mutation was being run')
|
||||
)
|
||||
}
|
||||
|
||||
function getPathSegments(request: Request, prefix: string) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (!pathname.startsWith(prefix)) return []
|
||||
@@ -1035,28 +978,29 @@ async function listSoulsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
cursor,
|
||||
})) as ListSoulsResult
|
||||
|
||||
const items = await Promise.all(
|
||||
result.items.map(async (item) => {
|
||||
const tags = await resolveSoulTags(ctx, item.soul.tags)
|
||||
return {
|
||||
slug: item.soul.slug,
|
||||
displayName: item.soul.displayName,
|
||||
summary: item.soul.summary ?? null,
|
||||
tags,
|
||||
stats: item.soul.stats,
|
||||
createdAt: item.soul.createdAt,
|
||||
updatedAt: item.soul.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
// Batch resolve all tags in a single query instead of N queries
|
||||
const resolvedTagsList = await resolveSoulTagsBatch(
|
||||
ctx,
|
||||
result.items.map((item) => item.soul.tags),
|
||||
)
|
||||
|
||||
const items = result.items.map((item, idx) => ({
|
||||
slug: item.soul.slug,
|
||||
displayName: item.soul.displayName,
|
||||
summary: item.soul.summary ?? null,
|
||||
tags: resolvedTagsList[idx],
|
||||
stats: item.soul.stats,
|
||||
createdAt: item.soul.createdAt,
|
||||
updatedAt: item.soul.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
@@ -1076,7 +1020,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const result = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
|
||||
if (!result?.soul) return text('Soul not found', 404, rate.headers)
|
||||
|
||||
const tags = await resolveSoulTags(ctx, result.soul.tags)
|
||||
const [tags] = await resolveSoulTagsBatch(ctx, [result.soul.tags])
|
||||
return json(
|
||||
{
|
||||
soul: {
|
||||
@@ -1210,7 +1154,9 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const isSvg =
|
||||
file.contentType?.toLowerCase().includes('svg') || file.path.toLowerCase().endsWith('.svg')
|
||||
|
||||
const headers = mergeHeaders(rate.headers, {
|
||||
const headers = mergeHeaders(
|
||||
rate.headers,
|
||||
{
|
||||
'Content-Type': file.contentType
|
||||
? `${file.contentType}; charset=utf-8`
|
||||
: 'text/plain; charset=utf-8',
|
||||
@@ -1226,7 +1172,9 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
|
||||
})
|
||||
},
|
||||
corsHeaders(),
|
||||
)
|
||||
return new Response(textContent, { status: 200, headers })
|
||||
}
|
||||
|
||||
@@ -1287,8 +1235,8 @@ async function soulsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('soul', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1309,13 +1257,30 @@ async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
deleted: true,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('soul', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const soulsDeleteRouterV1Http = httpAction(soulsDeleteRouterV1Handler)
|
||||
|
||||
function softDeleteErrorToResponse(
|
||||
entity: 'skill' | 'soul',
|
||||
error: unknown,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const message = error instanceof Error ? error.message : `${entity} delete failed`
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
if (lower.includes('unauthorized')) return text('Unauthorized', 401, headers)
|
||||
if (lower.includes('forbidden')) return text('Forbidden', 403, headers)
|
||||
if (lower.includes('not found')) return text(message, 404, headers)
|
||||
if (lower.includes('slug required')) return text('Slug required', 400, headers)
|
||||
|
||||
// Unknown: server-side failure. Keep body generic.
|
||||
return text('Internal Server Error', 500, headers)
|
||||
}
|
||||
|
||||
async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { httpAction } from './_generated/server'
|
||||
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
|
||||
|
||||
function getHeader(request: Request, name: string) {
|
||||
return request.headers.get(name) ?? request.headers.get(name.toLowerCase())
|
||||
}
|
||||
|
||||
export function buildPreflightHeaders(request: Request) {
|
||||
const requestedHeaders = getHeader(request, 'Access-Control-Request-Headers')?.trim() || null
|
||||
const requestedMethod = getHeader(request, 'Access-Control-Request-Method')?.trim() || null
|
||||
|
||||
const vary = [
|
||||
...(requestedMethod ? ['Access-Control-Request-Method'] : []),
|
||||
...(requestedHeaders ? ['Access-Control-Request-Headers'] : []),
|
||||
].join(', ')
|
||||
|
||||
return mergeHeaders(
|
||||
corsHeaders(),
|
||||
{
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
|
||||
'Access-Control-Allow-Headers':
|
||||
requestedHeaders ?? 'Content-Type, Authorization, Digest, X-Clawhub-Version',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
...(vary ? { Vary: vary } : {}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const preflightHandler = httpAction(async (_ctx, request) => {
|
||||
// No cookies/credentials supported; allow any origin for simple browser access.
|
||||
// If we ever add cookie auth, this must switch to reflecting origin + Allow-Credentials.
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: buildPreflightHeaders(request),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EMBEDDING_DIMENSIONS, generateEmbedding } from './embeddings'
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalApiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
...init,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
process.env.OPENAI_API_KEY = 'test-key'
|
||||
consoleWarnSpy.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = originalApiKey
|
||||
}
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('generateEmbedding', () => {
|
||||
it('returns zero embedding when OPENAI_API_KEY is missing', async () => {
|
||||
delete process.env.OPENAI_API_KEY
|
||||
const result = await generateEmbedding('hello world')
|
||||
|
||||
expect(result).toHaveLength(EMBEDDING_DIMENSIONS)
|
||||
expect(result.every((value) => value === 0)).toBe(true)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries on 429 responses and then succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [0.25, 0.75] }] }))
|
||||
|
||||
const promise = generateEmbedding('retry me')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(promise).resolves.toEqual([0.25, 0.75])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not retry non-retryable 4xx responses', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('bad request', { status: 400 }))
|
||||
|
||||
await expect(generateEmbedding('bad')).rejects.toThrow('Embedding failed: bad request')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries on network failures and then succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [1, 2, 3] }] }))
|
||||
|
||||
const promise = generateEmbedding('network retry')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(promise).resolves.toEqual([1, 2, 3])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries timeouts up to max attempts and preserves timeout error', async () => {
|
||||
vi.useFakeTimers()
|
||||
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
|
||||
|
||||
const promise = generateEmbedding('always timeout')
|
||||
const rejection = expect(promise).rejects.toThrow(
|
||||
'OpenAI API request timed out after 10 seconds',
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejection
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
+125
-31
@@ -1,10 +1,67 @@
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
export const EMBEDDING_DIMENSIONS = 1536
|
||||
|
||||
const EMBEDDING_ENDPOINT = 'https://api.openai.com/v1/embeddings'
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
const MAX_ATTEMPTS = 3
|
||||
const BASE_RETRY_DELAY_MS = 1_000
|
||||
|
||||
class RetryableEmbeddingError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'RetryableEmbeddingError'
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEmbedding() {
|
||||
return Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0)
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(retryAfterHeader: string | null) {
|
||||
if (!retryAfterHeader) return null
|
||||
|
||||
const seconds = Number(retryAfterHeader)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.round(seconds * 1000)
|
||||
}
|
||||
|
||||
const dateMs = Date.parse(retryAfterHeader)
|
||||
if (Number.isFinite(dateMs)) {
|
||||
return Math.max(0, dateMs - Date.now())
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getRetryDelayMs(attempt: number, retryAfterMs: number | null) {
|
||||
const exponentialDelayMs = BASE_RETRY_DELAY_MS * 2 ** attempt
|
||||
if (retryAfterMs == null) return exponentialDelayMs
|
||||
return Math.max(exponentialDelayMs, retryAfterMs)
|
||||
}
|
||||
|
||||
function normalizeRetryableNetworkError(error: unknown) {
|
||||
if (!(error instanceof Error)) return null
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return new RetryableEmbeddingError(
|
||||
`OpenAI API request timed out after ${Math.floor(REQUEST_TIMEOUT_MS / 1000)} seconds`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
return new RetryableEmbeddingError(`Embedding request failed: ${error.message}`, { cause: error })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateEmbedding(text: string) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
@@ -12,40 +69,77 @@ export async function generateEmbedding(text: string) {
|
||||
return emptyEmbedding()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
|
||||
let lastRetryableError: RetryableEmbeddingError | null = null
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(`Embedding failed: ${message}`)
|
||||
}
|
||||
try {
|
||||
const response = await fetch(EMBEDDING_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
data?: Array<{ embedding: number[] }>
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
const isRetryableStatus = response.status === 429 || response.status >= 500
|
||||
if (isRetryableStatus) {
|
||||
const retryableError = new RetryableEmbeddingError(
|
||||
`Embedding failed (${response.status}): ${message}`,
|
||||
)
|
||||
lastRetryableError = retryableError
|
||||
|
||||
if (attempt < MAX_ATTEMPTS - 1) {
|
||||
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'))
|
||||
const delayMs = getRetryDelayMs(attempt, retryAfterMs)
|
||||
console.warn(
|
||||
`OpenAI embeddings retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
|
||||
)
|
||||
await sleep(delayMs)
|
||||
continue
|
||||
}
|
||||
|
||||
throw retryableError
|
||||
}
|
||||
|
||||
throw new Error(`Embedding failed: ${message}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
data?: Array<{ embedding: number[] }>
|
||||
}
|
||||
const embedding = payload.data?.[0]?.embedding
|
||||
if (!embedding) throw new Error('Embedding missing from response')
|
||||
return embedding
|
||||
} catch (error) {
|
||||
const retryableNetworkError = normalizeRetryableNetworkError(error)
|
||||
if (retryableNetworkError) {
|
||||
lastRetryableError = retryableNetworkError
|
||||
if (attempt < MAX_ATTEMPTS - 1) {
|
||||
const delayMs = getRetryDelayMs(attempt, null)
|
||||
console.warn(
|
||||
`OpenAI embeddings network retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
|
||||
)
|
||||
await sleep(delayMs)
|
||||
continue
|
||||
}
|
||||
throw retryableNetworkError
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
const embedding = payload.data?.[0]?.embedding
|
||||
if (!embedding) throw new Error('Embedding missing from response')
|
||||
return embedding
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('OpenAI API request timed out after 10 seconds', { cause: error })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
throw lastRetryableError ?? new Error('Embedding failed after retries')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock('../_generated/api', () => ({
|
||||
internal: {
|
||||
users: {
|
||||
getByIdInternal: Symbol('getByIdInternal'),
|
||||
getGitHubProviderAccountIdInternal: Symbol('getGitHubProviderAccountIdInternal'),
|
||||
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
|
||||
},
|
||||
},
|
||||
@@ -19,21 +20,24 @@ describe('requireGitHubAccountAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses cached githubCreatedAt when fresh', async () => {
|
||||
it('uses cached githubCreatedAt when present', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
@@ -44,17 +48,34 @@ describe('requireGitHubAccountAge', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
|
||||
expect(runQuery).not.toHaveBeenCalledWith(internal.users.getGitHubProviderAccountIdInternal, {
|
||||
userId: 'users:1',
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
it('rejects deactivated users', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
deactivatedAt: Date.now(),
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/User not found/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects accounts younger than 7 days', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'newbie',
|
||||
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
|
||||
})
|
||||
@@ -63,34 +84,20 @@ describe('requireGitHubAccountAge', () => {
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects deactivated users', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
deactivatedAt: Date.now(),
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/User not found/i)
|
||||
})
|
||||
|
||||
it('refreshes githubCreatedAt when cache is stale', async () => {
|
||||
it('fetches githubCreatedAt when missing (by providerAccountId)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -103,7 +110,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
'https://api.github.com/user/12345',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'User-Agent': 'clawhub' }),
|
||||
}),
|
||||
@@ -113,17 +120,54 @@ describe('requireGitHubAccountAge', () => {
|
||||
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
|
||||
githubFetchedAt: now.getTime(),
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
it('rejects when providerAccountId is missing', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account required/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when providerAccountId is invalid', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('abc123')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when GitHub lookup fails', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -134,12 +178,13 @@ describe('requireGitHubAccountAge', () => {
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 403', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 403 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -150,12 +195,13 @@ describe('requireGitHubAccountAge', () => {
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 429', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -165,6 +211,26 @@ describe('requireGitHubAccountAge', () => {
|
||||
).rejects.toThrow(/rate limit exceeded/i)
|
||||
})
|
||||
|
||||
it('throws when GitHub returns an invalid payload', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
})
|
||||
|
||||
it('includes Authorization header when GITHUB_TOKEN is set', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
@@ -172,12 +238,13 @@ describe('requireGitHubAccountAge', () => {
|
||||
|
||||
vi.stubEnv('GITHUB_TOKEN', 'ghp_test123')
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -190,7 +257,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
'https://api.github.com/user/12345',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': 'clawhub',
|
||||
@@ -198,7 +265,6 @@ describe('requireGitHubAccountAge', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -15,22 +14,29 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError('User not found')
|
||||
|
||||
const handle = user.handle?.trim()
|
||||
if (!handle) throw new ConvexError('GitHub handle required')
|
||||
|
||||
const now = Date.now()
|
||||
let createdAt = user.githubCreatedAt ?? null
|
||||
const fetchedAt = user.githubFetchedAt ?? 0
|
||||
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
|
||||
|
||||
if (stale) {
|
||||
if (!createdAt) {
|
||||
const providerAccountId = await ctx.runQuery(internal.users.getGitHubProviderAccountIdInternal, {
|
||||
userId,
|
||||
})
|
||||
if (!providerAccountId) {
|
||||
// Invariant: GitHub is our only auth provider, so this should never happen.
|
||||
throw new ConvexError('GitHub account required')
|
||||
}
|
||||
if (!/^[0-9]+$/.test(providerAccountId)) {
|
||||
throw new ConvexError('GitHub account lookup failed')
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
|
||||
// Fetch by immutable GitHub numeric ID to avoid username swap attacks entirely.
|
||||
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canHealSkillOwnershipByGitHubProviderAccountId } from './githubIdentity'
|
||||
|
||||
describe('canHealSkillOwnershipByGitHubProviderAccountId', () => {
|
||||
it('denies when either providerAccountId is missing', () => {
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId(undefined, undefined)).toBe(false)
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', undefined)).toBe(false)
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId(undefined, '123')).toBe(false)
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId(null, '123')).toBe(false)
|
||||
})
|
||||
|
||||
it('denies when providerAccountId differs', () => {
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', '456')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows when providerAccountId matches', () => {
|
||||
expect(canHealSkillOwnershipByGitHubProviderAccountId('123', '123')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { QueryCtx } from '../_generated/server'
|
||||
|
||||
export function canHealSkillOwnershipByGitHubProviderAccountId(
|
||||
ownerProviderAccountId: string | null | undefined,
|
||||
callerProviderAccountId: string | null | undefined,
|
||||
) {
|
||||
// Security invariant: missing identity must never grant ownership.
|
||||
if (!ownerProviderAccountId || !callerProviderAccountId) return false
|
||||
return ownerProviderAccountId === callerProviderAccountId
|
||||
}
|
||||
|
||||
export async function getGitHubProviderAccountId(
|
||||
ctx: Pick<QueryCtx, 'db'>,
|
||||
userId: Id<'users'>,
|
||||
): Promise<string | null> {
|
||||
const account = await ctx.db
|
||||
.query('authAccounts')
|
||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId).eq('provider', 'github'))
|
||||
.unique()
|
||||
return account?.providerAccountId ?? null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
function toHeaderRecord(init?: HeadersInit): Record<string, string> {
|
||||
if (!init) return {}
|
||||
if (init instanceof Headers) return Object.fromEntries(init.entries())
|
||||
if (Array.isArray(init)) return Object.fromEntries(init)
|
||||
return { ...(init as Record<string, string>) }
|
||||
}
|
||||
|
||||
export function mergeHeaders(...inits: Array<HeadersInit | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const init of inits) {
|
||||
Object.assign(out, toHeaderRecord(init))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function corsHeaders(origin: string = '*'): Record<string, string> {
|
||||
return { 'Access-Control-Allow-Origin': origin }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { corsHeaders, mergeHeaders } from './httpHeaders'
|
||||
import { hashToken } from './tokens'
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
@@ -42,6 +43,7 @@ export async function applyRateLimit(
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
@@ -141,10 +143,6 @@ function splitFirstIp(header: string | null) {
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
|
||||
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
|
||||
}
|
||||
|
||||
function shouldTrustForwardedIps() {
|
||||
const value = String(process.env.TRUST_FORWARDED_IPS ?? '')
|
||||
.trim()
|
||||
|
||||
@@ -77,4 +77,29 @@ description: Expert guidance for sushi-rolls.
|
||||
expect(quality.decision).toBe('reject')
|
||||
expect(quality.reason).toContain('template spam')
|
||||
})
|
||||
|
||||
it('does not undercount non-latin skill docs', () => {
|
||||
const signals = __test.computeQualitySignals({
|
||||
readmeText: `# 飞书图片助手
|
||||
## 核心能力
|
||||
- 上传本地图片到飞书并自动返回 image_key,避免重复上传浪费配额。
|
||||
- 支持群聊与私聊,自动识别目标类型并校验参数,减少调用错误。
|
||||
- 提供重试与错误分类,方便排查网络问题、权限问题与资源限制。
|
||||
## 使用说明
|
||||
先配置应用凭证,然后传入目标会话与文件路径。技能会先检查缓存,再执行上传,并在发送阶段附带日志说明,便于团队追踪。
|
||||
如果出现失败,输出会包含建议动作,例如补齐权限、检查文件大小、确认机器人是否在群内,以及如何重放请求。
|
||||
还会记录每一步耗时、返回码与上下文摘要,方便后续做性能分析、告警聚合和批量回放,避免同类问题反复出现。
|
||||
`,
|
||||
summary: '上传并发送图片到飞书,支持缓存、重试和错误诊断。',
|
||||
})
|
||||
|
||||
const quality = __test.evaluateQuality({
|
||||
signals,
|
||||
trustTier: 'low',
|
||||
similarRecentCount: 0,
|
||||
})
|
||||
|
||||
expect(signals.bodyWords).toBeGreaterThanOrEqual(45)
|
||||
expect(quality.decision).toBe('pass')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export type QualitySignals = {
|
||||
bulletCount: number
|
||||
templateMarkerHits: number
|
||||
genericSummary: boolean
|
||||
cjkChars: number
|
||||
structuralFingerprint: string
|
||||
}
|
||||
|
||||
@@ -40,6 +41,29 @@ function stripFrontmatter(raw: string) {
|
||||
}
|
||||
|
||||
function tokenizeWords(text: string) {
|
||||
const segmenterCtor = (Intl as typeof Intl & {
|
||||
Segmenter?: new (
|
||||
locale?: string | string[],
|
||||
options?: { granularity?: 'grapheme' | 'word' | 'sentence' },
|
||||
) => {
|
||||
segment: (
|
||||
input: string,
|
||||
) => Iterable<{ segment: string; isWordLike?: boolean }>
|
||||
}
|
||||
}).Segmenter
|
||||
|
||||
if (segmenterCtor) {
|
||||
const segmenter = new segmenterCtor(undefined, { granularity: 'word' })
|
||||
const tokens: string[] = []
|
||||
for (const entry of segmenter.segment(text)) {
|
||||
if (!entry.isWordLike) continue
|
||||
const token = entry.segment.trim().toLowerCase()
|
||||
if (!token) continue
|
||||
tokens.push(token)
|
||||
}
|
||||
if (tokens.length > 0) return tokens
|
||||
}
|
||||
|
||||
return (text.toLowerCase().match(/[a-z0-9][a-z0-9'-]*/g) ?? []).filter((word) => word.length > 1)
|
||||
}
|
||||
|
||||
@@ -95,6 +119,7 @@ export function computeQualitySignals(args: {
|
||||
const templateMarkerHits = TEMPLATE_MARKERS.filter((marker) => bodyLower.includes(marker)).length
|
||||
const summary = (args.summary ?? '').trim().toLowerCase()
|
||||
const genericSummary = /^expert guidance for [a-z0-9-]+\.?$/.test(summary)
|
||||
const cjkChars = (body.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? []).length
|
||||
|
||||
return {
|
||||
bodyChars,
|
||||
@@ -104,6 +129,7 @@ export function computeQualitySignals(args: {
|
||||
bulletCount,
|
||||
templateMarkerHits,
|
||||
genericSummary,
|
||||
cjkChars,
|
||||
structuralFingerprint: toStructuralFingerprint(args.readmeText),
|
||||
}
|
||||
}
|
||||
@@ -127,8 +153,14 @@ export function evaluateQuality(args: {
|
||||
}): QualityAssessment {
|
||||
const { signals, trustTier, similarRecentCount } = args
|
||||
const score = scoreQuality(signals)
|
||||
const rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
|
||||
const rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
|
||||
const cjkHeavy =
|
||||
signals.cjkChars >= 40 || (signals.bodyChars > 0 && signals.cjkChars / signals.bodyChars >= 0.15)
|
||||
let rejectWordsThreshold = trustTier === 'low' ? 45 : trustTier === 'medium' ? 35 : 28
|
||||
let rejectCharsThreshold = trustTier === 'low' ? 260 : trustTier === 'medium' ? 180 : 140
|
||||
if (cjkHeavy) {
|
||||
rejectWordsThreshold = Math.max(24, rejectWordsThreshold - 16)
|
||||
rejectCharsThreshold = Math.max(140, rejectCharsThreshold - 120)
|
||||
}
|
||||
const quarantineScoreThreshold = trustTier === 'low' ? 72 : trustTier === 'medium' ? 60 : 50
|
||||
const similarityRejectThreshold = trustTier === 'low' ? 5 : trustTier === 'medium' ? 8 : 12
|
||||
|
||||
@@ -157,6 +189,7 @@ export function evaluateQuality(args: {
|
||||
bulletCount: signals.bulletCount,
|
||||
templateMarkerHits: signals.templateMarkerHits,
|
||||
genericSummary: signals.genericSummary,
|
||||
cjkChars: signals.cjkChars,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -176,6 +209,7 @@ export function evaluateQuality(args: {
|
||||
bulletCount: signals.bulletCount,
|
||||
templateMarkerHits: signals.templateMarkerHits,
|
||||
genericSummary: signals.genericSummary,
|
||||
cjkChars: signals.cjkChars,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -194,6 +228,7 @@ export function evaluateQuality(args: {
|
||||
bulletCount: signals.bulletCount,
|
||||
templateMarkerHits: signals.templateMarkerHits,
|
||||
genericSummary: signals.genericSummary,
|
||||
cjkChars: signals.cjkChars,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+2
-17
@@ -251,23 +251,8 @@ export const evaluateWithLlm = internalAction({
|
||||
`[llmEval] Evaluated ${skill.slug}@${version.version}: ${result.verdict} (${result.confidence} confidence)`,
|
||||
)
|
||||
|
||||
// 10. Update moderation flags — re-read version to get the sha256hash
|
||||
// that VT may have stored while we were evaluating (both run concurrently).
|
||||
const freshVersion = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: args.versionId,
|
||||
})) as Doc<'skillVersions'> | null
|
||||
|
||||
const sha256hash = freshVersion?.sha256hash ?? version.sha256hash
|
||||
if (sha256hash) {
|
||||
const status = verdictToStatus(result.verdict)
|
||||
if (status === 'malicious' || status === 'suspicious' || status === 'clean') {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'llm',
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Moderation visibility is finalized by VT results.
|
||||
// LLM eval only stores analysis payload on the version.
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -999,6 +999,7 @@ export const applyEmptySkillCleanupInternal = internalMutation({
|
||||
bulletCount: v.number(),
|
||||
templateMarkerHits: v.number(),
|
||||
genericSummary: v.boolean(),
|
||||
cjkChars: v.optional(v.number()),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
+2
-3
@@ -3,8 +3,6 @@ import { defineSchema, defineTable } from 'convex/server'
|
||||
import { v } from 'convex/values'
|
||||
import { EMBEDDING_DIMENSIONS } from './lib/embeddings'
|
||||
|
||||
const authSchema = authTables as unknown as Record<string, ReturnType<typeof defineTable>>
|
||||
|
||||
const users = defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
@@ -96,6 +94,7 @@ const skills = defineTable({
|
||||
bulletCount: v.number(),
|
||||
templateMarkerHits: v.number(),
|
||||
genericSummary: v.boolean(),
|
||||
cjkChars: v.optional(v.number()),
|
||||
}),
|
||||
evaluatedAt: v.number(),
|
||||
}),
|
||||
@@ -546,7 +545,7 @@ const userSkillRootInstalls = defineTable({
|
||||
.index('by_skill', ['skillId'])
|
||||
|
||||
export default defineSchema({
|
||||
...authSchema,
|
||||
...authTables,
|
||||
users,
|
||||
skills,
|
||||
souls,
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('skills anti-spam guards', () => {
|
||||
).rejects.toThrow(/max 5 new skills per hour/i)
|
||||
})
|
||||
|
||||
it('auto-hides suspicious skills from low-trust publishers', async () => {
|
||||
it('keeps suspicious skills visible for low-trust publishers', async () => {
|
||||
const patch = vi.fn(async () => {})
|
||||
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
|
||||
const skill = {
|
||||
@@ -147,7 +147,7 @@ describe('skills anti-spam guards', () => {
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{
|
||||
sha256hash: 'h'.repeat(64),
|
||||
scanner: 'llm',
|
||||
scanner: 'vt',
|
||||
status: 'suspicious',
|
||||
} as never,
|
||||
)
|
||||
@@ -155,8 +155,9 @@ describe('skills anti-spam guards', () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'skills:1',
|
||||
expect.objectContaining({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'scanner.llm.suspicious',
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'scanner.vt.suspicious',
|
||||
moderationFlags: ['flagged.suspicious'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+63
-48
@@ -1,7 +1,6 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { paginationOptsValidator } from 'convex/server'
|
||||
import { ConvexError, v } from 'convex/values'
|
||||
import { paginator } from 'convex-helpers/server/pagination'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
@@ -21,6 +20,10 @@ import {
|
||||
type SkillBadgeMap,
|
||||
} from './lib/badges'
|
||||
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
|
||||
import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
} from './lib/githubIdentity'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
import { deriveModerationFlags } from './lib/moderation'
|
||||
import { toPublicSkill, toPublicUser } from './lib/public'
|
||||
@@ -32,7 +35,6 @@ import {
|
||||
} from './lib/skillPublish'
|
||||
import { isSkillSuspicious } from './lib/skillSafety'
|
||||
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
|
||||
import schema from './schema'
|
||||
|
||||
export { publishVersionForUser } from './lib/skillPublish'
|
||||
|
||||
@@ -1505,10 +1507,9 @@ export const listPublicPage = query({
|
||||
})
|
||||
|
||||
/**
|
||||
* V2 of listPublicPage using convex-helpers paginator for better cache behavior.
|
||||
* V2 of listPublicPage using standard Convex pagination (paginate + usePaginatedQuery).
|
||||
*
|
||||
* Key differences from V1:
|
||||
* - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching)
|
||||
* - Uses `by_active_updated` index to filter soft-deleted skills at query level
|
||||
* - Returns standard pagination shape compatible with usePaginatedQuery
|
||||
*/
|
||||
@@ -1530,15 +1531,15 @@ export const listPublicPageV2 = query({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const sort = args.sort ?? 'newest'
|
||||
const dir = args.dir ?? 'desc'
|
||||
const paginationOpts = {
|
||||
const dir = args.dir ?? (sort === 'name' ? 'asc' : 'desc')
|
||||
const paginationOpts: { cursor: string | null; numItems: number; id?: number } = {
|
||||
...args.paginationOpts,
|
||||
numItems: clampInt(args.paginationOpts.numItems, 1, MAX_PUBLIC_LIST_LIMIT),
|
||||
}
|
||||
|
||||
// Use the index to filter out soft-deleted skills at query time.
|
||||
// softDeletedAt === undefined means active (non-deleted) skills only.
|
||||
const result = await paginator(ctx.db, schema)
|
||||
const result = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
|
||||
.order(dir)
|
||||
@@ -1550,11 +1551,7 @@ export const listPublicPageV2 = query({
|
||||
|
||||
// Build the public skill entries (fetch latestVersion + ownerHandle)
|
||||
const items = await buildPublicSkillEntries(ctx, filteredPage)
|
||||
|
||||
return {
|
||||
...result,
|
||||
page: items,
|
||||
}
|
||||
return { ...result, page: items }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1630,6 +1627,14 @@ export const getVersionById = query({
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getVersionsByIdsInternal = internalQuery({
|
||||
args: { versionIds: v.array(v.id('skillVersions')) },
|
||||
handler: async (ctx, args) => {
|
||||
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
|
||||
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionByIdInternal = internalQuery({
|
||||
args: { versionId: v.id('skillVersions') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
@@ -2332,20 +2337,8 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let shouldHideSuspicious = false
|
||||
if (isSuspicious && !alreadyBlocked && !bypassSuspicious) {
|
||||
if (owner && !owner.deletedAt && !owner.deactivatedAt) {
|
||||
const trustSignals = await getOwnerTrustSignals(ctx, owner, now)
|
||||
shouldHideSuspicious = trustSignals.isLowTrust
|
||||
}
|
||||
}
|
||||
|
||||
const qualityLocked = skill.moderationReason === 'quality.low' && !isMalicious
|
||||
const nextModerationStatus = qualityLocked
|
||||
? 'hidden'
|
||||
: shouldHideSuspicious
|
||||
? 'hidden'
|
||||
: 'active'
|
||||
const nextModerationStatus = qualityLocked ? 'hidden' : 'active'
|
||||
const nextModerationReason = qualityLocked
|
||||
? 'quality.low'
|
||||
: bypassSuspicious
|
||||
@@ -2354,9 +2347,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
const nextModerationNotes = qualityLocked
|
||||
? (skill.moderationNotes ??
|
||||
'Quality gate quarantine is still active. Manual moderation review required.')
|
||||
: shouldHideSuspicious
|
||||
? 'Auto-hidden: suspicious result from low-trust publisher.'
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
moderationStatus: nextModerationStatus,
|
||||
@@ -3016,30 +3007,54 @@ export const insertVersion = internalMutation({
|
||||
bulletCount: v.number(),
|
||||
templateMarkerHits: v.number(),
|
||||
genericSummary: v.boolean(),
|
||||
cjkChars: v.optional(v.number()),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
embedding: v.array(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
|
||||
|
||||
let skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
const now = Date.now()
|
||||
|
||||
let skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.unique()
|
||||
|
||||
if (skill && skill.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
// Fallback: Convex Auth can create duplicate `users` records. Heal ownership ONLY
|
||||
// when the underlying GitHub identity matches (authAccounts.providerAccountId).
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const qualityAssessment = args.qualityAssessment
|
||||
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
|
||||
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
|
||||
const moderationNotes = isQualityQuarantine
|
||||
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
|
||||
getGitHubProviderAccountId(ctx, skill.ownerUserId),
|
||||
getGitHubProviderAccountId(ctx, userId),
|
||||
])
|
||||
|
||||
// Deny healing when GitHub identity isn't present/consistent.
|
||||
if (
|
||||
!canHealSkillOwnershipByGitHubProviderAccountId(
|
||||
ownerProviderAccountId,
|
||||
callerProviderAccountId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
|
||||
}
|
||||
|
||||
const qualityAssessment = args.qualityAssessment
|
||||
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
|
||||
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
|
||||
const moderationNotes = isQualityQuarantine
|
||||
? `Auto-quarantined by quality gate (score=${qualityAssessment.score}, tier=${qualityAssessment.trustTier}, similar=${qualityAssessment.similarRecentCount}).`
|
||||
: undefined
|
||||
const qualityRecord = qualityAssessment
|
||||
@@ -3050,13 +3065,13 @@ export const insertVersion = internalMutation({
|
||||
similarRecentCount: qualityAssessment.similarRecentCount,
|
||||
reason: qualityAssessment.reason,
|
||||
signals: qualityAssessment.signals,
|
||||
evaluatedAt: now,
|
||||
}
|
||||
: undefined
|
||||
evaluatedAt: now,
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (!skill) {
|
||||
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
|
||||
enforceNewSkillRateLimit(ownerTrustSignals)
|
||||
if (!skill) {
|
||||
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
|
||||
enforceNewSkillRateLimit(ownerTrustSignals)
|
||||
|
||||
const forkOfSlug = args.forkOf?.slug.trim().toLowerCase() || ''
|
||||
const forkOfVersion = args.forkOf?.version?.trim() || undefined
|
||||
|
||||
@@ -145,6 +145,14 @@ export const getVersionById = query({
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getVersionsByIdsInternal = internalQuery({
|
||||
args: { versionIds: v.array(v.id('soulVersions')) },
|
||||
handler: async (ctx, args) => {
|
||||
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
|
||||
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionByIdInternal = internalQuery({
|
||||
args: { versionId: v.id('soulVersions') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
|
||||
+21
-3
@@ -1,7 +1,25 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
/* This TypeScript project config describes the environment that
|
||||
* Convex functions run in and is used to typecheck them.
|
||||
* You can modify it, but some settings are required to use Convex.
|
||||
*/
|
||||
"compilerOptions": {
|
||||
/* These settings are not required by Convex and can be modified. */
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
/* These compiler options are required by Convex */
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2022", "dom", "dom.iterable"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*"],
|
||||
"exclude": ["./_generated"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./lib/access', async () => {
|
||||
const actual = await vi.importActual<typeof import('./lib/access')>('./lib/access')
|
||||
return { ...actual, requireUser: vi.fn() }
|
||||
})
|
||||
|
||||
const { requireUser } = await import('./lib/access')
|
||||
const { ensureHandler } = await import('./users')
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn()
|
||||
const get = vi.fn()
|
||||
return { ctx: { db: { patch, get } } as never, patch, get }
|
||||
}
|
||||
|
||||
describe('ensureHandler', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset()
|
||||
})
|
||||
|
||||
it('updates handle and display name when GitHub login changes', async () => {
|
||||
const { ctx, patch } = makeCtx()
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: 'old-handle',
|
||||
displayName: 'old-handle',
|
||||
name: 'new-handle',
|
||||
email: 'old@example.com',
|
||||
role: 'user',
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await ensureHandler(ctx)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('users:1', {
|
||||
handle: 'new-handle',
|
||||
displayName: 'new-handle',
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not override a custom display name when syncing handle', async () => {
|
||||
const { ctx, patch } = makeCtx()
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:2',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: 'old-handle',
|
||||
displayName: 'Custom Name',
|
||||
name: 'new-handle',
|
||||
role: 'user',
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await ensureHandler(ctx)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('users:2', {
|
||||
handle: 'new-handle',
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('fills display name from existing handle when missing', async () => {
|
||||
const { ctx, patch } = makeCtx()
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: 'users:3',
|
||||
user: {
|
||||
_creationTime: 1,
|
||||
handle: 'steady-handle',
|
||||
displayName: undefined,
|
||||
name: undefined,
|
||||
email: undefined,
|
||||
role: 'user',
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never)
|
||||
|
||||
await ensureHandler(ctx)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith('users:3', {
|
||||
displayName: 'steady-handle',
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
})
|
||||
+132
-20
@@ -21,6 +21,17 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
})
|
||||
|
||||
export const getGitHubProviderAccountIdInternal = internalQuery({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, args) => {
|
||||
const account = await ctx.db
|
||||
.query('authAccounts')
|
||||
.withIndex('userIdAndProvider', (q) => q.eq('userId', args.userId).eq('provider', 'github'))
|
||||
.unique()
|
||||
return account?.providerAccountId ?? null
|
||||
},
|
||||
})
|
||||
|
||||
export const searchInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
@@ -45,7 +56,6 @@ export const searchInternal = internalQuery({
|
||||
return { items, total: result.total }
|
||||
},
|
||||
})
|
||||
|
||||
export const updateGithubMetaInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
@@ -74,27 +84,65 @@ export const me = query({
|
||||
|
||||
export const ensure = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const handle = user.handle || user.name || user.email?.split('@')[0]
|
||||
if (!user.handle && handle) updates.handle = handle
|
||||
if (!user.displayName) updates.displayName = handle
|
||||
if (!user.role) {
|
||||
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now()
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
return ctx.db.get(userId)
|
||||
},
|
||||
handler: ensureHandler,
|
||||
})
|
||||
|
||||
function normalizeHandle(handle: string | undefined) {
|
||||
const normalized = handle?.trim()
|
||||
return normalized ? normalized : undefined
|
||||
}
|
||||
|
||||
function deriveHandle(args: { existingHandle?: string; githubLogin?: string; email?: string }) {
|
||||
// Prefer the GitHub login; only fall back to email-derived handle when we don't already have one.
|
||||
if (args.githubLogin) return args.githubLogin
|
||||
if (!args.existingHandle && args.email) return args.email.split('@')[0]?.trim() || undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
function computeEnsureUpdates(user: Doc<'users'>) {
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const existingHandle = normalizeHandle(user.handle)
|
||||
const githubLogin = normalizeHandle(user.name)
|
||||
const derivedHandle = deriveHandle({
|
||||
existingHandle,
|
||||
githubLogin,
|
||||
email: user.email,
|
||||
})
|
||||
const baseHandle = derivedHandle ?? existingHandle
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
updates.handle = derivedHandle
|
||||
}
|
||||
|
||||
const displayName = normalizeHandle(user.displayName)
|
||||
if (!displayName && baseHandle) {
|
||||
updates.displayName = baseHandle
|
||||
} else if (derivedHandle && displayName === existingHandle) {
|
||||
updates.displayName = derivedHandle
|
||||
}
|
||||
|
||||
if (!user.role) {
|
||||
updates.role = baseHandle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export async function ensureHandler(ctx: MutationCtx) {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const updates = computeEnsureUpdates(user)
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now()
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
return ctx.db.get(userId)
|
||||
}
|
||||
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
displayName: v.string(),
|
||||
@@ -241,6 +289,27 @@ export const banUserInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const unbanUser = mutation({
|
||||
args: { userId: v.id('users'), reason: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
return unbanUserWithActor(ctx, user, args.userId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
export const unbanUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
targetUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
|
||||
return unbanUserWithActor(ctx, actor, args.targetUserId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
async function banUserWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
@@ -307,6 +376,49 @@ async function banUserWithActor(
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
|
||||
}
|
||||
|
||||
async function unbanUserWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
targetUserId: Id<'users'>,
|
||||
reasonRaw?: string,
|
||||
) {
|
||||
assertAdmin(actor)
|
||||
if (targetUserId === actor._id) throw new Error('Cannot unban yourself')
|
||||
|
||||
const target = await ctx.db.get(targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
if (target.deactivatedAt) {
|
||||
throw new Error('Cannot unban a permanently deleted account')
|
||||
}
|
||||
if (!target.deletedAt) {
|
||||
return { ok: true as const, alreadyUnbanned: true }
|
||||
}
|
||||
|
||||
const reason = reasonRaw?.trim()
|
||||
if (reason && reason.length > 500) {
|
||||
throw new Error('Reason too long (max 500 chars)')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(targetUserId, {
|
||||
deletedAt: undefined,
|
||||
banReason: undefined,
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'user.unban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { reason: reason || undefined },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyUnbanned: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by VT.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
|
||||
+12
-17
@@ -375,8 +375,6 @@ export const scanWithVirusTotal = internalAction({
|
||||
// File exists and has AI analysis - use the verdict
|
||||
const verdict = normalizeVerdict(aiResult.verdict)
|
||||
const status = verdictToStatus(verdict)
|
||||
const isSafe = status === 'clean'
|
||||
|
||||
console.log(
|
||||
`Version ${args.versionId} found in VT with AI analysis. Hash: ${sha256hash}. Verdict: ${verdict}`,
|
||||
)
|
||||
@@ -393,14 +391,12 @@ export const scanWithVirusTotal = internalAction({
|
||||
},
|
||||
})
|
||||
|
||||
// VT is supplementary — only escalate (never override LLM verdict)
|
||||
if (!isSafe && (status === 'malicious' || status === 'suspicious')) {
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
status,
|
||||
})
|
||||
}
|
||||
// Clean VT result: vtAnalysis already written above — don't touch moderation
|
||||
// VT finalizes moderation visibility for newly published versions.
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -578,13 +574,12 @@ export const pollPendingScans = internalAction({
|
||||
},
|
||||
})
|
||||
|
||||
// VT is supplementary — only escalate for malicious/suspicious
|
||||
if (status === 'malicious' || status === 'suspicious') {
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
status,
|
||||
})
|
||||
}
|
||||
// VT finalizes moderation visibility for newly published versions.
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
updated++
|
||||
} catch (error) {
|
||||
console.error(`[vt:pollPendingScans] Error checking hash ${sha256hash}:`, error)
|
||||
|
||||
+12
-3
@@ -38,6 +38,8 @@ read_when:
|
||||
- hard-deletes all owned skills
|
||||
- revokes API tokens
|
||||
- sets `deletedAt` on the user
|
||||
- Admins can manually unban (`deletedAt` + `banReason` cleared); revoked API tokens
|
||||
stay revoked and should be recreated by the user.
|
||||
- Optional ban reason is stored in `users.banReason` and audit logs.
|
||||
- Moderators cannot ban admins; nobody can ban themselves.
|
||||
- Report counters effectively reset because deleted/banned skills are no longer
|
||||
@@ -57,12 +59,19 @@ read_when:
|
||||
## Upload gate (GitHub account age)
|
||||
|
||||
- Skill + soul publish actions require GitHub account age ≥ 7 days.
|
||||
- Lookup uses GitHub `created_at` and caches on the user:
|
||||
- Lookup uses GitHub `created_at` fetched by the immutable GitHub numeric ID (`providerAccountId`)
|
||||
and caches on the user:
|
||||
- `githubCreatedAt` (source of truth)
|
||||
- `githubFetchedAt` (fetch timestamp)
|
||||
- Cache TTL: 24 hours.
|
||||
- `githubFetchedAt` (fetch timestamp; set when `githubCreatedAt` is populated)
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
- If GitHub responds `403` or `429`, publish fails with:
|
||||
- `GitHub API rate limit exceeded — please try again in a few minutes`
|
||||
- To reduce rate-limit failures, set `GITHUB_TOKEN` in Convex env for authenticated
|
||||
GitHub API requests.
|
||||
|
||||
## Empty-skill cleanup (backfill)
|
||||
|
||||
- Cleanup uses quality heuristics plus trust tier to identify very thin/templated
|
||||
skills.
|
||||
- Word counting is language-aware (`Intl.Segmenter` with fallback), reducing
|
||||
false positives for non-space-separated languages.
|
||||
|
||||
@@ -60,7 +60,7 @@ async function makeTempConfig(registry: string, token: string | null) {
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal })
|
||||
} finally {
|
||||
@@ -504,4 +504,49 @@ describe('clawhub e2e', () => {
|
||||
await rm(cfg.dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
|
||||
it('delete returns proper error for non-existent skill', async () => {
|
||||
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
|
||||
const site = process.env.CLAWDHUB_SITE?.trim() || 'https://clawdhub.com'
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
|
||||
if (!token) {
|
||||
throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login')
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token)
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-delete-'))
|
||||
const nonExistentSlug = `non-existent-skill-${Date.now()}`
|
||||
|
||||
try {
|
||||
const del = spawnSync(
|
||||
'bun',
|
||||
[
|
||||
'clawdhub',
|
||||
'delete',
|
||||
nonExistentSlug,
|
||||
'--yes',
|
||||
'--site',
|
||||
site,
|
||||
'--registry',
|
||||
registry,
|
||||
'--workdir',
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: '1' },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
// Should fail with non-zero exit code
|
||||
expect(del.status).not.toBe(0)
|
||||
// Error should mention "not found" - not generic "Unauthorized"
|
||||
const output = (del.stdout + del.stderr).toLowerCase()
|
||||
expect(output).toMatch(/not found|404|does not exist/i)
|
||||
expect(output).not.toMatch(/unauthorized/i)
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true })
|
||||
await rm(cfg.dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readGlobalConfig } from '../config.js'
|
||||
import { fail } from './ui.js'
|
||||
|
||||
export async function getOptionalAuthToken(): Promise<string | undefined> {
|
||||
const cfg = await readGlobalConfig()
|
||||
return cfg?.token ?? undefined
|
||||
}
|
||||
|
||||
export async function requireAuthToken(): Promise<string> {
|
||||
const token = await getOptionalAuthToken()
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
|
||||
import { discoverRegistryFromSite } from '../../discovery.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1WhoamiResponseSchema } from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
|
||||
@@ -78,9 +79,7 @@ export async function cmdLogout(opts: GlobalOpts) {
|
||||
}
|
||||
|
||||
export async function cmdWhoami(opts: GlobalOpts) {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
|
||||
const spinner = createSpinner('Checking token')
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
vi.mock('../authToken.js', () => ({
|
||||
requireAuthToken: vi.fn(async () => 'tkn'),
|
||||
}))
|
||||
|
||||
vi.mock('../registry.js', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1DeleteResponseSchema, parseArk } from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
@@ -40,13 +40,6 @@ const unhideLabels: SkillActionLabels = {
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdDeleteSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
@@ -64,7 +57,7 @@ export async function cmdDeleteSkill(
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
@@ -98,7 +91,7 @@ export async function cmdUndeleteSkill(
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined)
|
||||
vi.mock('../authToken.js', () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
} from '../../schema/index.js'
|
||||
import { getOptionalAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError } from '../ui.js'
|
||||
@@ -31,12 +32,13 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
if (!trimmed) fail('Slug required')
|
||||
if (options.version && options.tag) fail('Use either --version or --tag')
|
||||
|
||||
const token = await getOptionalAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching skill')
|
||||
try {
|
||||
const skillResult = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
@@ -67,6 +69,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
|
||||
targetVersion,
|
||||
)}`,
|
||||
token,
|
||||
},
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
)
|
||||
@@ -80,7 +83,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
spinner.text = `Fetching versions (${limit})`
|
||||
versionsList = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
)
|
||||
}
|
||||
@@ -97,7 +100,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
|
||||
url.searchParams.set('version', latestVersion)
|
||||
}
|
||||
spinner.text = `Fetching ${options.file}`
|
||||
fileContent = await fetchText(registry, { url: url.toString() })
|
||||
fileContent = await fetchText(registry, { url: url.toString(), token })
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
vi.mock('../authToken.js', () => ({
|
||||
requireAuthToken: vi.fn(async () => 'tkn'),
|
||||
}))
|
||||
|
||||
vi.mock('../registry.js', () => ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isCancel, select } from '@clack/prompts'
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
@@ -8,17 +7,11 @@ import {
|
||||
ApiV1UserSearchResponseSchema,
|
||||
parseArk,
|
||||
} from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdBanUser(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
@@ -30,7 +23,7 @@ export async function cmdBanUser(
|
||||
|
||||
const reason = options.reason?.trim() || undefined
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const resolved = await resolveUserIdentifier(
|
||||
@@ -87,7 +80,7 @@ export async function cmdSetRole(
|
||||
if (!raw) fail('Handle or user id required')
|
||||
const role = normalizeRole(roleArg)
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const resolved = await resolveUserIdentifier(
|
||||
|
||||
@@ -6,8 +6,8 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
vi.mock('../authToken.js', () => ({
|
||||
requireAuthToken: vi.fn(async () => 'tkn'),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async (_opts: unknown, _params?: unknown) => 'https://clawhub.ai')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import semver from 'semver'
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequestForm } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1PublishResponseSchema } from '../../schema/index.js'
|
||||
import { listTextFiles } from '../../skills.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import { sanitizeSlug, titleCase } from '../slug.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
@@ -27,9 +27,7 @@ export async function cmdPublish(
|
||||
const folderStat = await stat(folder).catch(() => null)
|
||||
if (!folderStat || !folderStat.isDirectory()) fail('Path must be a folder')
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
|
||||
const slug = options.slug ?? sanitizeSlug(basename(folder))
|
||||
|
||||
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined)
|
||||
vi.mock('../authToken.js', () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
@@ -50,7 +55,7 @@ vi.mock('node:fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
@@ -189,3 +194,29 @@ describe('cmdUpdate', () => {
|
||||
expect(args?.url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdInstall', () => {
|
||||
it('passes optional auth token to API + download requests', async () => {
|
||||
mockGetOptionalAuthToken.mockResolvedValue('tkn')
|
||||
mockApiRequest.mockResolvedValue({
|
||||
skill: { slug: 'demo', displayName: 'Demo', summary: null, tags: {}, stats: {}, createdAt: 0, updatedAt: 0 },
|
||||
latestVersion: { version: '1.0.0' },
|
||||
owner: null,
|
||||
moderation: null,
|
||||
})
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
|
||||
vi.mocked(writeLockfile).mockResolvedValue()
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue()
|
||||
vi.mocked(extractZipToDir).mockResolvedValue()
|
||||
vi.mocked(stat).mockRejectedValue(new Error('missing'))
|
||||
vi.mocked(rm).mockResolvedValue()
|
||||
|
||||
await cmdInstall(makeOpts(), 'demo')
|
||||
|
||||
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
|
||||
expect(requestArgs?.token).toBe('tkn')
|
||||
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? []
|
||||
expect(zipArgs?.token).toBe('tkn')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts, ResolveResult } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
import { getOptionalAuthToken } from '../authToken.js'
|
||||
|
||||
export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) {
|
||||
if (!query) fail('Query required')
|
||||
@@ -61,6 +62,8 @@ export async function cmdInstall(
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) fail('Slug required')
|
||||
|
||||
const token = await getOptionalAuthToken()
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
await mkdir(opts.dir, { recursive: true })
|
||||
const target = join(opts.dir, trimmed)
|
||||
@@ -76,7 +79,7 @@ export async function cmdInstall(
|
||||
// Fetch skill metadata including moderation status
|
||||
const skillMeta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
@@ -106,7 +109,7 @@ export async function cmdInstall(
|
||||
if (!resolvedVersion) fail('Could not resolve latest version')
|
||||
|
||||
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`
|
||||
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion })
|
||||
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token })
|
||||
await extractZipToDir(zip, target)
|
||||
|
||||
await writeSkillOrigin(target, {
|
||||
@@ -144,6 +147,8 @@ export async function cmdUpdate(
|
||||
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
|
||||
const token = await getOptionalAuthToken()
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const lock = await readLockfile(opts.workdir)
|
||||
const slugs = slug ? [slug] : Object.keys(lock.skills)
|
||||
@@ -161,7 +166,7 @@ export async function cmdUpdate(
|
||||
// Always fetch skill metadata to check moderation status
|
||||
const skillMeta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
@@ -202,7 +207,7 @@ export async function cmdUpdate(
|
||||
|
||||
let resolveResult: ResolveResult
|
||||
if (localFingerprint) {
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token)
|
||||
} else {
|
||||
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
|
||||
}
|
||||
@@ -255,7 +260,7 @@ export async function cmdUpdate(
|
||||
spinner.start(`Updating ${entry} -> ${targetVersion}`)
|
||||
}
|
||||
await rm(target, { recursive: true, force: true })
|
||||
const zip = await downloadZip(registry, { slug: entry, version: targetVersion })
|
||||
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token })
|
||||
await extractZipToDir(zip, target)
|
||||
|
||||
const existingOrigin = await readSkillOrigin(target)
|
||||
@@ -407,13 +412,13 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
|
||||
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
|
||||
const url = new URL(ApiRoutes.resolve, registry)
|
||||
url.searchParams.set('slug', slug)
|
||||
url.searchParams.set('hash', hash)
|
||||
return apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
{ method: 'GET', url: url.toString(), token },
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1StarResponseSchema } from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdStarSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
@@ -28,7 +21,7 @@ export async function cmdStarSkill(
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Starring ${slug}`)
|
||||
try {
|
||||
|
||||
@@ -26,8 +26,8 @@ vi.mock('@clack/prompts', () => ({
|
||||
isCancel: () => false,
|
||||
}))
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
vi.mock('../authToken.js', () => ({
|
||||
requireAuthToken: vi.fn(async () => 'tkn'),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { intro, outro } from '@clack/prompts'
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { hashSkillFiles, listTextFiles, readSkillOrigin } from '../../skills.js'
|
||||
import { resolveClawdbotSkillRoots } from '../clawdbotConfig.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getFallbackSkillRoots } from '../scanSkills.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
|
||||
@@ -32,9 +32,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
intro('ClawHub sync')
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
const token = await requireAuthToken()
|
||||
|
||||
const registry = await getRegistryWithAuth(opts, token)
|
||||
const selectedRoots = buildScanRoots(opts, options.root)
|
||||
@@ -109,7 +107,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
let done = 0
|
||||
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
|
||||
try {
|
||||
return await checkRegistrySyncState(registry, skill, resolveSupport)
|
||||
return await checkRegistrySyncState(registry, skill, resolveSupport, token)
|
||||
} finally {
|
||||
done += 1
|
||||
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function checkRegistrySyncState(
|
||||
registry: string,
|
||||
skill: LocalSkill,
|
||||
resolveSupport: { value: boolean | null },
|
||||
token?: string,
|
||||
): Promise<Candidate> {
|
||||
if (resolveSupport.value !== false) {
|
||||
try {
|
||||
@@ -108,6 +109,7 @@ export async function checkRegistrySyncState(
|
||||
{
|
||||
method: 'GET',
|
||||
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
|
||||
token,
|
||||
},
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
)
|
||||
@@ -149,7 +151,7 @@ export async function checkRegistrySyncState(
|
||||
|
||||
const meta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}`, token },
|
||||
ApiV1SkillResponseSchema,
|
||||
).catch(() => null)
|
||||
|
||||
@@ -163,7 +165,7 @@ export async function checkRegistrySyncState(
|
||||
}
|
||||
}
|
||||
|
||||
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
|
||||
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion, token })
|
||||
const remote = hashSkillZip(zip).fingerprint
|
||||
const matchVersion = remote === skill.fingerprint ? latestVersion : null
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1UnstarResponseSchema } from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdUnstarSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
@@ -28,7 +21,7 @@ export async function cmdUnstarSkill(
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Unstarring ${slug}`)
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apiRequest, apiRequestForm, downloadZip } from './http'
|
||||
import { apiRequest, apiRequestForm, downloadZip, fetchText } from './http'
|
||||
import { ApiV1WhoamiResponseSchema } from './schema/index.js'
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void) => {
|
||||
callback()
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>
|
||||
})
|
||||
const clearTimeoutMock = vi.fn()
|
||||
vi.stubGlobal('setTimeout', setTimeoutMock as unknown as typeof setTimeout)
|
||||
vi.stubGlobal('clearTimeout', clearTimeoutMock as typeof clearTimeout)
|
||||
return { setTimeoutMock, clearTimeoutMock }
|
||||
}
|
||||
|
||||
function createAbortingFetchMock() {
|
||||
return vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal
|
||||
if (!signal || !(signal instanceof AbortSignal)) {
|
||||
throw new Error('Missing abort signal')
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw signal.reason
|
||||
}
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('adds bearer token and parses json', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
@@ -73,11 +105,16 @@ describe('apiRequest', () => {
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const bytes = await downloadZip('https://example.com', { slug: 'demo', version: '1.0.0' })
|
||||
const bytes = await downloadZip('https://example.com', {
|
||||
slug: 'demo',
|
||||
version: '1.0.0',
|
||||
token: 'clh_token',
|
||||
})
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3])
|
||||
const [url] = fetchMock.mock.calls[0] as [string]
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('slug=demo')
|
||||
expect(url).toContain('version=1.0.0')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -92,6 +129,25 @@ describe('apiRequest', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('aborts with Error timeouts and retries', async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts()
|
||||
const fetchMock = createAbortingFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await apiRequest('https://example.com', { method: 'GET', path: '/x' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
expect((caught as Error).message).toBe('Timeout')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiRequestForm', () => {
|
||||
@@ -154,3 +210,24 @@ describe('apiRequestForm', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchText', () => {
|
||||
it('aborts with Error timeouts and retries', async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts()
|
||||
const fetchMock = createAbortingFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await fetchText('https://example.com', { path: '/x' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
expect((caught as Error).message).toBe('Timeout')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,22 +52,13 @@ export async function apiRequest<T>(
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(args.body ?? {})
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
const message = text || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response))
|
||||
}
|
||||
return (await response.json()) as unknown
|
||||
},
|
||||
@@ -101,22 +92,13 @@ export async function apiRequestForm<T>(
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body: args.form,
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
const message = text || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response))
|
||||
}
|
||||
return (await response.json()) as unknown
|
||||
},
|
||||
@@ -138,17 +120,10 @@ export async function fetchText(registry: string, args: TextRequestArgs): Promis
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'text/plain' }
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
const response = await fetchWithTimeout(url, { method: 'GET', headers })
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
const message = text || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
throwHttpStatusError(response.status, text)
|
||||
}
|
||||
return text
|
||||
},
|
||||
@@ -156,26 +131,25 @@ export async function fetchText(registry: string, args: TextRequestArgs): Promis
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
|
||||
export async function downloadZip(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
) {
|
||||
const url = new URL(ApiRoutes.download, registry)
|
||||
url.searchParams.set('slug', args.slug)
|
||||
if (args.version) url.searchParams.set('version', args.version)
|
||||
return pRetry(
|
||||
async () => {
|
||||
if (isBun) {
|
||||
return await fetchBinaryViaCurl(url.toString())
|
||||
return await fetchBinaryViaCurl(url.toString(), args.token)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
const headers: Record<string, string> = {}
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`
|
||||
|
||||
const response = await fetchWithTimeout(url.toString(), { method: 'GET', headers })
|
||||
if (!response.ok) {
|
||||
const message = (await response.text().catch(() => '')) || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response))
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
},
|
||||
@@ -183,6 +157,28 @@ export async function downloadZip(registry: string, args: { slug: string; versio
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponseTextSafe(response: Response): Promise<string> {
|
||||
return await response.text().catch(() => '')
|
||||
}
|
||||
|
||||
function throwHttpStatusError(status: number, text: string): never {
|
||||
const message = text || `HTTP ${status}`
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
}
|
||||
|
||||
async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
const headers = ['-H', 'Accept: application/json']
|
||||
if (args.token) {
|
||||
@@ -217,10 +213,7 @@ async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
const status = Number(output.slice(splitAt + 1).trim())
|
||||
if (!Number.isFinite(status)) throw new Error('curl response missing status')
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(body || `HTTP ${status}`)
|
||||
}
|
||||
throw new AbortError(body || `HTTP ${status}`)
|
||||
throwHttpStatusError(status, body)
|
||||
}
|
||||
return JSON.parse(body || 'null') as unknown
|
||||
}
|
||||
@@ -272,10 +265,7 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
const status = Number(output.slice(splitAt + 1).trim())
|
||||
if (!Number.isFinite(status)) throw new Error('curl response missing status')
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(body || `HTTP ${status}`)
|
||||
}
|
||||
throw new AbortError(body || `HTTP ${status}`)
|
||||
throwHttpStatusError(status, body)
|
||||
}
|
||||
return JSON.parse(body || 'null') as unknown
|
||||
} finally {
|
||||
@@ -320,16 +310,22 @@ async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchBinaryViaCurl(url: string) {
|
||||
async function fetchBinaryViaCurl(url: string, token?: string) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-'))
|
||||
const filePath = join(tempDir, 'payload.bin')
|
||||
try {
|
||||
const headers: string[] = []
|
||||
if (token) {
|
||||
headers.push('-H', `Authorization: Bearer ${token}`)
|
||||
}
|
||||
|
||||
const curlArgs = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--max-time',
|
||||
String(REQUEST_TIMEOUT_SECONDS),
|
||||
...headers,
|
||||
'-o',
|
||||
filePath,
|
||||
'--write-out',
|
||||
@@ -344,11 +340,7 @@ async function fetchBinaryViaCurl(url: string) {
|
||||
if (!Number.isFinite(status)) throw new Error('curl response missing status')
|
||||
if (status < 200 || status >= 300) {
|
||||
const body = await readFileSafe(filePath)
|
||||
const message = body ? new TextDecoder().decode(body) : `HTTP ${status}`
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
throwHttpStatusError(status, body ? new TextDecoder().decode(body) : '')
|
||||
}
|
||||
const bytes = await readFileSafe(filePath)
|
||||
return bytes ? new Uint8Array(bytes) : new Uint8Array()
|
||||
|
||||
@@ -20,15 +20,18 @@ import {
|
||||
|
||||
describe('skills', () => {
|
||||
it('extracts zip into directory and skips traversal', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'clawhub-'))
|
||||
const parent = await mkdtemp(join(tmpdir(), 'clawhub-zip-'))
|
||||
const dir = join(parent, 'dir')
|
||||
await mkdir(dir)
|
||||
const evilName = `evil-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`
|
||||
const zip = zipSync({
|
||||
'SKILL.md': strToU8('hello'),
|
||||
'../evil.txt': strToU8('nope'),
|
||||
[`../${evilName}`]: strToU8('nope'),
|
||||
})
|
||||
await extractZipToDir(new Uint8Array(zip), dir)
|
||||
|
||||
expect((await readFile(join(dir, 'SKILL.md'), 'utf8')).trim()).toBe('hello')
|
||||
await expect(stat(join(dir, '..', 'evil.txt'))).rejects.toBeTruthy()
|
||||
await expect(stat(join(parent, evilName))).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('writes and reads lockfile', async () => {
|
||||
|
||||
@@ -21,9 +21,6 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('convex-helpers/react', () => ({
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
}))
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('convex-helpers/react', () => ({
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
}))
|
||||
|
||||
@@ -123,6 +120,32 @@ describe('SkillsIndex', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts search results by stars and breaks ties by updatedAt', async () => {
|
||||
searchMock = { q: 'remind', sort: 'stars', dir: 'desc' }
|
||||
const actionFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
makeSearchEntry({ slug: 'skill-a', displayName: 'Skill A', stars: 5, updatedAt: 100 }),
|
||||
makeSearchEntry({ slug: 'skill-b', displayName: 'Skill B', stars: 5, updatedAt: 200 }),
|
||||
makeSearchEntry({ slug: 'skill-c', displayName: 'Skill C', stars: 4, updatedAt: 999 }),
|
||||
])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[0]?.textContent).toContain('Skill B')
|
||||
expect(links[1]?.textContent).toContain('Skill A')
|
||||
expect(links[2]?.textContent).toContain('Skill C')
|
||||
})
|
||||
|
||||
it('uses relevance as default sort when searching', async () => {
|
||||
searchMock = { q: 'notion' }
|
||||
const actionFn = vi
|
||||
@@ -206,3 +229,32 @@ function makeSearchResult(slug: string, displayName: string, score: number, crea
|
||||
version: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSearchEntry(params: {
|
||||
slug: string
|
||||
displayName: string
|
||||
stars: number
|
||||
updatedAt: number
|
||||
}) {
|
||||
return {
|
||||
score: 0.9,
|
||||
skill: {
|
||||
_id: `skill_${params.slug}`,
|
||||
slug: params.slug,
|
||||
displayName: params.displayName,
|
||||
summary: `Summary ${params.slug}`,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: params.stars,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: params.updatedAt,
|
||||
},
|
||||
version: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ function applyMonacoTheme(monaco: NonNullable<ReturnType<typeof useMonaco>>) {
|
||||
const ink = styles.getPropertyValue('--ink').trim() || '#1d1a17'
|
||||
const inkSoft = styles.getPropertyValue('--ink-soft').trim() || '#4c463f'
|
||||
const line = styles.getPropertyValue('--line').trim() || 'rgba(29, 26, 23, 0.12)'
|
||||
const accent = styles.getPropertyValue('--accent').trim() || '#4f9dff'
|
||||
const accent = styles.getPropertyValue('--accent').trim() || '#e65c46'
|
||||
const seafoam = styles.getPropertyValue('--seafoam').trim() || '#2bc6a4'
|
||||
const diffAdded = styles.getPropertyValue('--diff-added').trim() || seafoam
|
||||
const diffRemoved = styles.getPropertyValue('--diff-removed').trim() || accent
|
||||
|
||||
@@ -34,7 +34,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:rgba(93,167,255,0.12)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -24,7 +24,7 @@ const ToggleGroupItem = React.forwardRef<
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:rgba(93,167,255,0.4)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white',
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
||||
import { useAction } from 'convex/react'
|
||||
import { usePaginatedQuery } from 'convex-helpers/react'
|
||||
import { useAction, usePaginatedQuery } from 'convex/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
import type { Doc } from '../../../convex/_generated/dataModel'
|
||||
@@ -136,7 +135,6 @@ export function SkillsIndex() {
|
||||
? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}::${nonSuspiciousOnly ? '1' : '0'}`
|
||||
: ''
|
||||
|
||||
// Use convex-helpers usePaginatedQuery for better cache behavior
|
||||
const {
|
||||
results: paginatedResults,
|
||||
status: paginationStatus,
|
||||
@@ -236,34 +234,48 @@ export function SkillsIndex() {
|
||||
)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!hasQuery) {
|
||||
return filtered
|
||||
}
|
||||
const multiplier = dir === 'asc' ? 1 : -1
|
||||
const results = [...filtered]
|
||||
results.sort((a, b) => {
|
||||
const tieBreak = () => {
|
||||
const updated = (a.skill.updatedAt - b.skill.updatedAt) * multiplier
|
||||
if (updated !== 0) return updated
|
||||
return a.skill.slug.localeCompare(b.skill.slug)
|
||||
}
|
||||
switch (sort) {
|
||||
case 'relevance':
|
||||
return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier
|
||||
case 'downloads':
|
||||
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
|
||||
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak()
|
||||
case 'installs':
|
||||
return (
|
||||
((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) *
|
||||
multiplier
|
||||
multiplier || tieBreak()
|
||||
)
|
||||
case 'stars':
|
||||
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier
|
||||
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak()
|
||||
case 'updated':
|
||||
return (a.skill.updatedAt - b.skill.updatedAt) * multiplier
|
||||
return (
|
||||
(a.skill.updatedAt - b.skill.updatedAt) * multiplier ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)
|
||||
)
|
||||
case 'name':
|
||||
return (
|
||||
(a.skill.displayName.localeCompare(b.skill.displayName) ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)) * multiplier
|
||||
)
|
||||
default:
|
||||
return (a.skill.createdAt - b.skill.createdAt) * multiplier
|
||||
return (
|
||||
(a.skill.createdAt - b.skill.createdAt) * multiplier ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)
|
||||
)
|
||||
}
|
||||
})
|
||||
return results
|
||||
}, [dir, filtered, sort])
|
||||
}, [dir, filtered, hasQuery, sort])
|
||||
|
||||
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
|
||||
const canLoadMore = hasQuery
|
||||
|
||||
+20
-20
@@ -5,24 +5,24 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f2f7f9;
|
||||
--bg-soft: #f7fcfd;
|
||||
--bg: #f8f2ed;
|
||||
--bg-soft: #fdf7f2;
|
||||
--bg-glow-1: #ffe1d4;
|
||||
--bg-glow-2: #d8f0f2;
|
||||
--bg-glow-2: #ffe8d8;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f0f8fa;
|
||||
--nav-bg: rgba(242, 247, 249, 0.88);
|
||||
--ink: #14242e;
|
||||
--ink-soft: #4b6677;
|
||||
--surface-muted: #f7efe9;
|
||||
--nav-bg: rgba(248, 242, 237, 0.88);
|
||||
--ink: #2a1f19;
|
||||
--ink-soft: #6b5549;
|
||||
--accent: #e65c46;
|
||||
--accent-deep: #bf3f30;
|
||||
--seafoam: #209e92;
|
||||
--gold: #e7bb67;
|
||||
--line: rgba(20, 36, 46, 0.14);
|
||||
--line: rgba(42, 31, 25, 0.14);
|
||||
--border-ui: rgba(191, 63, 48, 0.28);
|
||||
--border-ui-hover: rgba(191, 63, 48, 0.42);
|
||||
--border-ui-active: rgba(191, 63, 48, 0.62);
|
||||
--shadow: 0 22px 52px rgba(16, 34, 44, 0.11);
|
||||
--shadow: 0 22px 52px rgba(44, 28, 20, 0.11);
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
--radius-sm: 9px;
|
||||
@@ -36,20 +36,20 @@
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1b24;
|
||||
--bg-soft: #142632;
|
||||
--bg: #14100d;
|
||||
--bg-soft: #1d1713;
|
||||
--bg-glow-1: #4b211b;
|
||||
--bg-glow-2: #11303b;
|
||||
--surface: #1a2d39;
|
||||
--surface-muted: #213744;
|
||||
--nav-bg: rgba(13, 27, 36, 0.88);
|
||||
--ink: #edf6f9;
|
||||
--ink-soft: #acc2cf;
|
||||
--bg-glow-2: #3a2018;
|
||||
--surface: #241b16;
|
||||
--surface-muted: #2f241d;
|
||||
--nav-bg: rgba(20, 16, 13, 0.88);
|
||||
--ink: #f7eee8;
|
||||
--ink-soft: #c8b3a6;
|
||||
--accent: #ff7357;
|
||||
--accent-deep: #e25640;
|
||||
--seafoam: #47c3b8;
|
||||
--gold: #f3c97a;
|
||||
--line: rgba(232, 243, 249, 0.16);
|
||||
--line: rgba(247, 235, 225, 0.16);
|
||||
--border-ui: rgba(255, 115, 87, 0.4);
|
||||
--border-ui-hover: rgba(255, 115, 87, 0.58);
|
||||
--border-ui-active: rgba(255, 115, 87, 0.78);
|
||||
@@ -3300,7 +3300,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.scan-result-icon-vt {
|
||||
color: #0030ff;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.scan-result-icon-oc {
|
||||
@@ -3449,7 +3449,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.version-scan-icon-vt {
|
||||
color: #0030ff;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.version-scan-icon-oc {
|
||||
|
||||
Reference in New Issue
Block a user