mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 09:52:03 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add8b51d5c | ||
|
|
9e33607629 | ||
|
|
10b704278a | ||
|
|
a289f9cbd9 | ||
|
|
97d68a1be5 | ||
|
|
57e0d39cdc | ||
|
|
1c033868e7 | ||
|
|
4532366009 | ||
|
|
8e9fa44fc2 | ||
|
|
11a66ea148 | ||
|
|
f94e20d4c3 | ||
|
|
71c74f61e2 | ||
|
|
e2592684ed | ||
|
|
79c9381201 | ||
|
|
6a5712fdb6 | ||
|
|
a85faf76ac | ||
|
|
c0a04210e9 | ||
|
|
5adb334cb2 | ||
|
|
182ec8741f | ||
|
|
bafd17b00a | ||
|
|
802ee58054 | ||
|
|
0e83ba00b9 | ||
|
|
3326a5c838 | ||
|
|
f05dd556db | ||
|
|
964893a622 | ||
|
|
9a804b951f | ||
|
|
d699087786 | ||
|
|
26b42727fe | ||
|
|
8756b78a4e | ||
|
|
e9c771d55d | ||
|
|
a58f0166fa | ||
|
|
4328d4d700 | ||
|
|
28ee2618c1 | ||
|
|
a4b850ec33 | ||
|
|
7e0b21f7c8 | ||
|
|
71c6705ab1 | ||
|
|
a57769771f | ||
|
|
6a2c131a8a | ||
|
|
67ac157545 | ||
|
|
ef36cfd698 | ||
|
|
e0637ad6aa | ||
|
|
eb9a67f2af | ||
|
|
1ae0498595 | ||
|
|
f1a5254755 | ||
|
|
de2542e391 |
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Admin: add manual unban for banned users (clears `deletedAt` + `banReason`, audit log entry). Revoked API tokens stay revoked.
|
||||
- Admin: bulk restore skills from GitHub backup; reclaim squatted slugs via v1 endpoints + internal tooling (#298) (thanks @autogame-17).
|
||||
- Users: add `trustedPublisher` flag and admin mutations to bypass pending-scan auto-hide for trusted publishers (#298) (thanks @autogame-17).
|
||||
- Skills/Web: show skill owner avatar + handle on skill cards, lists, and detail pages (#312) (thanks @ianalloway).
|
||||
|
||||
### Changed
|
||||
- Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs.
|
||||
- Jobs: run skill stat event processing every 5 minutes (was 15).
|
||||
- API performance: batch resolve skill/soul tags in v1 list/get endpoints (fewer action->query round-trips) (#112) (thanks @mkrokosz).
|
||||
- Skills: reserve deleted slugs for prior owners (90-day cooldown) to prevent squatting; add admin reclaim flow (#298) (thanks @autogame-17).
|
||||
- Moderation: ban flow soft-deletes owned skills (reversible) and removes them from vector search (#298) (thanks @autogame-17).
|
||||
|
||||
### Fixed
|
||||
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
|
||||
- Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway).
|
||||
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
|
||||
- API: return proper status codes for delete/undelete errors (#35) (thanks @sergical).
|
||||
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
|
||||
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).
|
||||
- CLI: clarify `logout` only removes the local token; token remains valid until revoked in the web UI (#166) (thanks @aronchick).
|
||||
- Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98).
|
||||
- Skills: allow updating skill description/summary from frontmatter on subsequent publishes (#312) (thanks @ianalloway).
|
||||
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
+36
@@ -16,26 +16,44 @@ import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
import type * as githubBackupsNode from "../githubBackupsNode.js";
|
||||
import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubRestore from "../githubRestore.js";
|
||||
import type * as githubRestoreMutations from "../githubRestoreMutations.js";
|
||||
import type * as githubSoulBackups from "../githubSoulBackups.js";
|
||||
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as httpApi from "../httpApi.js";
|
||||
import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
|
||||
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
|
||||
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
|
||||
import type * as httpApiV1_starsV1 from "../httpApiV1/starsV1.js";
|
||||
import type * as httpApiV1_usersV1 from "../httpApiV1/usersV1.js";
|
||||
import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
|
||||
import type * as httpPreflight from "../httpPreflight.js";
|
||||
import type * as leaderboards from "../leaderboards.js";
|
||||
import type * as lib_access from "../lib/access.js";
|
||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_badges from "../lib/badges.js";
|
||||
import type * as lib_batching from "../lib/batching.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubRestoreHelpers from "../lib/githubRestoreHelpers.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
@@ -87,26 +105,44 @@ declare const fullApi: ApiFromModules<{
|
||||
downloads: typeof downloads;
|
||||
githubBackups: typeof githubBackups;
|
||||
githubBackupsNode: typeof githubBackupsNode;
|
||||
githubIdentity: typeof githubIdentity;
|
||||
githubImport: typeof githubImport;
|
||||
githubRestore: typeof githubRestore;
|
||||
githubRestoreMutations: typeof githubRestoreMutations;
|
||||
githubSoulBackups: typeof githubSoulBackups;
|
||||
githubSoulBackupsNode: typeof githubSoulBackupsNode;
|
||||
http: typeof http;
|
||||
httpApi: typeof httpApi;
|
||||
httpApiV1: typeof httpApiV1;
|
||||
"httpApiV1/shared": typeof httpApiV1_shared;
|
||||
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
|
||||
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
|
||||
"httpApiV1/starsV1": typeof httpApiV1_starsV1;
|
||||
"httpApiV1/usersV1": typeof httpApiV1_usersV1;
|
||||
"httpApiV1/whoamiV1": typeof httpApiV1_whoamiV1;
|
||||
httpPreflight: typeof httpPreflight;
|
||||
leaderboards: typeof leaderboards;
|
||||
"lib/access": typeof lib_access;
|
||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||
"lib/badges": typeof lib_badges;
|
||||
"lib/batching": typeof lib_batching;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
"lib/contentTypes": typeof lib_contentTypes;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
|
||||
+19
-3
@@ -2,7 +2,9 @@ import GitHub from '@auth/core/providers/github'
|
||||
import { convexAuth } from '@convex-dev/auth/server'
|
||||
import type { GenericMutationCtx } from 'convex/server'
|
||||
import { ConvexError } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { DataModel, Id } from './_generated/dataModel'
|
||||
import { shouldScheduleGitHubProfileSync } from './lib/githubProfileSync'
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
'Your account has been banned for uploading malicious skills. If you believe this is a mistake, please contact security@openclaw.ai and we will work with you to restore access.'
|
||||
@@ -14,8 +16,9 @@ const REAUTH_BLOCKING_BAN_ACTIONS = new Set(['user.ban', 'user.autoban.malware']
|
||||
export async function handleDeletedUserSignIn(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
|
||||
userOverride?: { deletedAt?: number; deactivatedAt?: number; purgedAt?: number } | null,
|
||||
) {
|
||||
const user = await ctx.db.get(args.userId)
|
||||
const user = userOverride !== undefined ? userOverride : await ctx.db.get(args.userId)
|
||||
if (!user?.deletedAt && !user?.deactivatedAt) return
|
||||
|
||||
// Verify that the incoming identity matches the existing account to prevent bypass.
|
||||
@@ -69,14 +72,27 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
],
|
||||
callbacks: {
|
||||
/**
|
||||
* Block sign-in for deleted/deactivated users.
|
||||
* Block sign-in for deleted/deactivated users and sync GitHub profile.
|
||||
*
|
||||
* Performance note: This callback runs on every OAuth sign-in, but the
|
||||
* audit log query ONLY executes when a legacy deleted user attempts to sign
|
||||
* in (user.deletedAt is set). For active users, this is a single field check.
|
||||
*
|
||||
* The GitHub profile sync is scheduled as a background action to handle
|
||||
* the case where a user renames their GitHub account (fixes #303).
|
||||
*/
|
||||
async afterUserCreatedOrUpdated(ctx, args) {
|
||||
await handleDeletedUserSignIn(ctx, args)
|
||||
const user = await ctx.db.get(args.userId)
|
||||
await handleDeletedUserSignIn(ctx, args, user)
|
||||
|
||||
// Schedule GitHub profile sync to handle username renames (fixes #303)
|
||||
// This runs as a background action so it doesn't block sign-in
|
||||
const now = Date.now()
|
||||
if (shouldScheduleGitHubProfileSync(user, now)) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.syncGitHubProfileAction, {
|
||||
userId: args.userId,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+1
-1
@@ -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>) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internalQuery } from './_generated/server'
|
||||
import { getGitHubProviderAccountId } from './lib/githubIdentity'
|
||||
|
||||
export const getGitHubProviderAccountIdInternal = internalQuery({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx, args) => getGitHubProviderAccountId(ctx, args.userId),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
'use node'
|
||||
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import { internalAction } from './_generated/server'
|
||||
import {
|
||||
fetchGitHubSkillMeta,
|
||||
getGitHubBackupContext,
|
||||
isGitHubBackupConfigured,
|
||||
} from './lib/githubBackup'
|
||||
import { assertAdmin } from './lib/access'
|
||||
import {
|
||||
listGitHubBackupFiles,
|
||||
readGitHubBackupFile,
|
||||
} from './lib/githubRestoreHelpers'
|
||||
import { publishVersionForUser } from './lib/skillPublish'
|
||||
import { guessContentTypeForPath } from './lib/contentTypes'
|
||||
|
||||
type RestoreResult = {
|
||||
slug: string
|
||||
status: 'restored' | 'slug_conflict' | 'already_exists' | 'no_backup' | 'error'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type BulkRestoreResult = {
|
||||
results: RestoreResult[]
|
||||
totalRestored: number
|
||||
totalConflicts: number
|
||||
totalSkipped: number
|
||||
totalErrors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: restore a single skill from GitHub backup.
|
||||
* Reads the backup files from the GitHub repo and re-creates the skill in the database.
|
||||
*/
|
||||
export const restoreSkillFromBackup = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
ownerHandle: v.string(),
|
||||
ownerUserId: v.id('users'),
|
||||
slug: v.string(),
|
||||
forceOverwriteSquatter: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<RestoreResult> => {
|
||||
try {
|
||||
const actor = await ctx.runQuery(internal.users.getByIdInternal, {
|
||||
userId: args.actorUserId,
|
||||
})
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
||||
return { slug: args.slug, status: 'error', detail: 'Actor not found' }
|
||||
}
|
||||
assertAdmin(actor as Doc<'users'>)
|
||||
|
||||
if (!isGitHubBackupConfigured()) {
|
||||
return { slug: args.slug, status: 'error', detail: 'GitHub backup not configured' }
|
||||
}
|
||||
|
||||
const ghContext = await getGitHubBackupContext()
|
||||
|
||||
// Check if skill already exists in the DB
|
||||
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
|
||||
slug: args.slug,
|
||||
})) as Doc<'skills'> | null
|
||||
|
||||
if (existingSkill) {
|
||||
if (existingSkill.ownerUserId === args.ownerUserId) {
|
||||
return { slug: args.slug, status: 'already_exists', detail: 'Skill already owned by user' }
|
||||
}
|
||||
|
||||
if (!args.forceOverwriteSquatter) {
|
||||
return {
|
||||
slug: args.slug,
|
||||
status: 'slug_conflict',
|
||||
detail: `Slug occupied by another user. Set forceOverwriteSquatter=true to reclaim.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Free the slug in-transaction by renaming the squatter, then enqueue cleanup.
|
||||
await ctx.runMutation(internal.githubRestoreMutations.evictSquatterSkillForRestoreInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
slug: args.slug,
|
||||
rightfulOwnerUserId: args.ownerUserId,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch metadata from GitHub backup
|
||||
const meta = await fetchGitHubSkillMeta(ghContext, args.ownerHandle, args.slug)
|
||||
if (!meta) {
|
||||
return { slug: args.slug, status: 'no_backup', detail: 'No backup found in GitHub repo' }
|
||||
}
|
||||
|
||||
// Read the actual files from the backup
|
||||
const backupFiles = await listGitHubBackupFiles(ghContext, args.ownerHandle, args.slug)
|
||||
if (backupFiles.length === 0) {
|
||||
return { slug: args.slug, status: 'no_backup', detail: 'Backup has no files' }
|
||||
}
|
||||
|
||||
// Download and store each file in Convex storage
|
||||
const storedFiles: Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: Id<'_storage'>
|
||||
sha256: string
|
||||
contentType: string
|
||||
}> = []
|
||||
|
||||
for (const filePath of backupFiles) {
|
||||
const fileContent = await readGitHubBackupFile(ghContext, args.ownerHandle, args.slug, filePath)
|
||||
if (!fileContent) continue
|
||||
|
||||
const sha256 = await sha256Hex(fileContent)
|
||||
const contentType = guessContentTypeForPath(filePath)
|
||||
const blob = new Blob([Buffer.from(fileContent)], { type: contentType })
|
||||
const storageId = await ctx.storage.store(blob)
|
||||
|
||||
storedFiles.push({
|
||||
path: filePath,
|
||||
size: fileContent.byteLength,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType,
|
||||
})
|
||||
}
|
||||
|
||||
if (storedFiles.length === 0) {
|
||||
return { slug: args.slug, status: 'error', detail: 'Could not download any backup files' }
|
||||
}
|
||||
|
||||
await publishVersionForUser(
|
||||
ctx,
|
||||
args.ownerUserId,
|
||||
{
|
||||
slug: args.slug,
|
||||
displayName: meta.displayName,
|
||||
version: meta.latest.version,
|
||||
changelog: 'Restored from GitHub backup',
|
||||
files: storedFiles,
|
||||
},
|
||||
{
|
||||
bypassGitHubAccountAge: true,
|
||||
bypassNewSkillRateLimit: true,
|
||||
bypassQualityGate: true,
|
||||
skipBackup: true,
|
||||
skipWebhook: true,
|
||||
},
|
||||
)
|
||||
|
||||
return { slug: args.slug, status: 'restored' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`[restore] Failed to restore ${args.slug}:`, message)
|
||||
return { slug: args.slug, status: 'error', detail: message }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Admin-only: bulk restore all skills for a user from GitHub backup.
|
||||
*/
|
||||
export const restoreUserSkillsFromBackup = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
ownerHandle: v.string(),
|
||||
ownerUserId: v.id('users'),
|
||||
slugs: v.array(v.string()),
|
||||
forceOverwriteSquatter: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<BulkRestoreResult> => {
|
||||
const results: RestoreResult[] = []
|
||||
let totalRestored = 0
|
||||
let totalConflicts = 0
|
||||
let totalSkipped = 0
|
||||
let totalErrors = 0
|
||||
|
||||
for (const slug of args.slugs) {
|
||||
const result = (await ctx.runAction(internal.githubRestore.restoreSkillFromBackup, {
|
||||
actorUserId: args.actorUserId,
|
||||
ownerHandle: args.ownerHandle,
|
||||
ownerUserId: args.ownerUserId,
|
||||
slug,
|
||||
forceOverwriteSquatter: args.forceOverwriteSquatter,
|
||||
})) as RestoreResult
|
||||
|
||||
results.push(result)
|
||||
|
||||
switch (result.status) {
|
||||
case 'restored':
|
||||
totalRestored += 1
|
||||
break
|
||||
case 'slug_conflict':
|
||||
totalConflicts += 1
|
||||
break
|
||||
case 'already_exists':
|
||||
case 'no_backup':
|
||||
totalSkipped += 1
|
||||
break
|
||||
case 'error':
|
||||
totalErrors += 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { results, totalRestored, totalConflicts, totalSkipped, totalErrors }
|
||||
},
|
||||
})
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const { createHash } = await import('node:crypto')
|
||||
const hash = createHash('sha256')
|
||||
hash.update(bytes)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
// guessContentTypeForPath in lib/contentTypes.ts
|
||||
@@ -0,0 +1,84 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import { internalMutation } from './_generated/server'
|
||||
import { assertAdmin } from './lib/access'
|
||||
|
||||
export const evictSquatterSkillForRestoreInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
slug: v.string(),
|
||||
rightfulOwnerUserId: v.id('users'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('Actor not found')
|
||||
assertAdmin(actor)
|
||||
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
if (!slug) throw new Error('Slug required')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const existingSkill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slug))
|
||||
.unique()
|
||||
if (!existingSkill) return { ok: true as const, action: 'noop' as const }
|
||||
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
|
||||
return { ok: true as const, action: 'already_owned' as const }
|
||||
}
|
||||
|
||||
const evictedSlug = buildEvictedSlug(slug, now)
|
||||
|
||||
// Free the slug immediately (same transaction) by renaming the squatter's skill.
|
||||
await ctx.db.patch(existingSkill._id, {
|
||||
slug: evictedSlug,
|
||||
softDeletedAt: now,
|
||||
hiddenAt: existingSkill.hiddenAt ?? now,
|
||||
hiddenBy: existingSkill.hiddenBy ?? actor._id,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
// Remove from vector search ASAP.
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', existingSkill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: 'deleted',
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Cleanup the rest asynchronously (versions, fingerprints, installs, etc.)
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: existingSkill._id,
|
||||
actorUserId: actor._id,
|
||||
phase: 'versions',
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'slug.reclaim.sync',
|
||||
targetType: 'skill',
|
||||
targetId: existingSkill._id,
|
||||
metadata: {
|
||||
slug,
|
||||
evictedSlug,
|
||||
squatterUserId: existingSkill.ownerUserId,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
reason: 'Synchronous eviction during GitHub restore',
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, action: 'evicted' as const, evictedSlug }
|
||||
},
|
||||
})
|
||||
|
||||
function buildEvictedSlug(slug: string, now: number) {
|
||||
const suffix = now.toString(36)
|
||||
return `${slug}-evicted-${suffix}`
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -27,6 +28,12 @@ function isRateLimitArgs(args: unknown): args is RateLimitArgs {
|
||||
)
|
||||
}
|
||||
|
||||
function hasSlugArgs(args: unknown): args is { slug: string } {
|
||||
if (!args || typeof args !== 'object') return false
|
||||
const value = args as Record<string, unknown>
|
||||
return typeof value.slug === 'string'
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === 'function'
|
||||
@@ -59,6 +66,8 @@ const blockedRate = () => ({
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockReset()
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null)
|
||||
vi.mocked(requireApiTokenUser).mockReset()
|
||||
vi.mocked(publishVersionForUser).mockReset()
|
||||
})
|
||||
@@ -78,6 +87,122 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(runAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('users/restore forbids non-admin api tokens', async () => {
|
||||
const runQuery = vi.fn()
|
||||
const runAction = vi.fn()
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:actor',
|
||||
user: { _id: 'users:actor', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ handle: 'target', slugs: ['a'] }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
expect(runQuery).not.toHaveBeenCalled()
|
||||
expect(runAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('users/restore calls restore action for admin', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue({ ok: true, totalRestored: 1, results: [] })
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate()
|
||||
return { ok: true }
|
||||
})
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('handle' in args) return { _id: 'users:target' }
|
||||
return null
|
||||
})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
handle: 'Target',
|
||||
slugs: ['a', 'b'],
|
||||
forceOverwriteSquatter: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: 'users:admin',
|
||||
ownerHandle: 'target',
|
||||
ownerUserId: 'users:target',
|
||||
slugs: ['a', 'b'],
|
||||
forceOverwriteSquatter: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('users/reclaim forbids non-admin api tokens', async () => {
|
||||
const runQuery = vi.fn()
|
||||
const runAction = vi.fn()
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:actor',
|
||||
user: { _id: 'users:actor', role: 'user' },
|
||||
} as never)
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/reclaim', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ handle: 'target', slugs: ['a'] }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
expect(runQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('users/reclaim calls reclaim mutation for admin', async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate()
|
||||
return { ok: true }
|
||||
})
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('handle' in args) return { _id: 'users:target' }
|
||||
return null
|
||||
})
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:admin',
|
||||
user: { _id: 'users:admin', role: 'admin' },
|
||||
} as never)
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction: vi.fn(), runMutation }),
|
||||
new Request('https://example.com/api/v1/users/reclaim', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ handle: 'Target', slugs: [' A ', 'b'], reason: 'r' }),
|
||||
}),
|
||||
)
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
|
||||
const reclaimCalls = runMutation.mock.calls.filter(([, args]) => hasSlugArgs(args))
|
||||
expect(reclaimCalls).toHaveLength(2)
|
||||
expect(reclaimCalls[0]?.[1]).toMatchObject({
|
||||
actorUserId: 'users:admin',
|
||||
slug: 'a',
|
||||
rightfulOwnerUserId: 'users:target',
|
||||
reason: 'r',
|
||||
})
|
||||
expect(reclaimCalls[1]?.[1]).toMatchObject({
|
||||
actorUserId: 'users:admin',
|
||||
slug: 'b',
|
||||
rightfulOwnerUserId: 'users:target',
|
||||
reason: 'r',
|
||||
})
|
||||
})
|
||||
|
||||
it('search forwards limit and highlightedOnly', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -148,7 +273,7 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.match.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills with resolved tags', async () => {
|
||||
it('lists skills with resolved tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
@@ -170,7 +295,10 @@ describe('httpApiV1 handlers', () => {
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionId' in args) return { version: '1.0.0' }
|
||||
// Batch query: versionIds (plural)
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
@@ -183,6 +311,209 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('batches tag resolution across multiple skills into single query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'skill-a',
|
||||
displayName: 'Skill A',
|
||||
summary: 's',
|
||||
tags: { latest: 'versions:1', stable: 'versions:2' },
|
||||
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
{
|
||||
skill: {
|
||||
_id: 'skills:2',
|
||||
slug: 'skill-b',
|
||||
displayName: 'Skill B',
|
||||
summary: 's',
|
||||
tags: { latest: 'versions:3' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
// Batch query should receive all version IDs from all skills
|
||||
if ('versionIds' in args) {
|
||||
const ids = args.versionIds as string[]
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(ids).toContain('versions:1')
|
||||
expect(ids).toContain('versions:2')
|
||||
expect(ids).toContain('versions:3')
|
||||
return [
|
||||
{ _id: 'versions:1', version: '2.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'versions:2', version: '1.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'versions:3', version: '1.0.0', softDeletedAt: undefined },
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSkillsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
// Verify tags are correctly resolved for each skill
|
||||
expect(json.items[0].tags.latest).toBe('2.0.0')
|
||||
expect(json.items[0].tags.stable).toBe('1.0.0')
|
||||
expect(json.items[1].tags.latest).toBe('1.0.0')
|
||||
// Verify batch query was called exactly once (not per-tag)
|
||||
const batchCalls = runQuery.mock.calls.filter(
|
||||
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
|
||||
)
|
||||
expect(batchCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lists souls with resolved tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'demo-soul',
|
||||
displayName: 'Demo Soul',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSoulsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls?limit=1'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.items[0].tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('batches tag resolution across multiple souls into single query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('cursor' in args || 'limit' in args) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'soul-a',
|
||||
displayName: 'Soul A',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1', stable: 'soulVersions:2' },
|
||||
stats: { downloads: 0, stars: 0, versions: 2, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
{
|
||||
soul: {
|
||||
_id: 'souls:2',
|
||||
slug: 'soul-b',
|
||||
displayName: 'Soul B',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:3' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
const ids = args.versionIds as string[]
|
||||
expect(ids).toHaveLength(3)
|
||||
expect(ids).toContain('soulVersions:1')
|
||||
expect(ids).toContain('soulVersions:2')
|
||||
expect(ids).toContain('soulVersions:3')
|
||||
return [
|
||||
{ _id: 'soulVersions:1', version: '2.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'soulVersions:2', version: '1.0.0', softDeletedAt: undefined },
|
||||
{ _id: 'soulVersions:3', version: '1.0.0', softDeletedAt: undefined },
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.listSoulsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.items[0].tags.latest).toBe('2.0.0')
|
||||
expect(json.items[0].tags.stable).toBe('1.0.0')
|
||||
expect(json.items[1].tags.latest).toBe('1.0.0')
|
||||
const batchCalls = runQuery.mock.calls.filter(
|
||||
([, args]) => args && 'versionIds' in (args as Record<string, unknown>),
|
||||
)
|
||||
expect(batchCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('souls get resolves tags using batch query', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
soul: {
|
||||
_id: 'souls:1',
|
||||
slug: 'demo-soul',
|
||||
displayName: 'Demo Soul',
|
||||
summary: 's',
|
||||
tags: { latest: 'soulVersions:1' },
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
owner: null,
|
||||
}
|
||||
}
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.soulsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/souls/demo-soul'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.soul.tags.latest).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('lists skills supports sort aliases', async () => {
|
||||
const checks: Array<[string, string]> = [
|
||||
['rating', 'stars'],
|
||||
@@ -218,6 +549,52 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('get skill returns pending-scan message for owner api token', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
_id: 'skills:1',
|
||||
slug: 'demo',
|
||||
ownerUserId: 'users:1',
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'pending.scan',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo'),
|
||||
)
|
||||
expect(response.status).toBe(423)
|
||||
expect(await response.text()).toContain('security scan is pending')
|
||||
})
|
||||
|
||||
it('get skill returns undelete hint for owner soft-deleted skill', async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never)
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
return {
|
||||
_id: 'skills:1',
|
||||
slug: 'demo',
|
||||
ownerUserId: 'users:1',
|
||||
softDeletedAt: 1,
|
||||
moderationStatus: 'hidden',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/skills/demo'),
|
||||
)
|
||||
expect(response.status).toBe(410)
|
||||
expect(await response.text()).toContain('clawhub undelete demo')
|
||||
})
|
||||
|
||||
it('get skill returns payload', async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('slug' in args) {
|
||||
@@ -241,7 +618,10 @@ describe('httpApiV1 handlers', () => {
|
||||
owner: { handle: 'p', displayName: 'Peter', image: null },
|
||||
}
|
||||
}
|
||||
if ('versionId' in args) return { version: '1.0.0' }
|
||||
// Batch query for tag resolution
|
||||
if ('versionIds' in args) {
|
||||
return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }]
|
||||
}
|
||||
return null
|
||||
})
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
@@ -708,4 +1088,61 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(json.ok).toBe(true)
|
||||
expect(json.unstarred).toBe(true)
|
||||
})
|
||||
|
||||
it('delete/undelete map forbidden/not-found/unknown to 403/404/500', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
|
||||
const runMutationForbidden = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('Forbidden')
|
||||
})
|
||||
const forbidden = await __handlers.skillsDeleteRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationForbidden }),
|
||||
new Request('https://example.com/api/v1/skills/demo', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(forbidden.status).toBe(403)
|
||||
expect(await forbidden.text()).toBe('Forbidden')
|
||||
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runMutationNotFound = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('Skill not found')
|
||||
})
|
||||
const notFound = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationNotFound }),
|
||||
new Request('https://example.com/api/v1/skills/demo/undelete', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(notFound.status).toBe(404)
|
||||
expect(await notFound.text()).toBe('Skill not found')
|
||||
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runMutationUnknown = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ('key' in args) return okRate()
|
||||
throw new Error('boom')
|
||||
})
|
||||
const unknown = await __handlers.soulsDeleteRouterV1Handler(
|
||||
makeCtx({ runMutation: runMutationUnknown }),
|
||||
new Request('https://example.com/api/v1/souls/demo-soul', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: 'Bearer clh_test' },
|
||||
}),
|
||||
)
|
||||
expect(unknown.status).toBe(500)
|
||||
expect(await unknown.text()).toBe('Internal Server Error')
|
||||
})
|
||||
})
|
||||
|
||||
+24
-1348
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
import { CliPublishRequestSchema, parseArk } from 'clawhub-schema'
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { assertAdmin } from '../lib/access'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { corsHeaders, mergeHeaders } from '../lib/httpHeaders'
|
||||
|
||||
export const MAX_RAW_FILE_BYTES = 200 * 1024
|
||||
|
||||
const SAFE_TEXT_FILE_CSP =
|
||||
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
|
||||
function isSvgLike(contentType: string | undefined, path: string) {
|
||||
return contentType?.toLowerCase().includes('svg') || path.toLowerCase().endsWith('.svg')
|
||||
}
|
||||
|
||||
export function safeTextFileResponse(params: {
|
||||
textContent: string
|
||||
path: string
|
||||
contentType?: string
|
||||
sha256: string
|
||||
size: number
|
||||
headers?: HeadersInit
|
||||
}) {
|
||||
const isSvg = isSvgLike(params.contentType, params.path)
|
||||
|
||||
// For any text response that a browser might try to render, lock it down.
|
||||
// In particular, this prevents SVG <foreignObject> script execution from reading
|
||||
// localStorage tokens on this origin.
|
||||
const headers = mergeHeaders(
|
||||
params.headers,
|
||||
{
|
||||
'Content-Type': params.contentType
|
||||
? `${params.contentType}; charset=utf-8`
|
||||
: 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
ETag: params.sha256,
|
||||
'X-Content-SHA256': params.sha256,
|
||||
'X-Content-Size': String(params.size),
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': SAFE_TEXT_FILE_CSP,
|
||||
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
|
||||
},
|
||||
corsHeaders(),
|
||||
)
|
||||
|
||||
return new Response(params.textContent, { status: 200, headers })
|
||||
}
|
||||
|
||||
export function json(value: unknown, status = 200, headers?: HeadersInit) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function text(value: string, status: number, headers?: HeadersInit) {
|
||||
return new Response(value, {
|
||||
status,
|
||||
headers: mergeHeaders(
|
||||
{
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseJsonPayload(request: Request, headers: HeadersInit) {
|
||||
try {
|
||||
const payload = (await request.json()) as Record<string, unknown>
|
||||
return { ok: true as const, payload }
|
||||
} catch {
|
||||
return { ok: false as const, response: text('Invalid JSON', 400, headers) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireApiTokenUserOrResponse(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
try {
|
||||
const auth = await requireApiTokenUser(ctx, request)
|
||||
return { ok: true as const, userId: auth.userId, user: auth.user as Doc<'users'> }
|
||||
} catch {
|
||||
return { ok: false as const, response: text('Unauthorized', 401, headers) }
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdminOrResponse(user: Doc<'users'>, headers: HeadersInit) {
|
||||
try {
|
||||
assertAdmin(user)
|
||||
return { ok: true as const }
|
||||
} catch {
|
||||
return { ok: false as const, response: text('Forbidden', 403, headers) }
|
||||
}
|
||||
}
|
||||
|
||||
export function getPathSegments(request: Request, prefix: string) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (!pathname.startsWith(prefix)) return []
|
||||
const rest = pathname.slice(prefix.length)
|
||||
return rest
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment))
|
||||
}
|
||||
|
||||
export function toOptionalNumber(value: string | null) {
|
||||
if (!value) return undefined
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch resolve soul version tags to version strings.
|
||||
* Collects all version IDs, fetches them in a single query, then maps back.
|
||||
* Reduces N sequential queries to 1 batch query.
|
||||
*/
|
||||
export async function resolveSoulTagsBatch(
|
||||
ctx: ActionCtx,
|
||||
tagsList: Array<Record<string, Id<'soulVersions'>>>,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
return resolveVersionTagsBatch(ctx, tagsList, internal.souls.getVersionsByIdsInternal)
|
||||
}
|
||||
|
||||
export async function resolveTagsBatch(
|
||||
ctx: ActionCtx,
|
||||
tagsList: Array<Record<string, Id<'skillVersions'>>>,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
return resolveVersionTagsBatch(ctx, tagsList, internal.skills.getVersionsByIdsInternal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch resolve version tags to version strings.
|
||||
* Collects all version IDs, fetches them in a single query, then maps back.
|
||||
*
|
||||
* Notes:
|
||||
* - Uses `internal.*` queries to avoid expanding the public Convex API surface.
|
||||
* - Sorts ids for stable query args (helps caching/log diffs).
|
||||
*/
|
||||
export async function resolveVersionTagsBatch<TTable extends 'skillVersions' | 'soulVersions'>(
|
||||
ctx: ActionCtx,
|
||||
tagsList: Array<Record<string, Id<TTable>>>,
|
||||
getVersionsByIdsQuery: unknown,
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
const allVersionIds = new Set<Id<TTable>>()
|
||||
for (const tags of tagsList) {
|
||||
for (const versionId of Object.values(tags)) allVersionIds.add(versionId)
|
||||
}
|
||||
|
||||
if (allVersionIds.size === 0) return tagsList.map(() => ({}))
|
||||
|
||||
const versionIds = [...allVersionIds].sort() as Array<Id<TTable>>
|
||||
const versions =
|
||||
((await ctx.runQuery(getVersionsByIdsQuery as never, { versionIds } as never)) as Array<{
|
||||
_id: Id<TTable>
|
||||
version: string
|
||||
softDeletedAt?: unknown
|
||||
}> | null) ?? []
|
||||
|
||||
const versionMap = new Map<Id<TTable>, string>()
|
||||
for (const v of versions) {
|
||||
if (!v?.softDeletedAt) versionMap.set(v._id, v.version)
|
||||
}
|
||||
|
||||
return tagsList.map((tags) => {
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, versionId] of Object.entries(tags)) {
|
||||
const version = versionMap.get(versionId)
|
||||
if (version) resolved[tag] = version
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const data = new Uint8Array(bytes)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return toHex(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array) {
|
||||
let out = ''
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
|
||||
return out
|
||||
}
|
||||
|
||||
type FileLike = {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
arrayBuffer: () => Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
type FileLikeEntry = FormDataEntryValue & FileLike
|
||||
|
||||
function toFileLike(entry: FormDataEntryValue): FileLikeEntry | null {
|
||||
if (typeof entry === 'string') return null
|
||||
const candidate = entry as Partial<FileLike>
|
||||
if (typeof candidate.name !== 'string') return null
|
||||
if (typeof candidate.size !== 'number') return null
|
||||
if (typeof candidate.arrayBuffer !== 'function') return null
|
||||
return entry as FileLikeEntry
|
||||
}
|
||||
|
||||
export async function parseMultipartPublish(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
): Promise<{
|
||||
slug: string
|
||||
displayName: string
|
||||
version: string
|
||||
changelog: string
|
||||
tags?: string[]
|
||||
forkOf?: { slug: string; version?: string }
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: Id<'_storage'>
|
||||
sha256: string
|
||||
contentType?: string
|
||||
}>
|
||||
}> {
|
||||
const form = await request.formData()
|
||||
const payloadRaw = form.get('payload')
|
||||
if (!payloadRaw || typeof payloadRaw !== 'string') {
|
||||
throw new Error('Missing payload')
|
||||
}
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(payloadRaw) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error('Invalid JSON payload')
|
||||
}
|
||||
|
||||
const files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: Id<'_storage'>
|
||||
sha256: string
|
||||
contentType?: string
|
||||
}> = []
|
||||
|
||||
for (const entry of form.getAll('files')) {
|
||||
const file = toFileLike(entry)
|
||||
if (!file) continue
|
||||
const path = file.name
|
||||
const size = file.size
|
||||
const contentType = file.type || undefined
|
||||
const buffer = new Uint8Array(await file.arrayBuffer())
|
||||
const sha256 = await sha256Hex(buffer)
|
||||
const storageId = await ctx.storage.store(file as Blob)
|
||||
files.push({ path, size, storageId, sha256, contentType })
|
||||
}
|
||||
|
||||
const forkOf = payload.forkOf && typeof payload.forkOf === 'object' ? payload.forkOf : undefined
|
||||
const body = {
|
||||
slug: payload.slug,
|
||||
displayName: payload.displayName,
|
||||
version: payload.version,
|
||||
changelog: typeof payload.changelog === 'string' ? payload.changelog : '',
|
||||
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
||||
...(payload.source ? { source: payload.source } : {}),
|
||||
files,
|
||||
...(forkOf ? { forkOf } : {}),
|
||||
}
|
||||
|
||||
return parsePublishBody(body)
|
||||
}
|
||||
|
||||
export function parsePublishBody(body: unknown) {
|
||||
const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload')
|
||||
if (parsed.files.length === 0) throw new Error('files required')
|
||||
const tags = parsed.tags && parsed.tags.length > 0 ? parsed.tags : undefined
|
||||
return {
|
||||
slug: parsed.slug,
|
||||
displayName: parsed.displayName,
|
||||
version: parsed.version,
|
||||
changelog: parsed.changelog,
|
||||
tags,
|
||||
source: parsed.source ?? undefined,
|
||||
forkOf: parsed.forkOf
|
||||
? {
|
||||
slug: parsed.forkOf.slug,
|
||||
version: parsed.forkOf.version ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
files: parsed.files.map((file) => ({
|
||||
...file,
|
||||
storageId: file.storageId as Id<'_storage'>,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function softDeleteErrorToResponse(
|
||||
entity: 'skill' | 'soul',
|
||||
error: unknown,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const message = error instanceof Error ? error.message : `${entity} delete failed`
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
if (lower.includes('unauthorized')) return text('Unauthorized', 401, headers)
|
||||
if (lower.includes('forbidden')) return text('Forbidden', 403, headers)
|
||||
if (lower.includes('not found')) return text(message, 404, headers)
|
||||
if (lower.includes('slug required')) return text('Slug required', 400, headers)
|
||||
|
||||
// Unknown: server-side failure. Keep body generic.
|
||||
return text('Internal Server Error', 500, headers)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { api, internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
|
||||
import { publishVersionForUser } from '../skills'
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
getPathSegments,
|
||||
json,
|
||||
parseMultipartPublish,
|
||||
parsePublishBody,
|
||||
resolveTagsBatch,
|
||||
safeTextFileResponse,
|
||||
softDeleteErrorToResponse,
|
||||
text,
|
||||
toOptionalNumber,
|
||||
} from './shared'
|
||||
|
||||
type SearchSkillEntry = {
|
||||
score: number
|
||||
skill: {
|
||||
slug?: string
|
||||
displayName?: string
|
||||
summary?: string | null
|
||||
updatedAt?: number
|
||||
} | null
|
||||
version: { version?: string; createdAt?: number } | null
|
||||
}
|
||||
|
||||
type ListSkillsResult = {
|
||||
items: Array<{
|
||||
skill: {
|
||||
_id: Id<'skills'>
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
tags: Record<string, Id<'skillVersions'>>
|
||||
stats: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
latestVersionId?: Id<'skillVersions'>
|
||||
}
|
||||
latestVersion: { version: string; createdAt: number; changelog: string } | null
|
||||
}>
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
|
||||
type GetBySlugResult = {
|
||||
skill: {
|
||||
_id: Id<'skills'>
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
tags: Record<string, Id<'skillVersions'>>
|
||||
stats: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
} | null
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
|
||||
moderationInfo?: {
|
||||
isPendingScan: boolean
|
||||
isMalwareBlocked: boolean
|
||||
isSuspicious: boolean
|
||||
isHiddenByMod: boolean
|
||||
isRemoved: boolean
|
||||
reason?: string
|
||||
} | null
|
||||
} | null
|
||||
|
||||
type ListVersionsResult = {
|
||||
items: Array<{
|
||||
version: string
|
||||
createdAt: number
|
||||
changelog: string
|
||||
changelogSource?: 'auto' | 'user'
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: Id<'_storage'>
|
||||
sha256: string
|
||||
contentType?: string
|
||||
}>
|
||||
softDeletedAt?: number
|
||||
}>
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const query = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
|
||||
|
||||
if (!query) return json({ results: [] }, 200, rate.headers)
|
||||
|
||||
const results = (await ctx.runAction(api.search.searchSkills, {
|
||||
query,
|
||||
limit,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
})) as SearchSkillEntry[]
|
||||
|
||||
return json(
|
||||
{
|
||||
results: results.map((result) => ({
|
||||
score: result.score,
|
||||
slug: result.skill?.slug,
|
||||
displayName: result.skill?.displayName,
|
||||
summary: result.skill?.summary ?? null,
|
||||
version: result.version?.version ?? null,
|
||||
updatedAt: result.skill?.updatedAt,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
|
||||
const hash = url.searchParams.get('hash')?.trim().toLowerCase()
|
||||
if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers)
|
||||
if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers)
|
||||
|
||||
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash })
|
||||
if (!resolved) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
return json({ slug, match: resolved.match, latestVersion: resolved.latestVersion }, 200, rate.headers)
|
||||
}
|
||||
|
||||
type SkillListSort =
|
||||
| 'updated'
|
||||
| 'downloads'
|
||||
| 'stars'
|
||||
| 'installsCurrent'
|
||||
| 'installsAllTime'
|
||||
| 'trending'
|
||||
|
||||
function parseListSort(value: string | null): SkillListSort {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (normalized === 'downloads') return 'downloads'
|
||||
if (normalized === 'stars' || normalized === 'rating') return 'stars'
|
||||
if (
|
||||
normalized === 'installs' ||
|
||||
normalized === 'install' ||
|
||||
normalized === 'installscurrent' ||
|
||||
normalized === 'installs-current'
|
||||
) {
|
||||
return 'installsCurrent'
|
||||
}
|
||||
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
|
||||
return 'installsAllTime'
|
||||
}
|
||||
if (normalized === 'trending') return 'trending'
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const sort = parseListSort(url.searchParams.get('sort'))
|
||||
const cursor = sort === 'trending' ? undefined : rawCursor
|
||||
|
||||
const result = (await ctx.runQuery(api.skills.listPublicPage, {
|
||||
limit,
|
||||
cursor,
|
||||
sort,
|
||||
})) as ListSkillsResult
|
||||
|
||||
// Batch resolve all tags in a single query instead of N queries
|
||||
const resolvedTagsList = await resolveTagsBatch(
|
||||
ctx,
|
||||
result.items.map((item) => item.skill.tags),
|
||||
)
|
||||
|
||||
const items = result.items.map((item, idx) => ({
|
||||
slug: item.skill.slug,
|
||||
displayName: item.skill.displayName,
|
||||
summary: item.skill.summary ?? null,
|
||||
tags: resolvedTagsList[idx],
|
||||
stats: item.skill.stats,
|
||||
createdAt: item.skill.createdAt,
|
||||
updatedAt: item.skill.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
async function describeOwnerVisibleSkillState(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
slug: string,
|
||||
): Promise<{ status: number; message: string } | null> {
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return null
|
||||
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
|
||||
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
|
||||
if (!isOwner) return null
|
||||
|
||||
if (skill.softDeletedAt) {
|
||||
return {
|
||||
status: 410,
|
||||
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.moderationStatus === 'hidden') {
|
||||
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
|
||||
return {
|
||||
status: 423,
|
||||
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
|
||||
}
|
||||
}
|
||||
if (skill.moderationReason === 'quality.low') {
|
||||
return {
|
||||
status: 403,
|
||||
message:
|
||||
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 403,
|
||||
message: `Skill is hidden by moderation${
|
||||
skill.moderationReason ? ` (${skill.moderationReason})` : ''
|
||||
}.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.moderationStatus === 'removed') {
|
||||
return { status: 410, message: 'Skill has been removed by moderation.' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/skills/')
|
||||
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
const second = segments[1]
|
||||
const third = segments[2]
|
||||
|
||||
if (segments.length === 1) {
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
|
||||
if (!result?.skill) {
|
||||
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
|
||||
if (hidden) return text(hidden.message, hidden.status, rate.headers)
|
||||
return text('Skill not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags])
|
||||
return json(
|
||||
{
|
||||
skill: {
|
||||
slug: result.skill.slug,
|
||||
displayName: result.skill.displayName,
|
||||
summary: result.skill.summary ?? null,
|
||||
tags,
|
||||
stats: result.skill.stats,
|
||||
createdAt: result.skill.createdAt,
|
||||
updatedAt: result.skill.updatedAt,
|
||||
},
|
||||
latestVersion: result.latestVersion
|
||||
? {
|
||||
version: result.latestVersion.version,
|
||||
createdAt: result.latestVersion.createdAt,
|
||||
changelog: result.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
owner: result.owner
|
||||
? {
|
||||
handle: result.owner.handle ?? null,
|
||||
userId: result.owner._id,
|
||||
displayName: result.owner.displayName ?? null,
|
||||
image: result.owner.image ?? null,
|
||||
}
|
||||
: null,
|
||||
moderation: result.moderationInfo
|
||||
? {
|
||||
isSuspicious: result.moderationInfo.isSuspicious ?? false,
|
||||
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
}
|
||||
|
||||
if (second === 'versions' && segments.length === 2) {
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const cursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const result = (await ctx.runQuery(api.skills.listVersionsPage, {
|
||||
skillId: skill._id,
|
||||
limit,
|
||||
cursor,
|
||||
})) as ListVersionsResult
|
||||
|
||||
const items = result.items
|
||||
.filter((version) => !version.softDeletedAt)
|
||||
.map((version) => ({
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
if (second === 'versions' && third && segments.length === 3) {
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
|
||||
skillId: skill._id,
|
||||
version: third,
|
||||
})
|
||||
if (!version) return text('Version not found', 404, rate.headers)
|
||||
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
|
||||
|
||||
return json(
|
||||
{
|
||||
skill: { slug: skill.slug, displayName: skill.displayName },
|
||||
version: {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file: SkillFile) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
}
|
||||
|
||||
if (second === 'file' && segments.length === 2) {
|
||||
const url = new URL(request.url)
|
||||
const path = url.searchParams.get('path')?.trim()
|
||||
if (!path) return text('Missing path', 400, rate.headers)
|
||||
const versionParam = url.searchParams.get('version')?.trim()
|
||||
const tagParam = url.searchParams.get('tag')?.trim()
|
||||
|
||||
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
|
||||
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
let version = skillResult.latestVersion
|
||||
if (versionParam) {
|
||||
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
|
||||
skillId: skillResult.skill._id,
|
||||
version: versionParam,
|
||||
})
|
||||
} else if (tagParam) {
|
||||
const versionId = skillResult.skill.tags[tagParam]
|
||||
if (versionId) {
|
||||
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) return text('Version not found', 404, rate.headers)
|
||||
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
|
||||
|
||||
const normalized = path.trim()
|
||||
const normalizedLower = normalized.toLowerCase()
|
||||
const file =
|
||||
version.files.find((entry) => entry.path === normalized) ??
|
||||
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
|
||||
if (!file) return text('File not found', 404, rate.headers)
|
||||
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
|
||||
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) return text('File missing in storage', 410, rate.headers)
|
||||
const textContent = await blob.text()
|
||||
return safeTextFileResponse({
|
||||
textContent,
|
||||
path: file.path,
|
||||
contentType: file.contentType ?? undefined,
|
||||
sha256: file.sha256,
|
||||
size: file.size,
|
||||
headers: rate.headers,
|
||||
})
|
||||
}
|
||||
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
try {
|
||||
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
|
||||
const contentType = request.headers.get('content-type') ?? ''
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json()
|
||||
const payload = parsePublishBody(body)
|
||||
const result = await publishVersionForUser(ctx, userId, payload)
|
||||
return json({ ok: true, ...result }, 200, rate.headers)
|
||||
}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const payload = await parseMultipartPublish(ctx, request)
|
||||
const result = await publishVersionForUser(ctx, userId, payload)
|
||||
return json({ ok: true, ...result }, 200, rate.headers)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Publish failed'
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
|
||||
return text('Unsupported content type', 415, rate.headers)
|
||||
}
|
||||
|
||||
export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/skills/')
|
||||
if (segments.length !== 2 || segments[1] !== 'undelete') {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/skills/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: true,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('skill', error, rate.headers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { api, internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
|
||||
import { publishSoulVersionForUser } from '../souls'
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
getPathSegments,
|
||||
json,
|
||||
parseMultipartPublish,
|
||||
parsePublishBody,
|
||||
resolveSoulTagsBatch,
|
||||
safeTextFileResponse,
|
||||
softDeleteErrorToResponse,
|
||||
text,
|
||||
toOptionalNumber,
|
||||
} from './shared'
|
||||
|
||||
type ListSoulsResult = {
|
||||
items: Array<{
|
||||
soul: {
|
||||
_id: Id<'souls'>
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
tags: Record<string, Id<'soulVersions'>>
|
||||
stats: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
latestVersionId?: Id<'soulVersions'>
|
||||
}
|
||||
latestVersion: { version: string; createdAt: number; changelog: string } | null
|
||||
}>
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type GetSoulBySlugResult = {
|
||||
soul: {
|
||||
_id: Id<'souls'>
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
tags: Record<string, Id<'soulVersions'>>
|
||||
stats: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
} | null
|
||||
latestVersion: Doc<'soulVersions'> | null
|
||||
owner: { handle?: string; displayName?: string; image?: string } | null
|
||||
} | null
|
||||
|
||||
type ListSoulVersionsResult = {
|
||||
items: Array<{
|
||||
version: string
|
||||
createdAt: number
|
||||
changelog: string
|
||||
changelogSource?: 'auto' | 'user'
|
||||
files: Array<{
|
||||
path: string
|
||||
size: number
|
||||
storageId: Id<'_storage'>
|
||||
sha256: string
|
||||
contentType?: string
|
||||
}>
|
||||
softDeletedAt?: number
|
||||
}>
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
type SoulFile = Doc<'soulVersions'>['files'][number]
|
||||
|
||||
export async function listSoulsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const cursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
|
||||
const result = (await ctx.runQuery(api.souls.listPublicPage, {
|
||||
limit,
|
||||
cursor,
|
||||
})) as ListSoulsResult
|
||||
|
||||
// Batch resolve all tags in a single query instead of N queries
|
||||
const resolvedTagsList = await resolveSoulTagsBatch(
|
||||
ctx,
|
||||
result.items.map((item) => item.soul.tags),
|
||||
)
|
||||
|
||||
const items = result.items.map((item, idx) => ({
|
||||
slug: item.soul.slug,
|
||||
displayName: item.soul.displayName,
|
||||
summary: item.soul.summary ?? null,
|
||||
tags: resolvedTagsList[idx],
|
||||
stats: item.soul.stats,
|
||||
createdAt: item.soul.createdAt,
|
||||
updatedAt: item.soul.updatedAt,
|
||||
latestVersion: item.latestVersion
|
||||
? {
|
||||
version: item.latestVersion.version,
|
||||
createdAt: item.latestVersion.createdAt,
|
||||
changelog: item.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
export async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/souls/')
|
||||
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
const second = segments[1]
|
||||
const third = segments[2]
|
||||
|
||||
if (segments.length === 1) {
|
||||
const result = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
|
||||
if (!result?.soul) return text('Soul not found', 404, rate.headers)
|
||||
|
||||
const [tags] = await resolveSoulTagsBatch(ctx, [result.soul.tags])
|
||||
return json(
|
||||
{
|
||||
soul: {
|
||||
slug: result.soul.slug,
|
||||
displayName: result.soul.displayName,
|
||||
summary: result.soul.summary ?? null,
|
||||
tags,
|
||||
stats: result.soul.stats,
|
||||
createdAt: result.soul.createdAt,
|
||||
updatedAt: result.soul.updatedAt,
|
||||
},
|
||||
latestVersion: result.latestVersion
|
||||
? {
|
||||
version: result.latestVersion.version,
|
||||
createdAt: result.latestVersion.createdAt,
|
||||
changelog: result.latestVersion.changelog,
|
||||
}
|
||||
: null,
|
||||
owner: result.owner
|
||||
? {
|
||||
handle: result.owner.handle ?? null,
|
||||
displayName: result.owner.displayName ?? null,
|
||||
image: result.owner.image ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
}
|
||||
|
||||
if (second === 'versions' && segments.length === 2) {
|
||||
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
|
||||
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const cursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const result = (await ctx.runQuery(api.souls.listVersionsPage, {
|
||||
soulId: soul._id,
|
||||
limit,
|
||||
cursor,
|
||||
})) as ListSoulVersionsResult
|
||||
|
||||
const items = result.items
|
||||
.filter((version) => !version.softDeletedAt)
|
||||
.map((version) => ({
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
}))
|
||||
|
||||
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
|
||||
}
|
||||
|
||||
if (second === 'versions' && third && segments.length === 3) {
|
||||
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
|
||||
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
|
||||
|
||||
const version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
|
||||
soulId: soul._id,
|
||||
version: third,
|
||||
})
|
||||
if (!version) return text('Version not found', 404, rate.headers)
|
||||
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
|
||||
|
||||
return json(
|
||||
{
|
||||
soul: { slug: soul.slug, displayName: soul.displayName },
|
||||
version: {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
changelogSource: version.changelogSource ?? null,
|
||||
files: version.files.map((file: SoulFile) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
}
|
||||
|
||||
if (second === 'file' && segments.length === 2) {
|
||||
const url = new URL(request.url)
|
||||
const path = url.searchParams.get('path')?.trim()
|
||||
if (!path) return text('Missing path', 400, rate.headers)
|
||||
const versionParam = url.searchParams.get('version')?.trim()
|
||||
const tagParam = url.searchParams.get('tag')?.trim()
|
||||
|
||||
const soulResult = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
|
||||
if (!soulResult?.soul) return text('Soul not found', 404, rate.headers)
|
||||
|
||||
let version = soulResult.latestVersion
|
||||
if (versionParam) {
|
||||
version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
|
||||
soulId: soulResult.soul._id,
|
||||
version: versionParam,
|
||||
})
|
||||
} else if (tagParam) {
|
||||
const versionId = soulResult.soul.tags[tagParam]
|
||||
if (versionId) {
|
||||
version = await ctx.runQuery(api.souls.getVersionById, { versionId })
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) return text('Version not found', 404, rate.headers)
|
||||
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
|
||||
|
||||
const normalized = path.trim()
|
||||
const normalizedLower = normalized.toLowerCase()
|
||||
const file =
|
||||
version.files.find((entry) => entry.path === normalized) ??
|
||||
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
|
||||
if (!file) return text('File not found', 404, rate.headers)
|
||||
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
|
||||
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) return text('File missing in storage', 410, rate.headers)
|
||||
const textContent = await blob.text()
|
||||
|
||||
void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id })
|
||||
return safeTextFileResponse({
|
||||
textContent,
|
||||
path: file.path,
|
||||
contentType: file.contentType ?? undefined,
|
||||
sha256: file.sha256,
|
||||
size: file.size,
|
||||
headers: rate.headers,
|
||||
})
|
||||
}
|
||||
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
export async function publishSoulV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
try {
|
||||
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
|
||||
const contentType = request.headers.get('content-type') ?? ''
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json()
|
||||
const payload = parsePublishBody(body)
|
||||
const result = await publishSoulVersionForUser(ctx, userId, payload)
|
||||
return json({ ok: true, ...result }, 200, rate.headers)
|
||||
}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const payload = await parseMultipartPublish(ctx, request)
|
||||
const result = await publishSoulVersionForUser(ctx, userId, payload)
|
||||
return json({ ok: true, ...result }, 200, rate.headers)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Publish failed'
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
|
||||
return text('Unsupported content type', 415, rate.headers)
|
||||
}
|
||||
|
||||
export async function soulsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/souls/')
|
||||
if (segments.length !== 2 || segments[1] !== 'undelete') {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: false,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('soul', error, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/souls/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
|
||||
userId,
|
||||
slug,
|
||||
deleted: true,
|
||||
})
|
||||
return json({ ok: true }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
return softDeleteErrorToResponse('soul', error, rate.headers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { applyRateLimit } from '../lib/httpRateLimit'
|
||||
import { getPathSegments, json, text } from './shared'
|
||||
|
||||
export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/stars/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const result = await ctx.runMutation(internal.stars.addStarInternal, {
|
||||
userId,
|
||||
skillId: skill._id,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/stars/')
|
||||
if (segments.length !== 1) return text('Not found', 404, rate.headers)
|
||||
const slug = segments[0]?.trim().toLowerCase() ?? ''
|
||||
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
|
||||
if (!skill) return text('Skill not found', 404, rate.headers)
|
||||
|
||||
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
|
||||
userId,
|
||||
skillId: skill._id,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { api, internal } from '../_generated/api'
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { applyRateLimit } from '../lib/httpRateLimit'
|
||||
import {
|
||||
getPathSegments,
|
||||
json,
|
||||
parseJsonPayload,
|
||||
requireAdminOrResponse,
|
||||
requireApiTokenUserOrResponse,
|
||||
text,
|
||||
toOptionalNumber,
|
||||
} from './shared'
|
||||
|
||||
export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/users/')
|
||||
if (segments.length !== 1) {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
const action = segments[0]
|
||||
if (action !== 'ban' && action !== 'role' && action !== 'restore' && action !== 'reclaim') {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
const payloadResult = await parseJsonPayload(request, rate.headers)
|
||||
if (!payloadResult.ok) return payloadResult.response
|
||||
const payload = payloadResult.payload
|
||||
|
||||
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers)
|
||||
if (!authResult.ok) return authResult.response
|
||||
const actorUserId = authResult.userId
|
||||
const actorUser = authResult.user
|
||||
|
||||
// Restore and reclaim have different parameter shapes, handle them separately
|
||||
if (action === 'restore') {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers)
|
||||
if (!admin.ok) return admin.response
|
||||
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers)
|
||||
}
|
||||
|
||||
if (action === 'reclaim') {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers)
|
||||
if (!admin.ok) return admin.response
|
||||
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers)
|
||||
}
|
||||
|
||||
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
|
||||
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
|
||||
const reasonRaw = typeof payload.reason === 'string' ? payload.reason.trim() : ''
|
||||
if (!handleRaw && !userIdRaw) {
|
||||
return text('Missing userId or handle', 400, rate.headers)
|
||||
}
|
||||
|
||||
const roleRaw = typeof payload.role === 'string' ? payload.role.trim().toLowerCase() : ''
|
||||
if (action === 'role' && !roleRaw) {
|
||||
return text('Missing role', 400, rate.headers)
|
||||
}
|
||||
const role = roleRaw === 'user' || roleRaw === 'moderator' || roleRaw === 'admin' ? roleRaw : null
|
||||
if (action === 'role' && !role) {
|
||||
return text('Invalid role', 400, rate.headers)
|
||||
}
|
||||
|
||||
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
|
||||
if (!targetUserId) {
|
||||
const handle = handleRaw.toLowerCase()
|
||||
const user = await ctx.runQuery(api.users.getByHandle, { handle })
|
||||
if (!user?._id) return text('User not found', 404, rate.headers)
|
||||
targetUserId = user._id
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
const reason = reasonRaw.length > 0 ? reasonRaw : undefined
|
||||
if (reason && reason.length > 500) {
|
||||
return text('Reason too long (max 500 chars)', 400, rate.headers)
|
||||
}
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
reason,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Ban failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return text(message, 404, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return text('Invalid role', 400, rate.headers)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.users.setRoleInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
role,
|
||||
})
|
||||
return json({ ok: true, role: result.role ?? role }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Role change failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return text(message, 404, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/restore
|
||||
* Admin-only: restore skills from GitHub backup for a user.
|
||||
* Body: { handle: string, slugs: string[], forceOverwriteSquatter?: boolean }
|
||||
*/
|
||||
async function handleAdminRestore(
|
||||
ctx: ActionCtx,
|
||||
_request: Request,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<'users'>,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
|
||||
if (!handle) return text('Missing handle', 400, headers)
|
||||
|
||||
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
|
||||
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
|
||||
if (slugs.length > 100) return text('Too many slugs (max 100)', 400, headers)
|
||||
|
||||
const forceOverwriteSquatter = Boolean(payload.forceOverwriteSquatter)
|
||||
|
||||
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
|
||||
if (!targetUser?._id) return text('User not found', 404, headers)
|
||||
|
||||
try {
|
||||
const result = await ctx.runAction(internal.githubRestore.restoreUserSkillsFromBackup, {
|
||||
actorUserId,
|
||||
ownerHandle: handle,
|
||||
ownerUserId: targetUser._id,
|
||||
slugs,
|
||||
forceOverwriteSquatter,
|
||||
})
|
||||
return json(result, 200, headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Restore failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, headers)
|
||||
}
|
||||
return text(message, 400, headers)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/reclaim
|
||||
* Admin-only: reclaim squatted slugs and reserve them for the rightful owner.
|
||||
* Body: { handle: string, slugs: string[], reason?: string }
|
||||
*/
|
||||
async function handleAdminReclaim(
|
||||
ctx: ActionCtx,
|
||||
_request: Request,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<'users'>,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
|
||||
if (!handle) return text('Missing handle', 400, headers)
|
||||
|
||||
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
|
||||
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
|
||||
if (slugs.length > 200) return text('Too many slugs (max 200)', 400, headers)
|
||||
|
||||
const reason = typeof payload.reason === 'string' ? payload.reason.trim() : undefined
|
||||
|
||||
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
|
||||
if (!targetUser?._id) return text('User not found', 404, headers)
|
||||
|
||||
const results: Array<{ slug: string; ok: boolean; error?: string }> = []
|
||||
for (const slug of slugs) {
|
||||
try {
|
||||
await ctx.runMutation(internal.skills.reclaimSlugInternal, {
|
||||
actorUserId,
|
||||
slug: slug.trim().toLowerCase(),
|
||||
rightfulOwnerUserId: targetUser._id,
|
||||
reason,
|
||||
})
|
||||
results.push({ slug, ok: true })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Reclaim failed'
|
||||
results.push({ slug, ok: false, error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.ok).length
|
||||
const failed = results.filter((r) => !r.ok).length
|
||||
|
||||
return json({ ok: true, results, succeeded, failed }, 200, headers)
|
||||
}
|
||||
|
||||
export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limitRaw = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const query = url.searchParams.get('q') ?? url.searchParams.get('query') ?? ''
|
||||
|
||||
let actorUserId: Id<'users'>
|
||||
try {
|
||||
const auth = await requireApiTokenUser(ctx, request)
|
||||
actorUserId = auth.userId
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(limitRaw ?? 20, 1), 200)
|
||||
try {
|
||||
const result = await ctx.runQuery(internal.users.searchInternal, {
|
||||
actorUserId,
|
||||
query,
|
||||
limit,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'User search failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('unauthorized')) {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { requireApiTokenUser } from '../lib/apiTokenAuth'
|
||||
import { applyRateLimit } from '../lib/httpRateLimit'
|
||||
import { json, text } from './shared'
|
||||
|
||||
export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
try {
|
||||
const { user } = await requireApiTokenUser(ctx, request)
|
||||
return json(
|
||||
{
|
||||
user: {
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
image: user.image ?? null,
|
||||
},
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
)
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
import type { Scheduler } from 'convex/server'
|
||||
|
||||
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
|
||||
scheduler: Scheduler,
|
||||
fn: unknown,
|
||||
args: TArgs,
|
||||
isDone: boolean,
|
||||
continueCursor: string | null,
|
||||
) {
|
||||
if (isDone) return
|
||||
void scheduler.runAfter(0, fn as never, {
|
||||
...args,
|
||||
cursor: continueCursor ?? undefined,
|
||||
} as never)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const EXT_TO_TYPE: Record<string, string> = {
|
||||
md: 'text/markdown',
|
||||
mdx: 'text/markdown',
|
||||
json: 'application/json',
|
||||
json5: 'application/json',
|
||||
yaml: 'application/yaml',
|
||||
yml: 'application/yaml',
|
||||
toml: 'application/toml',
|
||||
svg: 'image/svg+xml',
|
||||
}
|
||||
|
||||
export function guessContentTypeForPath(path: string) {
|
||||
const trimmed = path.trim().toLowerCase()
|
||||
if (!trimmed) return 'application/octet-stream'
|
||||
const ext = trimmed.split('.').at(-1) ?? ''
|
||||
return EXT_TO_TYPE[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type EmbeddingVisibility =
|
||||
| 'latest'
|
||||
| 'latest-approved'
|
||||
| 'archived'
|
||||
| 'archived-approved'
|
||||
| 'deleted'
|
||||
|
||||
export function embeddingVisibilityFor(isLatest: boolean, isApproved: boolean): Exclude<
|
||||
EmbeddingVisibility,
|
||||
'deleted'
|
||||
> {
|
||||
if (isLatest && isApproved) return 'latest-approved'
|
||||
if (isLatest) return 'latest'
|
||||
if (isApproved) return 'archived-approved'
|
||||
return 'archived'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { internal } from '../_generated/api'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
import { requireGitHubAccountAge, syncGitHubProfile } from './githubAccount'
|
||||
|
||||
vi.mock('../_generated/api', () => ({
|
||||
internal: {
|
||||
githubIdentity: {
|
||||
getGitHubProviderAccountIdInternal: Symbol('getGitHubProviderAccountIdInternal'),
|
||||
},
|
||||
users: {
|
||||
getByIdInternal: Symbol('getByIdInternal'),
|
||||
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
|
||||
setGitHubCreatedAtInternal: Symbol('setGitHubCreatedAtInternal'),
|
||||
syncGitHubProfileInternal: Symbol('syncGitHubProfileInternal'),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -19,21 +23,23 @@ describe('requireGitHubAccountAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses cached githubCreatedAt when fresh', async () => {
|
||||
it('uses cached githubCreatedAt when present', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
@@ -44,53 +50,55 @@ describe('requireGitHubAccountAge', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
|
||||
expect(runQuery).not.toHaveBeenCalledWith(
|
||||
internal.githubIdentity.getGitHubProviderAccountIdInternal,
|
||||
{ userId: 'users:1' },
|
||||
)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
it('rejects deactivated users', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
deactivatedAt: Date.now(),
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/User not found/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects accounts younger than 7 days', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'newbie',
|
||||
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects deactivated users', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
deactivatedAt: Date.now(),
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/User not found/i)
|
||||
})
|
||||
|
||||
it('refreshes githubCreatedAt when cache is stale', async () => {
|
||||
it('fetches githubCreatedAt when missing (by providerAccountId)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -103,27 +111,60 @@ describe('requireGitHubAccountAge', () => {
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
'https://api.github.com/user/12345',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'User-Agent': 'clawhub' }),
|
||||
}),
|
||||
)
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.setGitHubCreatedAtInternal, {
|
||||
userId: 'users:1',
|
||||
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
|
||||
githubFetchedAt: now.getTime(),
|
||||
})
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
it('rejects when providerAccountId is missing', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account required/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when providerAccountId is invalid', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('abc123')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when GitHub lookup fails', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -134,12 +175,12 @@ describe('requireGitHubAccountAge', () => {
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 403', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 403 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -150,12 +191,12 @@ describe('requireGitHubAccountAge', () => {
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 429', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -165,6 +206,25 @@ describe('requireGitHubAccountAge', () => {
|
||||
).rejects.toThrow(/rate limit exceeded/i)
|
||||
})
|
||||
|
||||
it('throws when GitHub returns an invalid payload', async () => {
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
})
|
||||
|
||||
it('includes Authorization header when GITHUB_TOKEN is set', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
@@ -172,12 +232,12 @@ describe('requireGitHubAccountAge', () => {
|
||||
|
||||
vi.stubEnv('GITHUB_TOKEN', 'ghp_test123')
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
githubCreatedAt: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -190,7 +250,7 @@ describe('requireGitHubAccountAge', () => {
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
'https://api.github.com/user/12345',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': 'clawhub',
|
||||
@@ -198,7 +258,139 @@ describe('requireGitHubAccountAge', () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncGitHubProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('skips recent syncs (throttle)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
name: 'oldname',
|
||||
githubProfileSyncedAt: now.getTime(),
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates profile even when only avatar changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
name: 'same',
|
||||
image: 'https://avatars.githubusercontent.com/u/1?v=3',
|
||||
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
login: 'same',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
|
||||
userId: 'users:1',
|
||||
name: 'same',
|
||||
image: 'https://avatars.githubusercontent.com/u/1?v=4',
|
||||
syncedAt: now.getTime(),
|
||||
})
|
||||
})
|
||||
|
||||
it('updates name and records sync timestamp', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
name: 'old',
|
||||
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
login: 'new',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=1',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
|
||||
userId: 'users:1',
|
||||
name: 'new',
|
||||
image: 'https://avatars.githubusercontent.com/u/1?v=1',
|
||||
syncedAt: now.getTime(),
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards GitHub profile name (full name) when present', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: 'users:1',
|
||||
name: 'same',
|
||||
githubProfileSyncedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
})
|
||||
.mockResolvedValueOnce('12345')
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
login: 'same',
|
||||
name: 'Real Name',
|
||||
avatar_url: 'https://avatars.githubusercontent.com/u/1?v=1',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await syncGitHubProfile({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.syncGitHubProfileInternal, {
|
||||
userId: 'users:1',
|
||||
name: 'same',
|
||||
image: 'https://avatars.githubusercontent.com/u/1?v=1',
|
||||
profileName: 'Real Name',
|
||||
syncedAt: now.getTime(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+88
-15
@@ -2,36 +2,54 @@ import { ConvexError } from 'convex/values'
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { GITHUB_PROFILE_SYNC_WINDOW_MS } from './githubProfileSync'
|
||||
|
||||
const GITHUB_API = 'https://api.github.com'
|
||||
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
type GitHubUser = {
|
||||
login?: string
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function assertGitHubNumericId(providerAccountId: string) {
|
||||
if (!/^[0-9]+$/.test(providerAccountId)) {
|
||||
throw new ConvexError('GitHub account lookup failed')
|
||||
}
|
||||
}
|
||||
|
||||
function buildGitHubHeaders() {
|
||||
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError('User not found')
|
||||
|
||||
const handle = user.handle?.trim()
|
||||
if (!handle) throw new ConvexError('GitHub handle required')
|
||||
|
||||
const now = Date.now()
|
||||
let createdAt = user.githubCreatedAt ?? null
|
||||
const fetchedAt = user.githubFetchedAt ?? 0
|
||||
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
|
||||
|
||||
if (stale) {
|
||||
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
if (!createdAt) {
|
||||
const providerAccountId = await ctx.runQuery(
|
||||
internal.githubIdentity.getGitHubProviderAccountIdInternal,
|
||||
{ userId },
|
||||
)
|
||||
if (!providerAccountId) {
|
||||
// Invariant: GitHub is our only auth provider, so this should never happen.
|
||||
throw new ConvexError('GitHub account required')
|
||||
}
|
||||
assertGitHubNumericId(providerAccountId)
|
||||
|
||||
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
|
||||
headers,
|
||||
// Fetch by immutable GitHub numeric ID to avoid username swap attacks entirely.
|
||||
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
|
||||
headers: buildGitHubHeaders(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 403 || response.status === 429) {
|
||||
@@ -45,10 +63,9 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
|
||||
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
createdAt = parsed
|
||||
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
|
||||
await ctx.runMutation(internal.users.setGitHubCreatedAtInternal, {
|
||||
userId,
|
||||
githubCreatedAt: createdAt,
|
||||
githubFetchedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,3 +82,59 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the user's GitHub profile (username, avatar) from the GitHub API.
|
||||
* This handles the case where a user renames their GitHub account.
|
||||
* Uses the immutable GitHub numeric ID to fetch the current profile.
|
||||
*/
|
||||
export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return
|
||||
|
||||
const now = Date.now()
|
||||
const lastSyncedAt = user.githubProfileSyncedAt ?? null
|
||||
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return
|
||||
|
||||
const providerAccountId = await ctx.runQuery(
|
||||
internal.githubIdentity.getGitHubProviderAccountIdInternal,
|
||||
{ userId },
|
||||
)
|
||||
if (!providerAccountId) return
|
||||
|
||||
assertGitHubNumericId(providerAccountId)
|
||||
|
||||
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
|
||||
headers: buildGitHubHeaders(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Silently fail - this is a best-effort sync, not critical path
|
||||
console.warn(`[syncGitHubProfile] GitHub API error for user ${userId}: ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GitHubUser
|
||||
const newLogin = payload.login?.trim()
|
||||
const newImage = payload.avatar_url?.trim()
|
||||
const profileName = payload.name?.trim()
|
||||
|
||||
if (!newLogin) return
|
||||
|
||||
const args: {
|
||||
userId: Id<'users'>
|
||||
name: string
|
||||
image?: string
|
||||
syncedAt: number
|
||||
profileName?: string
|
||||
} = {
|
||||
userId,
|
||||
name: newLogin,
|
||||
image: newImage,
|
||||
syncedAt: now,
|
||||
}
|
||||
if (profileName && profileName !== newLogin) {
|
||||
args.profileName = profileName
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.users.syncGitHubProfileInternal, args)
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
export const GITHUB_PROFILE_SYNC_WINDOW_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
export function shouldScheduleGitHubProfileSync(
|
||||
user:
|
||||
| {
|
||||
deletedAt?: number
|
||||
deactivatedAt?: number
|
||||
githubProfileSyncedAt?: number
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
now: number,
|
||||
) {
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return false
|
||||
const lastSyncedAt = user.githubProfileSyncedAt ?? null
|
||||
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GitHubBackupContext } from './githubBackup'
|
||||
import { readGitHubBackupFile } from './githubRestoreHelpers'
|
||||
|
||||
function makeContext(): GitHubBackupContext {
|
||||
return {
|
||||
token: 'token',
|
||||
repo: 'owner/repo',
|
||||
repoOwner: 'owner',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
root: 'skills',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('githubRestoreHelpers', () => {
|
||||
it('decodes base64 payloads (including newlines) into bytes', async () => {
|
||||
const content = 'SGVs\n bG8h' // "Hello!" with whitespace/newline
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ content, encoding: 'base64' }),
|
||||
text: async () => '',
|
||||
})),
|
||||
)
|
||||
|
||||
const bytes = await readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')
|
||||
expect(bytes).not.toBeNull()
|
||||
expect(Buffer.from(bytes!).toString('utf8')).toBe('Hello!')
|
||||
})
|
||||
|
||||
it('throws on unsupported GitHub content encoding', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ content: 'eA==', encoding: 'utf-16' }),
|
||||
text: async () => '',
|
||||
})),
|
||||
)
|
||||
|
||||
await expect(readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')).rejects.toThrow(
|
||||
/Unsupported GitHub content encoding/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
'use node'
|
||||
|
||||
import type { GitHubBackupContext } from './githubBackup'
|
||||
|
||||
const GITHUB_API = 'https://api.github.com'
|
||||
const META_FILENAME = '_meta.json'
|
||||
const USER_AGENT = 'clawhub/skills-restore'
|
||||
|
||||
type GitHubContentsEntry = {
|
||||
name?: string
|
||||
path?: string
|
||||
type?: string // 'file' | 'dir'
|
||||
size?: number
|
||||
}
|
||||
|
||||
type GitHubBlobResponse = {
|
||||
content?: string
|
||||
encoding?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a skill's backup directory (excluding _meta.json).
|
||||
* Uses the Contents API scoped to the target directory instead of fetching
|
||||
* the entire repository tree, which is critical for bulk restore performance.
|
||||
* Returns relative file paths (e.g. "SKILL.md", "lib/helper.ts").
|
||||
*/
|
||||
export async function listGitHubBackupFiles(
|
||||
context: GitHubBackupContext,
|
||||
ownerHandle: string,
|
||||
slug: string,
|
||||
): Promise<string[]> {
|
||||
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
|
||||
return listFilesRecursive(context, skillRoot, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively list files under a directory using the GitHub Contents API.
|
||||
* Each call is scoped to one directory, avoiding full-repo tree downloads.
|
||||
*/
|
||||
async function listFilesRecursive(
|
||||
context: GitHubBackupContext,
|
||||
basePath: string,
|
||||
relativePath: string,
|
||||
): Promise<string[]> {
|
||||
const dirPath = relativePath ? `${basePath}/${relativePath}` : basePath
|
||||
|
||||
try {
|
||||
const entries = await githubGet<GitHubContentsEntry[]>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(dirPath)}?ref=${context.branch}`,
|
||||
)
|
||||
|
||||
if (!Array.isArray(entries)) return []
|
||||
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.name || !entry.type) continue
|
||||
|
||||
const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name
|
||||
|
||||
if (entry.type === 'file') {
|
||||
// Skip the meta file
|
||||
if (entry.name === META_FILENAME) continue
|
||||
files.push(entryRelative)
|
||||
} else if (entry.type === 'dir') {
|
||||
// Recurse into subdirectories
|
||||
const subFiles = await listFilesRecursive(context, basePath, entryRelative)
|
||||
files.push(...subFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single file from the GitHub backup repository.
|
||||
* Returns the file content as a Uint8Array, or null if not found.
|
||||
*/
|
||||
export async function readGitHubBackupFile(
|
||||
context: GitHubBackupContext,
|
||||
ownerHandle: string,
|
||||
slug: string,
|
||||
filePath: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
|
||||
const fullPath = `${skillRoot}/${filePath}`
|
||||
|
||||
try {
|
||||
const response = await githubGet<GitHubBlobResponse>(
|
||||
context.token,
|
||||
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(fullPath)}?ref=${context.branch}`,
|
||||
)
|
||||
|
||||
if (!response.content) return null
|
||||
|
||||
if (response.encoding && response.encoding !== 'base64') {
|
||||
throw new Error(`Unsupported GitHub content encoding: ${response.encoding}`)
|
||||
}
|
||||
|
||||
return fromBase64Bytes(response.content)
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function buildSkillRoot(root: string, ownerHandle: string, slug: string) {
|
||||
const ownerSegment = normalizeOwner(ownerHandle)
|
||||
return `${root}/${ownerSegment}/${slug}`
|
||||
}
|
||||
|
||||
function normalizeOwner(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function encodePath(path: string) {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
function fromBase64Bytes(value: string) {
|
||||
// GitHub may include newlines in the base64 payload.
|
||||
const normalized = value.replace(/\s/g, '')
|
||||
return new Uint8Array(Buffer.from(normalized, 'base64'))
|
||||
}
|
||||
|
||||
async function githubGet<T>(token: string, path: string): Promise<T> {
|
||||
const response = await fetch(`${GITHUB_API}${path}`, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(`GitHub GET ${path} failed: ${message}`)
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error && (error.message.includes('404') || error.message.includes('Not Found'))
|
||||
)
|
||||
}
|
||||
@@ -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,19 +1,31 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getClientIp } from './httpRateLimit'
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('uses forwarded headers by default when cf-connecting-ip is missing', () => {
|
||||
let prev: string | undefined
|
||||
beforeEach(() => {
|
||||
prev = process.env.TRUST_FORWARDED_IPS
|
||||
})
|
||||
afterEach(() => {
|
||||
if (prev === undefined) {
|
||||
delete process.env.TRUST_FORWARDED_IPS
|
||||
} else {
|
||||
process.env.TRUST_FORWARDED_IPS = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when cf-connecting-ip is missing (CF-only default)', () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
},
|
||||
})
|
||||
delete process.env.TRUST_FORWARDED_IPS
|
||||
expect(getClientIp(request)).toBe('203.0.113.9')
|
||||
expect(getClientIp(request)).toBeNull()
|
||||
})
|
||||
|
||||
it('can disable forwarded headers explicitly', () => {
|
||||
it('keeps forwarded headers disabled when TRUST_FORWARDED_IPS=false', () => {
|
||||
const request = new Request('https://example.com', {
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
@@ -21,7 +33,6 @@ describe('getClientIp', () => {
|
||||
})
|
||||
process.env.TRUST_FORWARDED_IPS = 'false'
|
||||
expect(getClientIp(request)).toBeNull()
|
||||
delete process.env.TRUST_FORWARDED_IPS
|
||||
})
|
||||
|
||||
it('returns first ip from cf-connecting-ip', () => {
|
||||
@@ -41,6 +52,5 @@ describe('getClientIp', () => {
|
||||
})
|
||||
process.env.TRUST_FORWARDED_IPS = 'true'
|
||||
expect(getClientIp(request)).toBe('203.0.113.9')
|
||||
delete process.env.TRUST_FORWARDED_IPS
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { internal } from '../_generated/api'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { corsHeaders, mergeHeaders } from './httpHeaders'
|
||||
import { hashToken } from './tokens'
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000
|
||||
@@ -42,6 +43,7 @@ export async function applyRateLimit(
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
headers,
|
||||
corsHeaders(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
@@ -141,17 +143,14 @@ function splitFirstIp(header: string | null) {
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function mergeHeaders(base: HeadersInit, extra?: HeadersInit) {
|
||||
return { ...(base as Record<string, string>), ...(extra as Record<string, string>) }
|
||||
}
|
||||
|
||||
function shouldTrustForwardedIps() {
|
||||
const value = String(process.env.TRUST_FORWARDED_IPS ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!value) return true
|
||||
// Hardening default: CF-only. Forwarded headers are trivial to spoof unless you
|
||||
// control the trusted proxy layer.
|
||||
if (!value) return false
|
||||
if (value === '1' || value === 'true' || value === 'yes') return true
|
||||
if (value === '0' || value === 'false' || value === 'no') return false
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from '../_generated/server'
|
||||
|
||||
type ReservedSlug = Doc<'reservedSlugs'>
|
||||
|
||||
const DEFAULT_ACTIVE_LIMIT = 25
|
||||
|
||||
function reservedSlugQuery(ctx: QueryCtx | MutationCtx, slug: string) {
|
||||
return ctx.db
|
||||
.query('reservedSlugs')
|
||||
.withIndex('by_slug_active_deletedAt', (q) => q.eq('slug', slug).eq('releasedAt', undefined))
|
||||
.order('desc')
|
||||
}
|
||||
|
||||
export async function listActiveReservedSlugsForSlug(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
slug: string,
|
||||
limit = DEFAULT_ACTIVE_LIMIT,
|
||||
) {
|
||||
return reservedSlugQuery(ctx, slug).take(limit)
|
||||
}
|
||||
|
||||
export async function getLatestActiveReservedSlug(ctx: QueryCtx | MutationCtx, slug: string) {
|
||||
return (await reservedSlugQuery(ctx, slug).take(1))[0] ?? null
|
||||
}
|
||||
|
||||
export async function releaseDuplicateActiveReservations(
|
||||
ctx: MutationCtx,
|
||||
active: ReservedSlug[],
|
||||
keepId: Id<'reservedSlugs'> | null | undefined,
|
||||
releasedAt: number,
|
||||
) {
|
||||
for (const stale of active) {
|
||||
if (keepId && stale._id === keepId) continue
|
||||
await ctx.db.patch(stale._id, { releasedAt })
|
||||
}
|
||||
}
|
||||
|
||||
export async function reserveSlugForHardDeleteFinalize(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
slug: string
|
||||
originalOwnerUserId: Id<'users'>
|
||||
deletedAt: number
|
||||
expiresAt: number
|
||||
},
|
||||
) {
|
||||
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
|
||||
const latest = active[0] ?? null
|
||||
|
||||
if (latest) {
|
||||
// Only extend reservation if it matches the owner being deleted.
|
||||
// If it points elsewhere, it likely came from a reclaim flow; do not overwrite.
|
||||
if (latest.originalOwnerUserId === params.originalOwnerUserId) {
|
||||
await ctx.db.patch(latest._id, {
|
||||
deletedAt: params.deletedAt,
|
||||
expiresAt: params.expiresAt,
|
||||
releasedAt: undefined,
|
||||
})
|
||||
}
|
||||
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.deletedAt)
|
||||
return
|
||||
}
|
||||
|
||||
const inserted = await ctx.db.insert('reservedSlugs', {
|
||||
slug: params.slug,
|
||||
originalOwnerUserId: params.originalOwnerUserId,
|
||||
deletedAt: params.deletedAt,
|
||||
expiresAt: params.expiresAt,
|
||||
})
|
||||
await releaseDuplicateActiveReservations(ctx, active, inserted, params.deletedAt)
|
||||
}
|
||||
|
||||
export async function upsertReservedSlugForRightfulOwner(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
slug: string
|
||||
rightfulOwnerUserId: Id<'users'>
|
||||
deletedAt: number
|
||||
expiresAt: number
|
||||
reason?: string
|
||||
},
|
||||
) {
|
||||
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
|
||||
const latest = active[0] ?? null
|
||||
|
||||
let keepId: Id<'reservedSlugs'>
|
||||
if (latest) {
|
||||
keepId = latest._id
|
||||
await ctx.db.patch(latest._id, {
|
||||
originalOwnerUserId: params.rightfulOwnerUserId,
|
||||
deletedAt: params.deletedAt,
|
||||
expiresAt: params.expiresAt,
|
||||
reason: params.reason ?? latest.reason,
|
||||
releasedAt: undefined,
|
||||
})
|
||||
} else {
|
||||
keepId = await ctx.db.insert('reservedSlugs', {
|
||||
slug: params.slug,
|
||||
originalOwnerUserId: params.rightfulOwnerUserId,
|
||||
deletedAt: params.deletedAt,
|
||||
expiresAt: params.expiresAt,
|
||||
reason: params.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await releaseDuplicateActiveReservations(ctx, active, keepId, params.deletedAt)
|
||||
}
|
||||
|
||||
export async function enforceReservedSlugCooldownForNewSkill(
|
||||
ctx: MutationCtx,
|
||||
params: { slug: string; userId: Id<'users'>; now: number },
|
||||
) {
|
||||
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
|
||||
const latest = active[0] ?? null
|
||||
if (!latest) return
|
||||
|
||||
if (latest.expiresAt > params.now && latest.originalOwnerUserId !== params.userId) {
|
||||
throw new Error(
|
||||
`Slug "${params.slug}" is reserved for its previous owner until ${new Date(latest.expiresAt).toISOString()}. ` +
|
||||
'Please choose a different slug.',
|
||||
)
|
||||
}
|
||||
|
||||
await ctx.db.patch(latest._id, { releasedAt: params.now })
|
||||
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
|
||||
}
|
||||
|
||||
@@ -77,4 +77,29 @@ description: Expert guidance for sushi-rolls.
|
||||
expect(quality.decision).toBe('reject')
|
||||
expect(quality.reason).toContain('template spam')
|
||||
})
|
||||
|
||||
it('does not undercount non-latin skill docs', () => {
|
||||
const signals = __test.computeQualitySignals({
|
||||
readmeText: `# 飞书图片助手
|
||||
## 核心能力
|
||||
- 上传本地图片到飞书并自动返回 image_key,避免重复上传浪费配额。
|
||||
- 支持群聊与私聊,自动识别目标类型并校验参数,减少调用错误。
|
||||
- 提供重试与错误分类,方便排查网络问题、权限问题与资源限制。
|
||||
## 使用说明
|
||||
先配置应用凭证,然后传入目标会话与文件路径。技能会先检查缓存,再执行上传,并在发送阶段附带日志说明,便于团队追踪。
|
||||
如果出现失败,输出会包含建议动作,例如补齐权限、检查文件大小、确认机器人是否在群内,以及如何重放请求。
|
||||
还会记录每一步耗时、返回码与上下文摘要,方便后续做性能分析、告警聚合和批量回放,避免同类问题反复出现。
|
||||
`,
|
||||
summary: '上传并发送图片到飞书,支持缓存、重试和错误诊断。',
|
||||
})
|
||||
|
||||
const quality = __test.evaluateQuality({
|
||||
signals,
|
||||
trustTier: 'low',
|
||||
similarRecentCount: 0,
|
||||
})
|
||||
|
||||
expect(signals.bodyWords).toBeGreaterThanOrEqual(45)
|
||||
expect(quality.decision).toBe('pass')
|
||||
})
|
||||
})
|
||||
|
||||
+39
-17
@@ -19,6 +19,7 @@ import { generateSkillSummary } from './skillSummary'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
getFrontmatterMetadata,
|
||||
getFrontmatterValue,
|
||||
hashSkillFiles,
|
||||
isTextFile,
|
||||
parseClawdisMetadata,
|
||||
@@ -63,10 +64,19 @@ export type PublishVersionArgs = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type PublishOptions = {
|
||||
bypassGitHubAccountAge?: boolean
|
||||
bypassNewSkillRateLimit?: boolean
|
||||
bypassQualityGate?: boolean
|
||||
skipBackup?: boolean
|
||||
skipWebhook?: boolean
|
||||
}
|
||||
|
||||
export async function publishVersionForUser(
|
||||
ctx: ActionCtx,
|
||||
userId: Id<'users'>,
|
||||
args: PublishVersionArgs,
|
||||
options: PublishOptions = {},
|
||||
): Promise<PublishResult> {
|
||||
const version = args.version.trim()
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
@@ -79,7 +89,9 @@ export async function publishVersionForUser(
|
||||
throw new ConvexError('Version must be valid semver')
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
if (!options.bypassGitHubAccountAge) {
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
}
|
||||
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
|
||||
slug,
|
||||
})) as Doc<'skills'> | null
|
||||
@@ -122,13 +134,18 @@ export async function publishVersionForUser(
|
||||
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now()
|
||||
const now = Date.now()
|
||||
const frontmatterMetadata = getFrontmatterMetadata(frontmatter)
|
||||
const summaryFromFrontmatter =
|
||||
// Check for description in metadata.description (nested) or description (direct frontmatter field)
|
||||
const metadataDescription =
|
||||
frontmatterMetadata &&
|
||||
typeof frontmatterMetadata === 'object' &&
|
||||
!Array.isArray(frontmatterMetadata) &&
|
||||
typeof (frontmatterMetadata as Record<string, unknown>).description === 'string'
|
||||
? ((frontmatterMetadata as Record<string, unknown>).description as string)
|
||||
: undefined
|
||||
const directDescription = getFrontmatterValue(frontmatter, 'description')
|
||||
// Prioritize the new description from frontmatter over the existing skill summary
|
||||
// This ensures updates to the description are reflected on subsequent publishes (#301)
|
||||
const summaryFromFrontmatter = metadataDescription ?? directDescription
|
||||
const summary = await generateSkillSummary({
|
||||
slug,
|
||||
displayName,
|
||||
@@ -137,7 +154,7 @@ export async function publishVersionForUser(
|
||||
})
|
||||
|
||||
let qualityAssessment: QualityAssessment | null = null
|
||||
if (isNewSkill) {
|
||||
if (isNewSkill && !options.bypassQualityGate) {
|
||||
const ownerActivity = (await ctx.runQuery(internal.skills.getOwnerSkillActivityInternal, {
|
||||
ownerUserId: userId,
|
||||
limit: QUALITY_ACTIVITY_LIMIT,
|
||||
@@ -240,6 +257,7 @@ export async function publishVersionForUser(
|
||||
version: args.forkOf.version?.trim() || undefined,
|
||||
}
|
||||
: undefined,
|
||||
bypassNewSkillRateLimit: options.bypassNewSkillRateLimit || undefined,
|
||||
files: safeFiles.map((file) => ({
|
||||
...file,
|
||||
path: file.path,
|
||||
@@ -273,24 +291,28 @@ export async function publishVersionForUser(
|
||||
|
||||
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
|
||||
|
||||
void ctx.scheduler
|
||||
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
|
||||
if (!options.skipBackup) {
|
||||
void ctx.scheduler
|
||||
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
|
||||
slug,
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: safeFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('GitHub backup scheduling failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
if (!options.skipWebhook) {
|
||||
void schedulePublishWebhook(ctx, {
|
||||
slug,
|
||||
version,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
files: safeFiles,
|
||||
publishedAt: Date.now(),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('GitHub backup scheduling failed', error)
|
||||
})
|
||||
|
||||
void schedulePublishWebhook(ctx, {
|
||||
slug,
|
||||
version,
|
||||
displayName,
|
||||
})
|
||||
}
|
||||
|
||||
return publishResult
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
+18
-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()),
|
||||
@@ -19,6 +17,8 @@ const users = defineTable({
|
||||
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
|
||||
githubCreatedAt: v.optional(v.number()),
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
deactivatedAt: v.optional(v.number()),
|
||||
purgedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
@@ -96,6 +96,7 @@ const skills = defineTable({
|
||||
bulletCount: v.number(),
|
||||
templateMarkerHits: v.number(),
|
||||
genericSummary: v.boolean(),
|
||||
cjkChars: v.optional(v.number()),
|
||||
}),
|
||||
evaluatedAt: v.number(),
|
||||
}),
|
||||
@@ -501,6 +502,19 @@ const downloadDedupes = defineTable({
|
||||
.index('by_skill_identity_hour', ['skillId', 'identityHash', 'hourStart'])
|
||||
.index('by_hour', ['hourStart'])
|
||||
|
||||
const reservedSlugs = defineTable({
|
||||
slug: v.string(),
|
||||
originalOwnerUserId: v.id('users'),
|
||||
deletedAt: v.number(),
|
||||
expiresAt: v.number(),
|
||||
reason: v.optional(v.string()),
|
||||
releasedAt: v.optional(v.number()),
|
||||
})
|
||||
.index('by_slug', ['slug'])
|
||||
.index('by_slug_active_deletedAt', ['slug', 'releasedAt', 'deletedAt'])
|
||||
.index('by_owner', ['originalOwnerUserId'])
|
||||
.index('by_expiry', ['expiresAt'])
|
||||
|
||||
const githubBackupSyncState = defineTable({
|
||||
key: v.string(),
|
||||
cursor: v.optional(v.string()),
|
||||
@@ -546,7 +560,7 @@ const userSkillRootInstalls = defineTable({
|
||||
.index('by_skill', ['skillId'])
|
||||
|
||||
export default defineSchema({
|
||||
...authSchema,
|
||||
...authTables,
|
||||
users,
|
||||
skills,
|
||||
souls,
|
||||
@@ -572,6 +586,7 @@ export default defineSchema({
|
||||
apiTokens,
|
||||
rateLimits,
|
||||
downloadDedupes,
|
||||
reservedSlugs,
|
||||
githubBackupSyncState,
|
||||
userSyncRoots,
|
||||
userSkillInstalls,
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('search helpers', () => {
|
||||
skill: makePublicSkill({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' }),
|
||||
version: null,
|
||||
ownerHandle: 'steipete',
|
||||
owner: null,
|
||||
},
|
||||
]
|
||||
const runQuery = vi
|
||||
@@ -154,6 +155,7 @@ describe('search helpers', () => {
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'one',
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
embeddingId: 'skillEmbeddings:b',
|
||||
@@ -165,6 +167,7 @@ describe('search helpers', () => {
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'two',
|
||||
owner: null,
|
||||
},
|
||||
]
|
||||
const fallbackEntries = [
|
||||
@@ -177,6 +180,7 @@ describe('search helpers', () => {
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'one',
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
@@ -187,6 +191,7 @@ describe('search helpers', () => {
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'three',
|
||||
owner: null,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
+38
-28
@@ -1,18 +1,36 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { QueryCtx } from './_generated/server'
|
||||
import { action, internalQuery } from './_generated/server'
|
||||
import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
|
||||
import { generateEmbedding } from './lib/embeddings'
|
||||
import { toPublicSkill, toPublicSoul } from './lib/public'
|
||||
import { toPublicSkill, toPublicSoul, toPublicUser } from './lib/public'
|
||||
import { matchesExactTokens, tokenize } from './lib/searchText'
|
||||
import { isSkillSuspicious } from './lib/skillSafety'
|
||||
|
||||
type OwnerInfo = { handle: string | null; owner: ReturnType<typeof toPublicUser> | null }
|
||||
|
||||
function makeOwnerInfoGetter(ctx: Pick<QueryCtx, 'db'>) {
|
||||
const ownerCache = new Map<Id<'users'>, Promise<OwnerInfo>>()
|
||||
return (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => ({
|
||||
handle: ownerDoc?.handle ?? (ownerDoc?._id ? String(ownerDoc._id) : null),
|
||||
owner: toPublicUser(ownerDoc),
|
||||
}))
|
||||
ownerCache.set(ownerUserId, ownerPromise)
|
||||
return ownerPromise
|
||||
}
|
||||
}
|
||||
|
||||
type SkillSearchEntry = {
|
||||
embeddingId?: Id<'skillEmbeddings'>
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>
|
||||
version: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
owner: ReturnType<typeof toPublicUser> | null
|
||||
}
|
||||
|
||||
type SearchResult = SkillSearchEntry & { score: number }
|
||||
@@ -211,17 +229,7 @@ export const hydrateResults = internalQuery({
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db
|
||||
.get(ownerUserId)
|
||||
.then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
const getOwnerInfo = makeOwnerInfoGetter(ctx)
|
||||
|
||||
const entries: Array<SkillSearchEntry | null> = await Promise.all(
|
||||
args.embeddingIds.map(async (embeddingId) => {
|
||||
@@ -230,13 +238,19 @@ export const hydrateResults = internalQuery({
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (!skill || skill.softDeletedAt) return null
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
const [version, ownerInfo] = await Promise.all([
|
||||
ctx.db.get(embedding.versionId),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
return { embeddingId, skill: publicSkill, version, ownerHandle }
|
||||
return {
|
||||
embeddingId,
|
||||
skill: publicSkill,
|
||||
version,
|
||||
ownerHandle: ownerInfo.handle,
|
||||
owner: ownerInfo.owner,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -291,26 +305,22 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
)
|
||||
if (matched.length === 0) return []
|
||||
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db
|
||||
.get(ownerUserId)
|
||||
.then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
const getOwnerInfo = makeOwnerInfoGetter(ctx)
|
||||
|
||||
const entries = await Promise.all(
|
||||
matched.map(async (skill) => {
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
const [version, ownerInfo] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : Promise.resolve(null),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
return { skill: publicSkill, version, ownerHandle }
|
||||
return {
|
||||
skill: publicSkill,
|
||||
version,
|
||||
ownerHandle: ownerInfo.handle,
|
||||
owner: ownerInfo.owner,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const validEntries = entries.filter((entry): entry is SkillSearchEntry => entry !== null)
|
||||
|
||||
@@ -84,6 +84,16 @@ describe('skills anti-spam guards', () => {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (table === 'reservedSlugs') {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name === 'by_slug_active_deletedAt') {
|
||||
return { order: () => ({ take: async () => [] }) }
|
||||
}
|
||||
throw new Error(`unexpected index ${name}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`)
|
||||
}),
|
||||
}
|
||||
@@ -93,7 +103,7 @@ describe('skills anti-spam guards', () => {
|
||||
).rejects.toThrow(/max 5 new skills per hour/i)
|
||||
})
|
||||
|
||||
it('auto-hides suspicious skills from low-trust publishers', async () => {
|
||||
it('keeps suspicious skills visible for low-trust publishers', async () => {
|
||||
const patch = vi.fn(async () => {})
|
||||
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
|
||||
const skill = {
|
||||
@@ -147,7 +157,7 @@ describe('skills anti-spam guards', () => {
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{
|
||||
sha256hash: 'h'.repeat(64),
|
||||
scanner: 'llm',
|
||||
scanner: 'vt',
|
||||
status: 'suspicious',
|
||||
} as never,
|
||||
)
|
||||
@@ -155,8 +165,9 @@ describe('skills anti-spam guards', () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
'skills:1',
|
||||
expect.objectContaining({
|
||||
moderationStatus: 'hidden',
|
||||
moderationReason: 'scanner.llm.suspicious',
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'scanner.vt.suspicious',
|
||||
moderationFlags: ['flagged.suspicious'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+432
-120
@@ -1,7 +1,6 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { paginationOptsValidator } from 'convex/server'
|
||||
import { ConvexError, v } from 'convex/values'
|
||||
import { paginator } from 'convex-helpers/server/pagination'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx, QueryCtx } from './_generated/server'
|
||||
@@ -21,9 +20,21 @@ import {
|
||||
type SkillBadgeMap,
|
||||
} from './lib/badges'
|
||||
import { generateChangelogPreview as buildChangelogPreview } from './lib/changelog'
|
||||
import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
} from './lib/githubIdentity'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
import { deriveModerationFlags } from './lib/moderation'
|
||||
import { toPublicSkill, toPublicUser } from './lib/public'
|
||||
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
|
||||
import { scheduleNextBatchIfNeeded } from './lib/batching'
|
||||
import {
|
||||
enforceReservedSlugCooldownForNewSkill,
|
||||
getLatestActiveReservedSlug,
|
||||
reserveSlugForHardDeleteFinalize,
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from './lib/reservedSlugs'
|
||||
import {
|
||||
fetchText,
|
||||
type PublishResult,
|
||||
@@ -32,7 +43,6 @@ import {
|
||||
} from './lib/skillPublish'
|
||||
import { isSkillSuspicious } from './lib/skillSafety'
|
||||
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
|
||||
import schema from './schema'
|
||||
|
||||
export { publishVersionForUser } from './lib/skillPublish'
|
||||
|
||||
@@ -48,11 +58,14 @@ const MAX_BADGE_LOOKUP_SKILLS = 200
|
||||
const HARD_DELETE_BATCH_SIZE = 100
|
||||
const HARD_DELETE_VERSION_BATCH_SIZE = 10
|
||||
const HARD_DELETE_LEADERBOARD_BATCH_SIZE = 25
|
||||
const BAN_USER_SKILLS_BATCH_SIZE = 25
|
||||
const MAX_ACTIVE_REPORTS_PER_USER = 20
|
||||
const AUTO_HIDE_REPORT_THRESHOLD = 3
|
||||
const MAX_REPORT_REASON_SAMPLE = 5
|
||||
const RATE_LIMIT_HOUR_MS = 60 * 60 * 1000
|
||||
const RATE_LIMIT_DAY_MS = 24 * RATE_LIMIT_HOUR_MS
|
||||
const SLUG_RESERVATION_DAYS = 90
|
||||
const SLUG_RESERVATION_MS = SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS
|
||||
const LOW_TRUST_ACCOUNT_AGE_MS = 30 * RATE_LIMIT_DAY_MS
|
||||
const TRUSTED_PUBLISHER_SKILL_THRESHOLD = 10
|
||||
const LOW_TRUST_BURST_THRESHOLD_PER_HOUR = 8
|
||||
@@ -153,12 +166,6 @@ function enforceNewSkillRateLimit(signals: OwnerTrustSignals) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
|
||||
const owner = await ctx.db.get(ownerUserId)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) return null
|
||||
return owner?.handle ?? owner?._id ?? null
|
||||
}
|
||||
|
||||
const HARD_DELETE_PHASES = [
|
||||
'versions',
|
||||
'fingerprints',
|
||||
@@ -435,6 +442,13 @@ async function hardDeleteSkillStep(
|
||||
return
|
||||
}
|
||||
case 'finalize': {
|
||||
await reserveSlugForHardDeleteFinalize(ctx, {
|
||||
slug: skill.slug,
|
||||
originalOwnerUserId: skill.ownerUserId,
|
||||
deletedAt: now,
|
||||
expiresAt: now + SLUG_RESERVATION_MS,
|
||||
})
|
||||
|
||||
await ctx.db.delete(skill._id)
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId,
|
||||
@@ -453,6 +467,7 @@ type PublicSkillEntry = {
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>
|
||||
latestVersion: PublicSkillListVersion | null
|
||||
ownerHandle: string | null
|
||||
owner: ReturnType<typeof toPublicUser> | null
|
||||
}
|
||||
|
||||
type PublicSkillListVersion = Pick<
|
||||
@@ -477,7 +492,10 @@ type ManagementSkillEntry = {
|
||||
type BadgeKind = Doc<'skillBadges'>['kind']
|
||||
|
||||
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
const ownerInfoCache = new Map<
|
||||
Id<'users'>,
|
||||
Promise<{ ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null }>
|
||||
>()
|
||||
const badgeMapBySkillId: Map<Id<'skills'>, SkillBadgeMap> = skills.length <=
|
||||
MAX_BADGE_LOOKUP_SKILLS
|
||||
? await getSkillBadgeMaps(
|
||||
@@ -486,25 +504,38 @@ async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
)
|
||||
: new Map()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
const getOwnerInfo = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerInfoCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => {
|
||||
if (!ownerDoc || ownerDoc.deletedAt || ownerDoc.deactivatedAt) {
|
||||
return { ownerHandle: null, owner: null }
|
||||
}
|
||||
return {
|
||||
ownerHandle: ownerDoc.handle ?? (ownerDoc._id ? String(ownerDoc._id) : null),
|
||||
owner: toPublicUser(ownerDoc),
|
||||
}
|
||||
})
|
||||
ownerInfoCache.set(ownerUserId, ownerPromise)
|
||||
return ownerPromise
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const [latestVersionDoc, ownerHandle] = await Promise.all([
|
||||
const [latestVersionDoc, ownerInfo] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
getOwnerInfo(skill.ownerUserId),
|
||||
])
|
||||
const badges = badgeMapBySkillId.get(skill._id) ?? {}
|
||||
const publicSkill = toPublicSkill({ ...skill, badges })
|
||||
if (!publicSkill) return null
|
||||
const latestVersion = toPublicSkillListVersion(latestVersionDoc)
|
||||
return { skill: publicSkill, latestVersion, ownerHandle }
|
||||
return {
|
||||
skill: publicSkill,
|
||||
latestVersion,
|
||||
ownerHandle: ownerInfo.ownerHandle,
|
||||
owner: ownerInfo.owner,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -774,6 +805,13 @@ export const getBySlugForStaff = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const getReservedSlugInternal = internalQuery({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return getLatestActiveReservedSlug(ctx, args.slug)
|
||||
},
|
||||
})
|
||||
|
||||
export const getSkillBySlugInternal = internalQuery({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1185,6 +1223,15 @@ export const listWithLatest = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const listHighlightedPublic = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 12, 1, MAX_PUBLIC_LIST_LIMIT)
|
||||
const skills = await loadHighlightedSkills(ctx, limit)
|
||||
return buildPublicSkillEntries(ctx, skills)
|
||||
},
|
||||
})
|
||||
|
||||
export const listForManagement = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
@@ -1418,16 +1465,7 @@ export const report = mutation({
|
||||
await ctx.db.patch(skill._id, updates)
|
||||
|
||||
if (shouldAutoHide) {
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: 'deleted',
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: userId,
|
||||
@@ -1505,10 +1543,9 @@ export const listPublicPage = query({
|
||||
})
|
||||
|
||||
/**
|
||||
* V2 of listPublicPage using convex-helpers paginator for better cache behavior.
|
||||
* V2 of listPublicPage using standard Convex pagination (paginate + usePaginatedQuery).
|
||||
*
|
||||
* Key differences from V1:
|
||||
* - Uses `paginator` from convex-helpers (doesn't track end-cursor internally, better caching)
|
||||
* - Uses `by_active_updated` index to filter soft-deleted skills at query level
|
||||
* - Returns standard pagination shape compatible with usePaginatedQuery
|
||||
*/
|
||||
@@ -1530,15 +1567,15 @@ export const listPublicPageV2 = query({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const sort = args.sort ?? 'newest'
|
||||
const dir = args.dir ?? 'desc'
|
||||
const paginationOpts = {
|
||||
const dir = args.dir ?? (sort === 'name' ? 'asc' : 'desc')
|
||||
const paginationOpts: { cursor: string | null; numItems: number; id?: number } = {
|
||||
...args.paginationOpts,
|
||||
numItems: clampInt(args.paginationOpts.numItems, 1, MAX_PUBLIC_LIST_LIMIT),
|
||||
}
|
||||
|
||||
// Use the index to filter out soft-deleted skills at query time.
|
||||
// softDeletedAt === undefined means active (non-deleted) skills only.
|
||||
const result = await paginator(ctx.db, schema)
|
||||
const result = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex(SORT_INDEXES[sort], (q) => q.eq('softDeletedAt', undefined))
|
||||
.order(dir)
|
||||
@@ -1550,11 +1587,7 @@ export const listPublicPageV2 = query({
|
||||
|
||||
// Build the public skill entries (fetch latestVersion + ownerHandle)
|
||||
const items = await buildPublicSkillEntries(ctx, filteredPage)
|
||||
|
||||
return {
|
||||
...result,
|
||||
page: items,
|
||||
}
|
||||
return { ...result, page: items }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1630,6 +1663,14 @@ export const getVersionById = query({
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getVersionsByIdsInternal = internalQuery({
|
||||
args: { versionIds: v.array(v.id('skillVersions')) },
|
||||
handler: async (ctx, args) => {
|
||||
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
|
||||
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionByIdInternal = internalQuery({
|
||||
args: { versionId: v.id('skillVersions') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
@@ -1659,6 +1700,7 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
const reason = skill.moderationReason
|
||||
if (skill.moderationStatus === 'hidden' && reason === 'pending.scan') return true
|
||||
if (skill.moderationStatus === 'hidden' && reason === 'quality.low') return true
|
||||
if (skill.moderationStatus === 'active' && reason === 'pending.scan') return true
|
||||
if (skill.moderationStatus === 'active' && reason === 'scanner.vt.pending') return true
|
||||
return (
|
||||
reason === 'scanner.llm.clean' ||
|
||||
@@ -2094,6 +2136,174 @@ export const setSkillModerationStatusActiveInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
async function listSkillEmbeddingsForSkill(ctx: MutationCtx, skillId: Id<'skills'>) {
|
||||
return ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skillId))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async function markSkillEmbeddingsDeleted(ctx: MutationCtx, skillId: Id<'skills'>, now: number) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
|
||||
for (const embedding of embeddings) {
|
||||
if (embedding.visibility === 'deleted') continue
|
||||
await ctx.db.patch(embedding._id, { visibility: 'deleted', updatedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSkillEmbeddingsVisibility(ctx: MutationCtx, skillId: Id<'skills'>, now: number) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
|
||||
for (const embedding of embeddings) {
|
||||
const visibility = embeddingVisibilityFor(embedding.isLatest, embedding.isApproved)
|
||||
await ctx.db.patch(embedding._id, { visibility, updatedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
async function setSkillEmbeddingsSoftDeleted(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<'skills'>,
|
||||
deleted: boolean,
|
||||
now: number,
|
||||
) {
|
||||
if (deleted) {
|
||||
await markSkillEmbeddingsDeleted(ctx, skillId, now)
|
||||
return
|
||||
}
|
||||
|
||||
await restoreSkillEmbeddingsVisibility(ctx, skillId, now)
|
||||
}
|
||||
|
||||
async function setSkillEmbeddingsLatestVersion(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<'skills'>,
|
||||
latestVersionId: Id<'skillVersions'>,
|
||||
now: number,
|
||||
) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
|
||||
for (const embedding of embeddings) {
|
||||
const isLatest = embedding.versionId === latestVersionId
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest,
|
||||
visibility: embeddingVisibilityFor(isLatest, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setSkillEmbeddingsApproved(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<'skills'>,
|
||||
approved: boolean,
|
||||
now: number,
|
||||
) {
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skillId)
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isApproved: approved,
|
||||
visibility: embeddingVisibilityFor(embedding.isLatest, approved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id('users'),
|
||||
bannedAt: v.number(),
|
||||
hiddenBy: v.optional(v.id('users')),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_SKILLS_BATCH_SIZE })
|
||||
|
||||
let hiddenCount = 0
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue
|
||||
|
||||
// Only overwrite moderation fields for active skills. Keep existing hidden/removed
|
||||
// moderation reasons intact.
|
||||
const shouldMarkModeration = (skill.moderationStatus ?? 'active') === 'active'
|
||||
|
||||
const patch: Partial<Doc<'skills'>> = { softDeletedAt: args.bannedAt, updatedAt: args.bannedAt }
|
||||
if (shouldMarkModeration) {
|
||||
patch.moderationStatus = 'hidden'
|
||||
patch.moderationReason = 'user.banned'
|
||||
patch.hiddenAt = args.bannedAt
|
||||
patch.hiddenBy = args.hiddenBy
|
||||
patch.lastReviewedAt = args.bannedAt
|
||||
hiddenCount += 1
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt)
|
||||
}
|
||||
|
||||
scheduleNextBatchIfNeeded(
|
||||
ctx.scheduler,
|
||||
internal.skills.applyBanToOwnedSkillsBatchInternal,
|
||||
args,
|
||||
isDone,
|
||||
continueCursor,
|
||||
)
|
||||
|
||||
return { ok: true as const, hiddenCount, scheduled: !isDone }
|
||||
},
|
||||
})
|
||||
|
||||
export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id('users'),
|
||||
bannedAt: v.number(),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_SKILLS_BATCH_SIZE })
|
||||
|
||||
let restoredCount = 0
|
||||
for (const skill of page) {
|
||||
if (
|
||||
!skill.softDeletedAt ||
|
||||
skill.softDeletedAt !== args.bannedAt ||
|
||||
skill.moderationReason !== 'user.banned'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'restored.unban',
|
||||
hiddenAt: undefined,
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now)
|
||||
restoredCount += 1
|
||||
}
|
||||
|
||||
scheduleNextBatchIfNeeded(
|
||||
ctx.scheduler,
|
||||
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
|
||||
args,
|
||||
isDone,
|
||||
continueCursor,
|
||||
)
|
||||
|
||||
return { ok: true as const, restoredCount, scheduled: !isDone }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Get legacy skills that are active but still have "pending.scan" reason.
|
||||
* These need to be scanned through VT to get proper verdicts.
|
||||
@@ -2332,20 +2542,8 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let shouldHideSuspicious = false
|
||||
if (isSuspicious && !alreadyBlocked && !bypassSuspicious) {
|
||||
if (owner && !owner.deletedAt && !owner.deactivatedAt) {
|
||||
const trustSignals = await getOwnerTrustSignals(ctx, owner, now)
|
||||
shouldHideSuspicious = trustSignals.isLowTrust
|
||||
}
|
||||
}
|
||||
|
||||
const qualityLocked = skill.moderationReason === 'quality.low' && !isMalicious
|
||||
const nextModerationStatus = qualityLocked
|
||||
? 'hidden'
|
||||
: shouldHideSuspicious
|
||||
? 'hidden'
|
||||
: 'active'
|
||||
const nextModerationStatus = qualityLocked ? 'hidden' : 'active'
|
||||
const nextModerationReason = qualityLocked
|
||||
? 'quality.low'
|
||||
: bypassSuspicious
|
||||
@@ -2354,9 +2552,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
const nextModerationNotes = qualityLocked
|
||||
? (skill.moderationNotes ??
|
||||
'Quality gate quarantine is still active. Manual moderation review required.')
|
||||
: shouldHideSuspicious
|
||||
? 'Auto-hidden: suspicious result from low-trust publisher.'
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
moderationStatus: nextModerationStatus,
|
||||
@@ -2631,25 +2827,15 @@ export const updateTags = mutation({
|
||||
}
|
||||
|
||||
const latestEntry = args.tags.find((entry) => entry.tag === 'latest')
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(skill._id, {
|
||||
tags: nextTags,
|
||||
latestVersionId: latestEntry ? latestEntry.versionId : skill.latestVersionId,
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (latestEntry) {
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
const isLatest = embedding.versionId === latestEntry.versionId
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest,
|
||||
visibility: visibilityFor(isLatest, embedding.isApproved),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
await setSkillEmbeddingsLatestVersion(ctx, skill._id, latestEntry.versionId, now)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -2675,17 +2861,7 @@ export const setRedactionApproved = mutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isApproved: args.approved,
|
||||
visibility: visibilityFor(embedding.isLatest, args.approved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
await setSkillEmbeddingsApproved(ctx, skill._id, args.approved, now)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
@@ -2754,18 +2930,7 @@ export const setSoftDeleted = mutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: args.deleted
|
||||
? 'deleted'
|
||||
: visibilityFor(embedding.isLatest, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
@@ -2799,10 +2964,7 @@ export const changeOwner = mutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id)
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
ownerId: args.ownerUserId,
|
||||
@@ -2821,6 +2983,133 @@ export const changeOwner = mutation({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Admin-only: reclaim a squatted slug by hard-deleting the squatter's skill
|
||||
* and reserving the slug for the rightful owner.
|
||||
*/
|
||||
export const reclaimSlug = mutation({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
rightfulOwnerUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertAdmin(user)
|
||||
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
if (!slug) throw new Error('Slug required')
|
||||
|
||||
const rightfulOwner = await ctx.db.get(args.rightfulOwnerUserId)
|
||||
if (!rightfulOwner) throw new Error('Rightful owner not found')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Check if slug is currently occupied by someone else
|
||||
const existingSkill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slug))
|
||||
.unique()
|
||||
|
||||
if (existingSkill) {
|
||||
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
|
||||
return { ok: true as const, action: 'already_owned' }
|
||||
}
|
||||
|
||||
// Hard-delete the squatter's skill
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: existingSkill._id,
|
||||
actorUserId: user._id,
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'slug.reclaim',
|
||||
targetType: 'skill',
|
||||
targetId: existingSkill._id,
|
||||
metadata: {
|
||||
slug,
|
||||
squatterUserId: existingSkill.ownerUserId,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
reason: args.reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
await upsertReservedSlugForRightfulOwner(ctx, {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
deletedAt: now,
|
||||
expiresAt: now + SLUG_RESERVATION_MS,
|
||||
reason: args.reason || 'slug.reclaimed',
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
action: existingSkill ? 'reclaimed_from_squatter' : 'reserved',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Admin-only: reclaim slugs in bulk. Useful for recovering multiple squatted slugs at once.
|
||||
*/
|
||||
export const reclaimSlugInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
slug: v.string(),
|
||||
rightfulOwnerUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
|
||||
assertAdmin(actor)
|
||||
|
||||
const slug = args.slug.trim().toLowerCase()
|
||||
if (!slug) throw new Error('Slug required')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const existingSkill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slug))
|
||||
.unique()
|
||||
|
||||
if (existingSkill && existingSkill.ownerUserId !== args.rightfulOwnerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: existingSkill._id,
|
||||
actorUserId: args.actorUserId,
|
||||
})
|
||||
}
|
||||
|
||||
await upsertReservedSlugForRightfulOwner(ctx, {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
deletedAt: now,
|
||||
expiresAt: now + SLUG_RESERVATION_MS,
|
||||
reason: args.reason || 'slug.reclaimed',
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: 'slug.reclaim',
|
||||
targetType: 'slug',
|
||||
targetId: slug,
|
||||
metadata: {
|
||||
slug,
|
||||
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
||||
hadSquatter: Boolean(existingSkill && existingSkill.ownerUserId !== args.rightfulOwnerUserId),
|
||||
reason: args.reason || undefined,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
|
||||
export const setDuplicate = mutation({
|
||||
args: { skillId: v.id('skills'), canonicalSlug: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -2980,6 +3269,7 @@ export const insertVersion = internalMutation({
|
||||
changelogSource: v.optional(v.union(v.literal('auto'), v.literal('user'))),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
fingerprint: v.string(),
|
||||
bypassNewSkillRateLimit: v.optional(v.boolean()),
|
||||
forkOf: v.optional(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
@@ -3016,6 +3306,7 @@ export const insertVersion = internalMutation({
|
||||
bulletCount: v.number(),
|
||||
templateMarkerHits: v.number(),
|
||||
genericSummary: v.boolean(),
|
||||
cjkChars: v.optional(v.number()),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -3026,22 +3317,56 @@ export const insertVersion = internalMutation({
|
||||
const user = await ctx.db.get(userId)
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
let skill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
|
||||
.unique()
|
||||
|
||||
if (skill && skill.ownerUserId !== userId) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
// Fallback: Convex Auth can create duplicate `users` records. Heal ownership ONLY
|
||||
// when the underlying GitHub identity matches (authAccounts.providerAccountId).
|
||||
const owner = await ctx.db.get(skill.ownerUserId)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
|
||||
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
|
||||
getGitHubProviderAccountId(ctx, skill.ownerUserId),
|
||||
getGitHubProviderAccountId(ctx, userId),
|
||||
])
|
||||
|
||||
// Deny healing when GitHub identity isn't present/consistent.
|
||||
if (
|
||||
!canHealSkillOwnershipByGitHubProviderAccountId(
|
||||
ownerProviderAccountId,
|
||||
callerProviderAccountId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Only the owner can publish updates')
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
|
||||
skill = { ...skill, ownerUserId: userId }
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const qualityAssessment = args.qualityAssessment
|
||||
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
|
||||
|
||||
// Trusted publishers (and moderators/admins) bypass auto-hide for pending scans.
|
||||
// Keep moderationReason as pending.scan so the VT poller keeps working.
|
||||
const isTrustedPublisher = Boolean(
|
||||
user.trustedPublisher || user.role === 'admin' || user.role === 'moderator',
|
||||
)
|
||||
const initialModerationStatus =
|
||||
isTrustedPublisher && !isQualityQuarantine ? 'active' : 'hidden'
|
||||
|
||||
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
|
||||
const moderationNotes = isQualityQuarantine
|
||||
? `Auto-quarantined by quality gate (score=${qualityAssessment.score}, tier=${qualityAssessment.trustTier}, similar=${qualityAssessment.similarRecentCount}).`
|
||||
: undefined
|
||||
|
||||
const qualityRecord = qualityAssessment
|
||||
? {
|
||||
score: qualityAssessment.score,
|
||||
@@ -3055,8 +3380,13 @@ export const insertVersion = internalMutation({
|
||||
: undefined
|
||||
|
||||
if (!skill) {
|
||||
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
|
||||
enforceNewSkillRateLimit(ownerTrustSignals)
|
||||
// Anti-squatting: enforce reserved slug cooldown.
|
||||
await enforceReservedSlugCooldownForNewSkill(ctx, { slug: args.slug, userId, now })
|
||||
|
||||
if (!args.bypassNewSkillRateLimit) {
|
||||
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
|
||||
enforceNewSkillRateLimit(ownerTrustSignals)
|
||||
}
|
||||
|
||||
const forkOfSlug = args.forkOf?.slug.trim().toLowerCase() || ''
|
||||
const forkOfVersion = args.forkOf?.version?.trim() || undefined
|
||||
@@ -3119,7 +3449,7 @@ export const insertVersion = internalMutation({
|
||||
official: undefined,
|
||||
deprecated: undefined,
|
||||
},
|
||||
moderationStatus: 'hidden',
|
||||
moderationStatus: initialModerationStatus,
|
||||
moderationReason,
|
||||
moderationNotes,
|
||||
quality: qualityRecord,
|
||||
@@ -3190,7 +3520,7 @@ export const insertVersion = internalMutation({
|
||||
tags: nextTags,
|
||||
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'hidden',
|
||||
moderationStatus: initialModerationStatus,
|
||||
moderationReason,
|
||||
moderationNotes,
|
||||
quality: qualityRecord ?? skill.quality,
|
||||
@@ -3208,7 +3538,7 @@ export const insertVersion = internalMutation({
|
||||
embedding: args.embedding,
|
||||
isLatest: true,
|
||||
isApproved,
|
||||
visibility: visibilityFor(true, isApproved),
|
||||
visibility: embeddingVisibilityFor(true, isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -3220,7 +3550,7 @@ export const insertVersion = internalMutation({
|
||||
if (previousEmbedding) {
|
||||
await ctx.db.patch(previousEmbedding._id, {
|
||||
isLatest: false,
|
||||
visibility: visibilityFor(false, previousEmbedding.isApproved),
|
||||
visibility: embeddingVisibilityFor(false, previousEmbedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
@@ -3270,18 +3600,7 @@ export const setSkillSoftDeletedInternal = internalMutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query('skillEmbeddings')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
for (const embedding of embeddings) {
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: args.deleted
|
||||
? 'deleted'
|
||||
: visibilityFor(embedding.isLatest, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.userId,
|
||||
@@ -3296,13 +3615,6 @@ export const setSkillSoftDeletedInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
function visibilityFor(isLatest: boolean, isApproved: boolean) {
|
||||
if (isLatest && isApproved) return 'latest-approved'
|
||||
if (isLatest) return 'latest'
|
||||
if (isApproved) return 'archived-approved'
|
||||
return 'archived'
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const rounded = Number.isFinite(value) ? Math.round(value) : min
|
||||
return Math.min(max, Math.max(min, rounded))
|
||||
|
||||
+13
-11
@@ -3,6 +3,7 @@ import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertModerator, requireUser, requireUserFromAction } from './lib/access'
|
||||
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
|
||||
import { toPublicSoul, toPublicUser } from './lib/public'
|
||||
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
|
||||
import { generateSoulChangelogPreview } from './lib/soulChangelog'
|
||||
@@ -145,6 +146,14 @@ export const getVersionById = query({
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
})
|
||||
|
||||
export const getVersionsByIdsInternal = internalQuery({
|
||||
args: { versionIds: v.array(v.id('soulVersions')) },
|
||||
handler: async (ctx, args) => {
|
||||
const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id)))
|
||||
return versions.filter((v): v is NonNullable<typeof v> => v !== null)
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionByIdInternal = internalQuery({
|
||||
args: { versionId: v.id('soulVersions') },
|
||||
handler: async (ctx, args) => ctx.db.get(args.versionId),
|
||||
@@ -349,7 +358,7 @@ export const updateTags = mutation({
|
||||
const isLatest = embedding.versionId === latestEntry.versionId
|
||||
await ctx.db.patch(embedding._id, {
|
||||
isLatest,
|
||||
visibility: visibilityFor(isLatest, embedding.isApproved),
|
||||
visibility: embeddingVisibilityFor(isLatest, embedding.isApproved),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
@@ -471,7 +480,7 @@ export const insertVersion = internalMutation({
|
||||
embedding: args.embedding,
|
||||
isLatest: true,
|
||||
isApproved: true,
|
||||
visibility: visibilityFor(true, true),
|
||||
visibility: embeddingVisibilityFor(true, true),
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -483,7 +492,7 @@ export const insertVersion = internalMutation({
|
||||
if (previousEmbedding) {
|
||||
await ctx.db.patch(previousEmbedding._id, {
|
||||
isLatest: false,
|
||||
visibility: visibilityFor(false, previousEmbedding.isApproved),
|
||||
visibility: embeddingVisibilityFor(false, previousEmbedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
@@ -539,7 +548,7 @@ export const setSoulSoftDeletedInternal = internalMutation({
|
||||
await ctx.db.patch(embedding._id, {
|
||||
visibility: args.deleted
|
||||
? 'deleted'
|
||||
: visibilityFor(embedding.isLatest, embedding.isApproved),
|
||||
: embeddingVisibilityFor(embedding.isLatest, embedding.isApproved),
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
@@ -557,13 +566,6 @@ export const setSoulSoftDeletedInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
function visibilityFor(isLatest: boolean, isApproved: boolean) {
|
||||
if (isLatest && isApproved) return 'latest-approved'
|
||||
if (isLatest) return 'latest'
|
||||
if (isApproved) return 'archived-approved'
|
||||
return 'archived'
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const rounded = Number.isFinite(value) ? Math.round(value) : min
|
||||
return Math.min(max, Math.max(min, rounded))
|
||||
|
||||
+21
-3
@@ -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),
|
||||
})
|
||||
})
|
||||
})
|
||||
+312
-54
@@ -2,9 +2,10 @@ import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import type { ActionCtx, MutationCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertAdmin, assertModerator, requireUser } from './lib/access'
|
||||
import { syncGitHubProfile } from './lib/githubAccount'
|
||||
import { toPublicUser } from './lib/public'
|
||||
import { buildUserSearchResults } from './lib/userSearch'
|
||||
|
||||
@@ -46,21 +47,96 @@ export const searchInternal = internalQuery({
|
||||
},
|
||||
})
|
||||
|
||||
export const updateGithubMetaInternal = internalMutation({
|
||||
export const setGitHubCreatedAtInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
githubCreatedAt: v.number(),
|
||||
githubFetchedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.userId, {
|
||||
githubCreatedAt: args.githubCreatedAt,
|
||||
githubFetchedAt: args.githubFetchedAt,
|
||||
updatedAt: args.githubFetchedAt,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Sync the user's GitHub profile (username, avatar) when it changes.
|
||||
* This handles the case where a user renames their GitHub account.
|
||||
*/
|
||||
export const syncGitHubProfileInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
name: v.string(),
|
||||
image: v.optional(v.string()),
|
||||
profileName: v.optional(v.string()),
|
||||
syncedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId)
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return
|
||||
|
||||
const updates: Partial<Doc<'users'>> = { githubProfileSyncedAt: args.syncedAt }
|
||||
let didChangeProfile = false
|
||||
|
||||
if (user.name !== args.name) {
|
||||
updates.name = args.name
|
||||
didChangeProfile = true
|
||||
}
|
||||
|
||||
// Update handle if it was derived from the old username
|
||||
if (user.handle === user.name && user.name !== args.name) {
|
||||
updates.handle = args.name
|
||||
didChangeProfile = true
|
||||
}
|
||||
|
||||
// Update displayName if it was derived from the old username
|
||||
if (
|
||||
(user.displayName === user.name || user.displayName === user.handle) &&
|
||||
user.name !== args.name
|
||||
) {
|
||||
updates.displayName = args.name
|
||||
didChangeProfile = true
|
||||
}
|
||||
|
||||
// If displayName is derived/missing, prefer the GitHub profile "name" (full name).
|
||||
const profileName = args.profileName?.trim()
|
||||
if (profileName && profileName !== args.name) {
|
||||
const currentDisplay = user.displayName?.trim()
|
||||
const currentHandle = user.handle?.trim()
|
||||
const currentLogin = user.name?.trim()
|
||||
const isDerivedOrMissing =
|
||||
!currentDisplay || currentDisplay === currentHandle || currentDisplay === currentLogin
|
||||
if (isDerivedOrMissing && currentDisplay !== profileName) {
|
||||
updates.displayName = profileName
|
||||
didChangeProfile = true
|
||||
}
|
||||
}
|
||||
|
||||
// Update avatar if provided
|
||||
if (args.image && args.image !== user.image) {
|
||||
updates.image = args.image
|
||||
didChangeProfile = true
|
||||
}
|
||||
|
||||
if (didChangeProfile) {
|
||||
updates.updatedAt = Date.now()
|
||||
}
|
||||
await ctx.db.patch(args.userId, updates)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Internal action to sync GitHub profile from the GitHub API.
|
||||
* This is called after login to ensure the username is up-to-date.
|
||||
*/
|
||||
export const syncGitHubProfileAction = internalAction({
|
||||
args: { userId: v.id('users') },
|
||||
handler: async (ctx: ActionCtx, args) => {
|
||||
await syncGitHubProfile(ctx, args.userId)
|
||||
},
|
||||
})
|
||||
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -74,27 +150,65 @@ export const me = query({
|
||||
|
||||
export const ensure = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const handle = user.handle || user.name || user.email?.split('@')[0]
|
||||
if (!user.handle && handle) updates.handle = handle
|
||||
if (!user.displayName) updates.displayName = handle
|
||||
if (!user.role) {
|
||||
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now()
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
return ctx.db.get(userId)
|
||||
},
|
||||
handler: ensureHandler,
|
||||
})
|
||||
|
||||
function normalizeHandle(handle: string | undefined) {
|
||||
const normalized = handle?.trim()
|
||||
return normalized ? normalized : undefined
|
||||
}
|
||||
|
||||
function deriveHandle(args: { existingHandle?: string; githubLogin?: string; email?: string }) {
|
||||
// Prefer the GitHub login; only fall back to email-derived handle when we don't already have one.
|
||||
if (args.githubLogin) return args.githubLogin
|
||||
if (!args.existingHandle && args.email) return args.email.split('@')[0]?.trim() || undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
function computeEnsureUpdates(user: Doc<'users'>) {
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const existingHandle = normalizeHandle(user.handle)
|
||||
const githubLogin = normalizeHandle(user.name)
|
||||
const derivedHandle = deriveHandle({
|
||||
existingHandle,
|
||||
githubLogin,
|
||||
email: user.email,
|
||||
})
|
||||
const baseHandle = derivedHandle ?? existingHandle
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
updates.handle = derivedHandle
|
||||
}
|
||||
|
||||
const displayName = normalizeHandle(user.displayName)
|
||||
if (!displayName && baseHandle) {
|
||||
updates.displayName = baseHandle
|
||||
} else if (derivedHandle && displayName === existingHandle) {
|
||||
updates.displayName = derivedHandle
|
||||
}
|
||||
|
||||
if (!user.role) {
|
||||
updates.role = baseHandle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
export async function ensureHandler(ctx: MutationCtx) {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const updates = computeEnsureUpdates(user)
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now()
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
return ctx.db.get(userId)
|
||||
}
|
||||
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
displayName: v.string(),
|
||||
@@ -143,7 +257,6 @@ export const deleteAccount = mutation({
|
||||
isAnonymous: undefined,
|
||||
bio: undefined,
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId })
|
||||
@@ -241,6 +354,27 @@ export const banUserInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const unbanUser = mutation({
|
||||
args: { userId: v.id('users'), reason: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
return unbanUserWithActor(ctx, user, args.userId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
export const unbanUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
targetUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
|
||||
return unbanUserWithActor(ctx, actor, args.targetUserId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
async function banUserWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
@@ -266,24 +400,26 @@ async function banUserWithActor(
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
|
||||
}
|
||||
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
|
||||
.collect()
|
||||
|
||||
for (const skill of skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: skill._id,
|
||||
actorUserId: actor._id,
|
||||
})
|
||||
}
|
||||
const banSkillsResult = (await ctx.runMutation(
|
||||
internal.skills.applyBanToOwnedSkillsBatchInternal,
|
||||
{
|
||||
ownerUserId: targetUserId,
|
||||
bannedAt: now,
|
||||
hiddenBy: actor._id,
|
||||
cursor: undefined,
|
||||
},
|
||||
)) as { hiddenCount?: number; scheduled?: boolean }
|
||||
const hiddenCount = banSkillsResult.hiddenCount ?? 0
|
||||
const scheduledSkills = banSkillsResult.scheduled ?? false
|
||||
|
||||
const tokens = await ctx.db
|
||||
.query('apiTokens')
|
||||
.withIndex('by_user', (q) => q.eq('userId', targetUserId))
|
||||
.collect()
|
||||
for (const token of tokens) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now })
|
||||
if (!token.revokedAt) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(targetUserId, {
|
||||
@@ -300,13 +436,136 @@ async function banUserWithActor(
|
||||
action: 'user.ban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { deletedSkills: skills.length, reason: reason || undefined },
|
||||
metadata: { hiddenSkills: hiddenCount, reason: reason || undefined },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
|
||||
}
|
||||
|
||||
async function unbanUserWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
targetUserId: Id<'users'>,
|
||||
reasonRaw?: string,
|
||||
) {
|
||||
assertAdmin(actor)
|
||||
if (targetUserId === actor._id) throw new Error('Cannot unban yourself')
|
||||
|
||||
const target = await ctx.db.get(targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
if (target.deactivatedAt) {
|
||||
throw new Error('Cannot unban a permanently deleted account')
|
||||
}
|
||||
if (!target.deletedAt) {
|
||||
return { ok: true as const, alreadyUnbanned: true }
|
||||
}
|
||||
|
||||
const reason = reasonRaw?.trim()
|
||||
if (reason && reason.length > 500) {
|
||||
throw new Error('Reason too long (max 500 chars)')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const bannedAt = target.deletedAt
|
||||
await ctx.db.patch(targetUserId, {
|
||||
deletedAt: undefined,
|
||||
banReason: undefined,
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const restoreSkillsResult = (await ctx.runMutation(
|
||||
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
|
||||
{
|
||||
ownerUserId: targetUserId,
|
||||
bannedAt,
|
||||
cursor: undefined,
|
||||
},
|
||||
)) as { restoredCount?: number; scheduled?: boolean }
|
||||
const restoredCount = restoreSkillsResult.restoredCount ?? 0
|
||||
const scheduledSkills = restoreSkillsResult.scheduled ?? false
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'user.unban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { reason: reason || undefined, restoredSkills: restoredCount },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyUnbanned: false, restoredSkills: restoredCount, scheduledSkills }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only: set or unset the trustedPublisher flag for a user.
|
||||
* Trusted publishers bypass the pending.scan auto-hide for new skill publishes.
|
||||
*/
|
||||
export const setTrustedPublisher = mutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
trusted: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertAdmin(user)
|
||||
|
||||
const target = await ctx.db.get(args.userId)
|
||||
if (!target) throw new Error('User not found')
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(args.userId, {
|
||||
trustedPublisher: args.trusted || undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: args.trusted ? 'user.trusted.set' : 'user.trusted.unset',
|
||||
targetType: 'user',
|
||||
targetId: args.userId,
|
||||
metadata: { trusted: args.trusted },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, trusted: args.trusted }
|
||||
},
|
||||
})
|
||||
|
||||
export const setTrustedPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
targetUserId: v.id('users'),
|
||||
trusted: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('User not found')
|
||||
assertAdmin(actor)
|
||||
|
||||
const target = await ctx.db.get(args.targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(args.targetUserId, {
|
||||
trustedPublisher: args.trusted || undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.actorUserId,
|
||||
action: args.trusted ? 'user.trusted.set' : 'user.trusted.unset',
|
||||
targetType: 'user',
|
||||
targetId: args.targetUserId,
|
||||
metadata: { trusted: args.trusted },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, trusted: args.trusted }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by VT.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
@@ -330,17 +589,16 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Soft-delete all their skills
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.collect()
|
||||
|
||||
for (const skill of skills) {
|
||||
if (!skill.softDeletedAt) {
|
||||
await ctx.db.patch(skill._id, { softDeletedAt: now, updatedAt: now })
|
||||
}
|
||||
}
|
||||
const banSkillsResult = (await ctx.runMutation(
|
||||
internal.skills.applyBanToOwnedSkillsBatchInternal,
|
||||
{
|
||||
ownerUserId: args.ownerUserId,
|
||||
bannedAt: now,
|
||||
cursor: undefined,
|
||||
},
|
||||
)) as { hiddenCount?: number; scheduled?: boolean }
|
||||
const hiddenCount = banSkillsResult.hiddenCount ?? 0
|
||||
const scheduledSkills = banSkillsResult.scheduled ?? false
|
||||
|
||||
// Revoke all API tokens
|
||||
const tokens = await ctx.db
|
||||
@@ -365,7 +623,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
userId: args.ownerUserId,
|
||||
})
|
||||
|
||||
// Audit log — use the target as actor since there's no human actor
|
||||
// Audit log -- use the target as actor since there's no human actor
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: args.ownerUserId,
|
||||
action: 'user.autoban.malware',
|
||||
@@ -375,7 +633,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
trigger: 'vt.malicious',
|
||||
sha256hash: args.sha256hash,
|
||||
slug: args.slug,
|
||||
deletedSkills: skills.length,
|
||||
hiddenSkills: hiddenCount,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
@@ -384,6 +642,6 @@ export const autobanMalwareAuthorInternal = internalMutation({
|
||||
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
||||
)
|
||||
|
||||
return { ok: true, alreadyBanned: false, deletedSkills: skills.length }
|
||||
return { ok: true, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
|
||||
},
|
||||
})
|
||||
|
||||
+12
-17
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ read_when:
|
||||
## Web auth (GitHub OAuth)
|
||||
|
||||
- Convex Auth + GitHub OAuth App.
|
||||
- GitHub is the only supported login provider.
|
||||
- Env vars:
|
||||
- `AUTH_GITHUB_ID`
|
||||
- `AUTH_GITHUB_SECRET`
|
||||
|
||||
+10
-2
@@ -27,8 +27,8 @@ Headers:
|
||||
|
||||
IP source:
|
||||
|
||||
- Uses `cf-connecting-ip` first, then falls back to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip`.
|
||||
- Set `TRUST_FORWARDED_IPS=false` to disable forwarded-header fallback.
|
||||
- Uses `cf-connecting-ip` (Cloudflare) for client IP by default.
|
||||
- Set `TRUST_FORWARDED_IPS=true` to opt in to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip` (non-Cloudflare deployments).
|
||||
|
||||
## Public endpoints (no auth)
|
||||
|
||||
@@ -156,6 +156,14 @@ Publishes a new version.
|
||||
|
||||
Soft-delete / restore a skill (moderator/admin only).
|
||||
|
||||
Status codes:
|
||||
|
||||
- `200`: ok
|
||||
- `401`: unauthorized
|
||||
- `403`: forbidden
|
||||
- `404`: skill/user not found
|
||||
- `500`: internal server error
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
Ban a user and hard-delete owned skills (moderator/admin only).
|
||||
|
||||
+11
-3
@@ -38,6 +38,8 @@ read_when:
|
||||
- hard-deletes all owned skills
|
||||
- revokes API tokens
|
||||
- sets `deletedAt` on the user
|
||||
- Admins can manually unban (`deletedAt` + `banReason` cleared); revoked API tokens
|
||||
stay revoked and should be recreated by the user.
|
||||
- Optional ban reason is stored in `users.banReason` and audit logs.
|
||||
- Moderators cannot ban admins; nobody can ban themselves.
|
||||
- Report counters effectively reset because deleted/banned skills are no longer
|
||||
@@ -57,12 +59,18 @@ read_when:
|
||||
## Upload gate (GitHub account age)
|
||||
|
||||
- Skill + soul publish actions require GitHub account age ≥ 7 days.
|
||||
- Lookup uses GitHub `created_at` and caches on the user:
|
||||
- Lookup uses GitHub `created_at` fetched by the immutable GitHub numeric ID (`providerAccountId`)
|
||||
and caches on the user:
|
||||
- `githubCreatedAt` (source of truth)
|
||||
- `githubFetchedAt` (fetch timestamp)
|
||||
- Cache TTL: 24 hours.
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
- If GitHub responds `403` or `429`, publish fails with:
|
||||
- `GitHub API rate limit exceeded — please try again in a few minutes`
|
||||
- To reduce rate-limit failures, set `GITHUB_TOKEN` in Convex env for authenticated
|
||||
GitHub API requests.
|
||||
|
||||
## Empty-skill cleanup (backfill)
|
||||
|
||||
- Cleanup uses quality heuristics plus trust tier to identify very thin/templated
|
||||
skills.
|
||||
- Word counting is language-aware (`Intl.Segmenter` with fallback), reducing
|
||||
false positives for non-space-separated languages.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(async () => null as { registry?: string; token?: string } | null)
|
||||
const mockWriteGlobalConfig = vi.fn(async (_cfg: unknown) => {})
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: () => mockReadGlobalConfig(),
|
||||
writeGlobalConfig: (cfg: unknown) => mockWriteGlobalConfig(cfg),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const { cmdLogout } = await import('./auth')
|
||||
|
||||
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawhub.ai',
|
||||
registry: 'https://clawhub.ai',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLog.mockClear()
|
||||
})
|
||||
|
||||
describe('cmdLogout', () => {
|
||||
it('removes token and logs a clear message', async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ registry: 'https://clawhub.ai', token: 'tkn' })
|
||||
|
||||
await cmdLogout(makeOpts())
|
||||
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
registry: 'https://clawhub.ai',
|
||||
token: undefined,
|
||||
})
|
||||
expect(mockGetRegistry).not.toHaveBeenCalled()
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'OK. Logged out locally. Token still valid until revoked (Settings -> API tokens).',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to resolved registry when config has no registry', async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ token: 'tkn' })
|
||||
mockGetRegistry.mockResolvedValueOnce('https://registry.example')
|
||||
|
||||
await cmdLogout(makeOpts())
|
||||
|
||||
expect(mockGetRegistry).toHaveBeenCalled()
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
registry: 'https://registry.example',
|
||||
token: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { readGlobalConfig, writeGlobalConfig } from '../../config.js'
|
||||
import { discoverRegistryFromSite } from '../../discovery.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import { ApiRoutes, ApiV1WhoamiResponseSchema } from '../../schema/index.js'
|
||||
import { requireAuthToken } from '../authToken.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js'
|
||||
@@ -74,13 +75,11 @@ export async function cmdLogout(opts: GlobalOpts) {
|
||||
const cfg = await readGlobalConfig()
|
||||
const registry = cfg?.registry || (await getRegistry(opts, { cache: true }))
|
||||
await writeGlobalConfig({ registry, token: undefined })
|
||||
console.log('OK. Logged out.')
|
||||
console.log('OK. Logged out locally. Token still valid until revoked (Settings -> API tokens).')
|
||||
}
|
||||
|
||||
export async function cmdWhoami(opts: GlobalOpts) {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
const token = await requireAuthToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
|
||||
const spinner = createSpinner('Checking token')
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
vi.mock('../authToken.js', () => ({
|
||||
requireAuthToken: vi.fn(async () => 'tkn'),
|
||||
}))
|
||||
|
||||
vi.mock('../registry.js', () => ({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getSkillBadges } from '../lib/badges'
|
||||
import type { PublicSkill, PublicUser } from '../lib/publicUser'
|
||||
import { canManageSkill, isModerator } from '../lib/roles'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import { UserBadge } from './UserBadge'
|
||||
|
||||
const SkillDiffCard = lazy(() =>
|
||||
import('./SkillDiffCard').then((m) => ({ default: m.SkillDiffCard })),
|
||||
@@ -138,7 +139,12 @@ function LlmAnalysisDetail({ analysis }: { analysis: LlmAnalysis }) {
|
||||
<button
|
||||
type="button"
|
||||
className="analysis-detail-header"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
onClick={() => {
|
||||
// Drag-select to copy summary text should not toggle open/closed.
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed) return
|
||||
setIsOpen((prev) => !prev)
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="analysis-summary-text">{analysis.summary}</span>
|
||||
@@ -646,11 +652,15 @@ export function SkillDetailPage({
|
||||
{skill.stats.installsCurrent ?? 0} current · {skill.stats.installsAllTime ?? 0}{' '}
|
||||
all-time
|
||||
</div>
|
||||
{owner?.handle ? (
|
||||
<div className="stat">
|
||||
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="stat">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
{forkOf && forkOfHref ? (
|
||||
<div className="stat">
|
||||
{forkOfLabel}{' '}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PublicUser } from '../lib/publicUser'
|
||||
|
||||
type UserBadgeProps = {
|
||||
user: PublicUser | null | undefined
|
||||
fallbackHandle?: string | null
|
||||
prefix?: string
|
||||
size?: 'sm' | 'md'
|
||||
link?: boolean
|
||||
showName?: boolean
|
||||
}
|
||||
|
||||
export function UserBadge({
|
||||
user,
|
||||
fallbackHandle,
|
||||
prefix = 'by',
|
||||
size = 'sm',
|
||||
link = true,
|
||||
showName = false,
|
||||
}: UserBadgeProps) {
|
||||
const handle = user?.handle ?? user?.name ?? fallbackHandle ?? null
|
||||
const href = user?.handle ? `/u/${encodeURIComponent(user.handle)}` : null
|
||||
const label = handle ? `@${handle}` : 'user'
|
||||
const image = user?.image ?? null
|
||||
const displayName = user?.displayName?.trim() || null
|
||||
const hasUsefulName =
|
||||
showName && Boolean(displayName) && Boolean(handle) && displayName!.toLowerCase() !== handle!.toLowerCase()
|
||||
const initial = (user?.displayName ?? user?.name ?? handle ?? 'u').charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<span className={`user-badge user-badge-${size}`}>
|
||||
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
|
||||
<span className="user-avatar" aria-hidden="true">
|
||||
{image ? (
|
||||
<img className="user-avatar-img" src={image} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="user-avatar-fallback">{initial}</span>
|
||||
)}
|
||||
</span>
|
||||
{hasUsefulName ? (
|
||||
<>
|
||||
<span className="user-name">{displayName}</span>
|
||||
<span className="user-name-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{link && href ? (
|
||||
<a className="user-handle" href={href}>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="user-handle">{label}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+34
-20
@@ -5,10 +5,10 @@ import { api } from '../../convex/_generated/api'
|
||||
import { InstallSwitcher } from '../components/InstallSwitcher'
|
||||
import { SkillCard } from '../components/SkillCard'
|
||||
import { SoulCard } from '../components/SoulCard'
|
||||
import { UserBadge } from '../components/UserBadge'
|
||||
import { getSkillBadges } from '../lib/badges'
|
||||
import type { PublicSkill, PublicSoul } from '../lib/publicUser'
|
||||
import type { PublicSkill, PublicSoul, PublicUser } from '../lib/publicUser'
|
||||
import { getSiteMode } from '../lib/site'
|
||||
import { mapPublicSkillPageEntries } from '../lib/skillPageEntries'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
@@ -23,21 +23,19 @@ function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill
|
||||
ownerHandle?: string | null
|
||||
owner?: PublicUser | null
|
||||
latestVersion?: unknown
|
||||
}
|
||||
|
||||
const highlighted =
|
||||
(useQuery(api.skills.list, {
|
||||
batch: 'highlighted',
|
||||
limit: 6,
|
||||
}) as PublicSkill[]) ?? []
|
||||
(useQuery(api.skills.listHighlightedPublic, { limit: 6 }) as SkillPageEntry[]) ?? []
|
||||
const popularResult = useQuery(api.skills.listPublicPageV2, {
|
||||
paginationOpts: { cursor: null, numItems: 12 },
|
||||
sort: 'downloads',
|
||||
dir: 'desc',
|
||||
nonSuspiciousOnly: true,
|
||||
}) as { page: SkillPageEntry[] } | undefined
|
||||
const popular = mapPublicSkillPageEntries(popularResult?.page)
|
||||
const popular = popularResult?.page ?? []
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -87,16 +85,24 @@ function SkillsHome() {
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge={getSkillBadges(skill)}
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
badge={getSkillBadges(entry.skill)}
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<div className="stat">
|
||||
⭐ {entry.skill.stats.stars} · ⤓ {entry.skill.stats.downloads} · ⤒{' '}
|
||||
{entry.skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -112,15 +118,23 @@ function SkillsHome() {
|
||||
{popular.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
popular.map((skill) => (
|
||||
popular.map((entry) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge
|
||||
user={entry.owner}
|
||||
fallbackHandle={entry.ownerHandle ?? null}
|
||||
prefix="by"
|
||||
link={false}
|
||||
/>
|
||||
<div className="stat">
|
||||
⭐ {entry.skill.stats.stars} · ⤓ {entry.skill.stats.downloads} · ⤒{' '}
|
||||
{entry.skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
+39
-15
@@ -1,12 +1,12 @@
|
||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router'
|
||||
import { useAction } from 'convex/react'
|
||||
import { usePaginatedQuery } from 'convex-helpers/react'
|
||||
import { useAction, usePaginatedQuery } from 'convex/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
import type { Doc } from '../../../convex/_generated/dataModel'
|
||||
import { SkillCard } from '../../components/SkillCard'
|
||||
import { UserBadge } from '../../components/UserBadge'
|
||||
import { getSkillBadges, isSkillHighlighted } from '../../lib/badges'
|
||||
import type { PublicSkill } from '../../lib/publicUser'
|
||||
import type { PublicSkill, PublicUser } from '../../lib/publicUser'
|
||||
|
||||
const sortKeys = [
|
||||
'relevance',
|
||||
@@ -53,6 +53,7 @@ type SkillListEntry = {
|
||||
}
|
||||
} | null
|
||||
ownerHandle?: string | null
|
||||
owner?: PublicUser | null
|
||||
searchScore?: number
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ type SkillSearchEntry = {
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
ownerHandle?: string | null
|
||||
owner?: PublicUser | null
|
||||
}
|
||||
|
||||
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
|
||||
@@ -136,7 +138,6 @@ export function SkillsIndex() {
|
||||
? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}::${nonSuspiciousOnly ? '1' : '0'}`
|
||||
: ''
|
||||
|
||||
// Use convex-helpers usePaginatedQuery for better cache behavior
|
||||
const {
|
||||
results: paginatedResults,
|
||||
status: paginationStatus,
|
||||
@@ -223,6 +224,7 @@ export function SkillsIndex() {
|
||||
skill: entry.skill,
|
||||
latestVersion: entry.version,
|
||||
ownerHandle: entry.ownerHandle ?? null,
|
||||
owner: entry.owner ?? null,
|
||||
searchScore: entry.score,
|
||||
}))
|
||||
}
|
||||
@@ -236,34 +238,48 @@ export function SkillsIndex() {
|
||||
)
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!hasQuery) {
|
||||
return filtered
|
||||
}
|
||||
const multiplier = dir === 'asc' ? 1 : -1
|
||||
const results = [...filtered]
|
||||
results.sort((a, b) => {
|
||||
const tieBreak = () => {
|
||||
const updated = (a.skill.updatedAt - b.skill.updatedAt) * multiplier
|
||||
if (updated !== 0) return updated
|
||||
return a.skill.slug.localeCompare(b.skill.slug)
|
||||
}
|
||||
switch (sort) {
|
||||
case 'relevance':
|
||||
return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier
|
||||
case 'downloads':
|
||||
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
|
||||
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak()
|
||||
case 'installs':
|
||||
return (
|
||||
((a.skill.stats.installsAllTime ?? 0) - (b.skill.stats.installsAllTime ?? 0)) *
|
||||
multiplier
|
||||
multiplier || tieBreak()
|
||||
)
|
||||
case 'stars':
|
||||
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier
|
||||
return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak()
|
||||
case 'updated':
|
||||
return (a.skill.updatedAt - b.skill.updatedAt) * multiplier
|
||||
return (
|
||||
(a.skill.updatedAt - b.skill.updatedAt) * multiplier ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)
|
||||
)
|
||||
case 'name':
|
||||
return (
|
||||
(a.skill.displayName.localeCompare(b.skill.displayName) ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)) * multiplier
|
||||
)
|
||||
default:
|
||||
return (a.skill.createdAt - b.skill.createdAt) * multiplier
|
||||
return (
|
||||
(a.skill.createdAt - b.skill.createdAt) * multiplier ||
|
||||
a.skill.slug.localeCompare(b.skill.slug)
|
||||
)
|
||||
}
|
||||
})
|
||||
return results
|
||||
}, [dir, filtered, sort])
|
||||
}, [dir, filtered, hasQuery, sort])
|
||||
|
||||
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList
|
||||
const canLoadMore = hasQuery
|
||||
@@ -442,7 +458,8 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
const ownerHandle = entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null
|
||||
const skillHref = buildSkillHref(skill, ownerHandle)
|
||||
return (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
@@ -452,9 +469,12 @@ export function SkillsIndex() {
|
||||
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
<div className="skill-card-footer-rows">
|
||||
<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -466,7 +486,8 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
const ownerHandle = entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null
|
||||
const skillHref = buildSkillHref(skill, ownerHandle)
|
||||
return (
|
||||
<Link key={skill._id} className="skills-row" to={skillHref}>
|
||||
<div className="skills-row-main">
|
||||
@@ -485,6 +506,9 @@ export function SkillsIndex() {
|
||||
<div className="skills-row-summary">
|
||||
{skill.summary ?? 'No summary provided.'}
|
||||
</div>
|
||||
<div className="skills-row-owner">
|
||||
<UserBadge user={entry.owner} fallbackHandle={ownerHandle} prefix="by" link={false} />
|
||||
</div>
|
||||
{isPlugin ? (
|
||||
<div className="skills-row-meta">
|
||||
Bundle includes SKILL.md, CLI, and config.
|
||||
|
||||
+143
-33
@@ -5,24 +5,24 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f2f7f9;
|
||||
--bg-soft: #f7fcfd;
|
||||
--bg: #f8f2ed;
|
||||
--bg-soft: #fdf7f2;
|
||||
--bg-glow-1: #ffe1d4;
|
||||
--bg-glow-2: #d8f0f2;
|
||||
--bg-glow-2: #ffe8d8;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f0f8fa;
|
||||
--nav-bg: rgba(242, 247, 249, 0.88);
|
||||
--ink: #14242e;
|
||||
--ink-soft: #4b6677;
|
||||
--surface-muted: #f7efe9;
|
||||
--nav-bg: rgba(248, 242, 237, 0.88);
|
||||
--ink: #2a1f19;
|
||||
--ink-soft: #6b5549;
|
||||
--accent: #e65c46;
|
||||
--accent-deep: #bf3f30;
|
||||
--seafoam: #209e92;
|
||||
--gold: #e7bb67;
|
||||
--line: rgba(20, 36, 46, 0.14);
|
||||
--line: rgba(42, 31, 25, 0.14);
|
||||
--border-ui: rgba(191, 63, 48, 0.28);
|
||||
--border-ui-hover: rgba(191, 63, 48, 0.42);
|
||||
--border-ui-active: rgba(191, 63, 48, 0.62);
|
||||
--shadow: 0 22px 52px rgba(16, 34, 44, 0.11);
|
||||
--shadow: 0 22px 52px rgba(44, 28, 20, 0.11);
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
--radius-sm: 9px;
|
||||
@@ -36,20 +36,20 @@
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1b24;
|
||||
--bg-soft: #142632;
|
||||
--bg: #14100d;
|
||||
--bg-soft: #1d1713;
|
||||
--bg-glow-1: #4b211b;
|
||||
--bg-glow-2: #11303b;
|
||||
--surface: #1a2d39;
|
||||
--surface-muted: #213744;
|
||||
--nav-bg: rgba(13, 27, 36, 0.88);
|
||||
--ink: #edf6f9;
|
||||
--ink-soft: #acc2cf;
|
||||
--bg-glow-2: #3a2018;
|
||||
--surface: #241b16;
|
||||
--surface-muted: #2f241d;
|
||||
--nav-bg: rgba(20, 16, 13, 0.88);
|
||||
--ink: #f7eee8;
|
||||
--ink-soft: #c8b3a6;
|
||||
--accent: #ff7357;
|
||||
--accent-deep: #e25640;
|
||||
--seafoam: #47c3b8;
|
||||
--gold: #f3c97a;
|
||||
--line: rgba(232, 243, 249, 0.16);
|
||||
--line: rgba(247, 235, 225, 0.16);
|
||||
--border-ui: rgba(255, 115, 87, 0.4);
|
||||
--border-ui-hover: rgba(255, 115, 87, 0.58);
|
||||
--border-ui-active: rgba(255, 115, 87, 0.78);
|
||||
@@ -1265,6 +1265,14 @@ code {
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.skills-row-owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.skills-row-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1368,6 +1376,94 @@ code {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skill-card-footer-rows {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.skill-card-footer-rows .stat {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-badge-prefix {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.user-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-avatar-fallback {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 680;
|
||||
color: color-mix(in srgb, var(--ink) 86%, transparent);
|
||||
}
|
||||
|
||||
.user-handle {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 18ch;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 28ch;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name-sep {
|
||||
color: var(--ink-soft);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.user-handle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.user-badge-md .user-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.user-badge-md .user-handle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hero-install {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1874,6 +1970,11 @@ code {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skill-hero-cta .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skill-version-pill {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -2898,9 +2999,10 @@ code {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 250, 247, 0.88));
|
||||
border: 1px solid rgba(255, 107, 74, 0.18);
|
||||
border-left: 3px solid var(--accent-deep);
|
||||
background:
|
||||
radial-gradient(1200px 220px at 12% 0%, rgba(255, 107, 74, 0.08), transparent 55%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 250, 247, 0.9));
|
||||
border: 1px solid rgba(255, 107, 74, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
@@ -2908,7 +3010,9 @@ code {
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
color: var(--ink);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
box-shadow:
|
||||
inset 4px 0 0 rgba(255, 107, 74, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
@@ -2921,11 +3025,14 @@ code {
|
||||
}
|
||||
|
||||
[data-theme="dark"] .markdown pre {
|
||||
background: linear-gradient(180deg, rgba(20, 36, 47, 0.9), rgba(15, 29, 40, 0.86));
|
||||
border: 1px solid rgba(232, 106, 71, 0.28);
|
||||
border-left-color: rgba(232, 106, 71, 0.8);
|
||||
color: #f5e9e3;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
background:
|
||||
radial-gradient(900px 260px at 12% 0%, rgba(255, 168, 142, 0.08), transparent 55%),
|
||||
linear-gradient(180deg, rgba(14, 22, 32, 0.95), rgba(10, 16, 24, 0.92));
|
||||
border: 1px solid rgba(255, 168, 142, 0.22);
|
||||
color: rgba(244, 238, 234, 0.96);
|
||||
box-shadow:
|
||||
inset 4px 0 0 rgba(255, 140, 110, 0.75),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .markdown :not(pre) > code {
|
||||
@@ -3123,13 +3230,13 @@ html.theme-transition::view-transition-new(theme) {
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--color-text);
|
||||
border-color: var(--border-ui);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--card-bg);
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-color: var(--border-ui-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -3300,7 +3407,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.scan-result-icon-vt {
|
||||
color: #0030ff;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.scan-result-icon-oc {
|
||||
@@ -3449,7 +3556,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.version-scan-icon-vt {
|
||||
color: #0030ff;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.version-scan-icon-oc {
|
||||
@@ -3518,6 +3625,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.analysis-detail-toggle .chevron {
|
||||
@@ -3533,6 +3641,8 @@ html.theme-transition::view-transition-new(theme) {
|
||||
color: var(--ink);
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.analysis-body {
|
||||
|
||||
Reference in New Issue
Block a user