Compare commits

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

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

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

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

Fixes #303

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

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

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

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

Fixes #301

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

* fix: throttle GitHub profile sync

* feat: show skill owner avatars

* fix: avoid nested owner links

* refactor: centralize profile sync + owner lookup

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

---------

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

* refactor: batch ban/unban skill updates

* refactor: report batched ban/unban scheduling

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Update the message to set correct expectations.

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

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

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 22:56:10 +01:00
Peter Steinberger 6a5712fdb6 Merge pull request #309 from openclaw/chore/merge-all
docs: changelog credit + v1 delete status codes
2026-02-14 22:21:56 +01:00
Peter Steinberger a85faf76ac docs: changelog credit + v1 delete status codes 2026-02-14 22:21:37 +01:00
Peter Steinberger c0a04210e9 fix: default to CF-only client IP parsing 2026-02-14 22:20:58 +01:00
Peter Steinberger 5adb334cb2 Merge pull request #35 from sergical/fix/delete-error-handling
fix: return proper HTTP status codes for delete/undelete errors
2026-02-14 22:11:04 +01:00
Peter Steinberger 182ec8741f test(api): reposition soft-delete mapping test 2026-02-14 22:08:05 +01:00
Peter Steinberger bafd17b00a test(api): cover v1 soft-delete error mapping 2026-02-14 22:06:02 +01:00
Peter Steinberger 802ee58054 chore(cli): align http client with main 2026-02-14 22:04:02 +01:00
Peter Steinberger 0e83ba00b9 fix(api): centralize v1 soft-delete error mapping 2026-02-14 21:59:14 +01:00
Peter Steinberger 3326a5c838 refactor: simplify GitHub age gate cache 2026-02-14 20:53:05 +01:00
Sergiy Dybskiy eb9a67f2af fix: use Error for timeout abort in e2e helper 2026-01-26 12:41:29 +00:00
Sergiy Dybskiy 1ae0498595 test: add e2e test for delete error handling
Verifies that deleting a non-existent skill returns a proper 'not found'
error instead of a generic 'Unauthorized' message.
2026-01-26 12:40:39 +00:00
Sergiy Dybskiy f1a5254755 fix(cli): use proper Error objects in abort timeouts
When AbortController.abort() receives a string instead of an Error,
the string itself is thrown. pRetry then wraps it in a confusing
message: 'Non-error was thrown: Timeout'

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

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

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

Fixes #34
2026-01-25 21:12:02 +00:00
44 changed files with 3786 additions and 1601 deletions
+9
View File
@@ -4,18 +4,27 @@
### 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
+30
View File
@@ -16,29 +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";
@@ -90,29 +105,44 @@ declare const fullApi: ApiFromModules<{
downloads: typeof downloads;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;
githubIdentity: typeof githubIdentity;
githubImport: typeof githubImport;
githubRestore: typeof githubRestore;
githubRestoreMutations: typeof githubRestoreMutations;
githubSoulBackups: typeof githubSoulBackups;
githubSoulBackupsNode: typeof githubSoulBackupsNode;
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
"httpApiV1/starsV1": typeof httpApiV1_starsV1;
"httpApiV1/usersV1": typeof httpApiV1_usersV1;
"httpApiV1/whoamiV1": typeof httpApiV1_whoamiV1;
httpPreflight: typeof httpPreflight;
leaderboards: typeof leaderboards;
"lib/access": typeof lib_access;
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/badges": typeof lib_badges;
"lib/batching": typeof lib_batching;
"lib/changelog": typeof lib_changelog;
"lib/contentTypes": typeof lib_contentTypes;
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubIdentity": typeof lib_githubIdentity;
"lib/githubImport": typeof lib_githubImport;
"lib/githubProfileSync": typeof lib_githubProfileSync;
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
"lib/httpHeaders": typeof lib_httpHeaders;
"lib/httpRateLimit": typeof lib_httpRateLimit;
"lib/leaderboards": typeof lib_leaderboards;
"lib/moderation": typeof lib_moderation;
"lib/public": typeof lib_public;
"lib/reservedSlugs": typeof lib_reservedSlugs;
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
"lib/skillBackfill": typeof lib_skillBackfill;
+19 -3
View File
@@ -2,7 +2,9 @@ import GitHub from '@auth/core/providers/github'
import { convexAuth } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { ConvexError } from 'convex/values'
import { internal } from './_generated/api'
import type { DataModel, Id } from './_generated/dataModel'
import { shouldScheduleGitHubProfileSync } from './lib/githubProfileSync'
export const BANNED_REAUTH_MESSAGE =
'Your account has been banned for uploading malicious skills. If you believe this is a mistake, please contact security@openclaw.ai and we will work with you to restore access.'
@@ -14,8 +16,9 @@ const REAUTH_BLOCKING_BAN_ACTIONS = new Set(['user.ban', 'user.autoban.malware']
export async function handleDeletedUserSignIn(
ctx: GenericMutationCtx<DataModel>,
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
userOverride?: { deletedAt?: number; deactivatedAt?: number; purgedAt?: number } | null,
) {
const user = await ctx.db.get(args.userId)
const user = userOverride !== undefined ? userOverride : await ctx.db.get(args.userId)
if (!user?.deletedAt && !user?.deactivatedAt) return
// Verify that the incoming identity matches the existing account to prevent bypass.
@@ -69,14 +72,27 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
],
callbacks: {
/**
* Block sign-in for deleted/deactivated users.
* Block sign-in for deleted/deactivated users and sync GitHub profile.
*
* Performance note: This callback runs on every OAuth sign-in, but the
* audit log query ONLY executes when a legacy deleted user attempts to sign
* in (user.deletedAt is set). For active users, this is a single field check.
*
* The GitHub profile sync is scheduled as a background action to handle
* the case where a user renames their GitHub account (fixes #303).
*/
async afterUserCreatedOrUpdated(ctx, args) {
await handleDeletedUserSignIn(ctx, args)
const user = await ctx.db.get(args.userId)
await handleDeletedUserSignIn(ctx, args, user)
// Schedule GitHub profile sync to handle username renames (fixes #303)
// This runs as a background action so it doesn't block sign-in
const now = Date.now()
if (shouldScheduleGitHubProfileSync(user, now)) {
await ctx.scheduler.runAfter(0, internal.users.syncGitHubProfileAction, {
userId: args.userId,
})
}
},
},
})
+9
View File
@@ -0,0 +1,9 @@
import { v } from 'convex/values'
import { internalQuery } from './_generated/server'
import { getGitHubProviderAccountId } from './lib/githubIdentity'
export const getGitHubProviderAccountIdInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, args) => getGitHubProviderAccountId(ctx, args.userId),
})
+216
View File
@@ -0,0 +1,216 @@
'use node'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { internalAction } from './_generated/server'
import {
fetchGitHubSkillMeta,
getGitHubBackupContext,
isGitHubBackupConfigured,
} from './lib/githubBackup'
import { assertAdmin } from './lib/access'
import {
listGitHubBackupFiles,
readGitHubBackupFile,
} from './lib/githubRestoreHelpers'
import { publishVersionForUser } from './lib/skillPublish'
import { guessContentTypeForPath } from './lib/contentTypes'
type RestoreResult = {
slug: string
status: 'restored' | 'slug_conflict' | 'already_exists' | 'no_backup' | 'error'
detail?: string
}
type BulkRestoreResult = {
results: RestoreResult[]
totalRestored: number
totalConflicts: number
totalSkipped: number
totalErrors: number
}
/**
* Admin-only: restore a single skill from GitHub backup.
* Reads the backup files from the GitHub repo and re-creates the skill in the database.
*/
export const restoreSkillFromBackup = internalAction({
args: {
actorUserId: v.id('users'),
ownerHandle: v.string(),
ownerUserId: v.id('users'),
slug: v.string(),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<RestoreResult> => {
try {
const actor = await ctx.runQuery(internal.users.getByIdInternal, {
userId: args.actorUserId,
})
if (!actor || actor.deletedAt || actor.deactivatedAt) {
return { slug: args.slug, status: 'error', detail: 'Actor not found' }
}
assertAdmin(actor as Doc<'users'>)
if (!isGitHubBackupConfigured()) {
return { slug: args.slug, status: 'error', detail: 'GitHub backup not configured' }
}
const ghContext = await getGitHubBackupContext()
// Check if skill already exists in the DB
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug: args.slug,
})) as Doc<'skills'> | null
if (existingSkill) {
if (existingSkill.ownerUserId === args.ownerUserId) {
return { slug: args.slug, status: 'already_exists', detail: 'Skill already owned by user' }
}
if (!args.forceOverwriteSquatter) {
return {
slug: args.slug,
status: 'slug_conflict',
detail: `Slug occupied by another user. Set forceOverwriteSquatter=true to reclaim.`,
}
}
// Free the slug in-transaction by renaming the squatter, then enqueue cleanup.
await ctx.runMutation(internal.githubRestoreMutations.evictSquatterSkillForRestoreInternal, {
actorUserId: args.actorUserId,
slug: args.slug,
rightfulOwnerUserId: args.ownerUserId,
})
}
// Fetch metadata from GitHub backup
const meta = await fetchGitHubSkillMeta(ghContext, args.ownerHandle, args.slug)
if (!meta) {
return { slug: args.slug, status: 'no_backup', detail: 'No backup found in GitHub repo' }
}
// Read the actual files from the backup
const backupFiles = await listGitHubBackupFiles(ghContext, args.ownerHandle, args.slug)
if (backupFiles.length === 0) {
return { slug: args.slug, status: 'no_backup', detail: 'Backup has no files' }
}
// Download and store each file in Convex storage
const storedFiles: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType: string
}> = []
for (const filePath of backupFiles) {
const fileContent = await readGitHubBackupFile(ghContext, args.ownerHandle, args.slug, filePath)
if (!fileContent) continue
const sha256 = await sha256Hex(fileContent)
const contentType = guessContentTypeForPath(filePath)
const blob = new Blob([Buffer.from(fileContent)], { type: contentType })
const storageId = await ctx.storage.store(blob)
storedFiles.push({
path: filePath,
size: fileContent.byteLength,
storageId,
sha256,
contentType,
})
}
if (storedFiles.length === 0) {
return { slug: args.slug, status: 'error', detail: 'Could not download any backup files' }
}
await publishVersionForUser(
ctx,
args.ownerUserId,
{
slug: args.slug,
displayName: meta.displayName,
version: meta.latest.version,
changelog: 'Restored from GitHub backup',
files: storedFiles,
},
{
bypassGitHubAccountAge: true,
bypassNewSkillRateLimit: true,
bypassQualityGate: true,
skipBackup: true,
skipWebhook: true,
},
)
return { slug: args.slug, status: 'restored' }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[restore] Failed to restore ${args.slug}:`, message)
return { slug: args.slug, status: 'error', detail: message }
}
},
})
/**
* Admin-only: bulk restore all skills for a user from GitHub backup.
*/
export const restoreUserSkillsFromBackup = internalAction({
args: {
actorUserId: v.id('users'),
ownerHandle: v.string(),
ownerUserId: v.id('users'),
slugs: v.array(v.string()),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<BulkRestoreResult> => {
const results: RestoreResult[] = []
let totalRestored = 0
let totalConflicts = 0
let totalSkipped = 0
let totalErrors = 0
for (const slug of args.slugs) {
const result = (await ctx.runAction(internal.githubRestore.restoreSkillFromBackup, {
actorUserId: args.actorUserId,
ownerHandle: args.ownerHandle,
ownerUserId: args.ownerUserId,
slug,
forceOverwriteSquatter: args.forceOverwriteSquatter,
})) as RestoreResult
results.push(result)
switch (result.status) {
case 'restored':
totalRestored += 1
break
case 'slug_conflict':
totalConflicts += 1
break
case 'already_exists':
case 'no_backup':
totalSkipped += 1
break
case 'error':
totalErrors += 1
break
}
}
return { results, totalRestored, totalConflicts, totalSkipped, totalErrors }
},
})
async function sha256Hex(bytes: Uint8Array) {
const { createHash } = await import('node:crypto')
const hash = createHash('sha256')
hash.update(bytes)
return hash.digest('hex')
}
// guessContentTypeForPath in lib/contentTypes.ts
+84
View File
@@ -0,0 +1,84 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { internalMutation } from './_generated/server'
import { assertAdmin } from './lib/access'
export const evictSquatterSkillForRestoreInternal = internalMutation({
args: {
actorUserId: v.id('users'),
slug: v.string(),
rightfulOwnerUserId: v.id('users'),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId)
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error('Actor not found')
assertAdmin(actor)
const slug = args.slug.trim().toLowerCase()
if (!slug) throw new Error('Slug required')
const now = Date.now()
const existingSkill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (!existingSkill) return { ok: true as const, action: 'noop' as const }
if (existingSkill.ownerUserId === args.rightfulOwnerUserId) {
return { ok: true as const, action: 'already_owned' as const }
}
const evictedSlug = buildEvictedSlug(slug, now)
// Free the slug immediately (same transaction) by renaming the squatter's skill.
await ctx.db.patch(existingSkill._id, {
slug: evictedSlug,
softDeletedAt: now,
hiddenAt: existingSkill.hiddenAt ?? now,
hiddenBy: existingSkill.hiddenBy ?? actor._id,
updatedAt: now,
})
// Remove from vector search ASAP.
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', existingSkill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: 'deleted',
updatedAt: now,
})
}
// Cleanup the rest asynchronously (versions, fingerprints, installs, etc.)
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: existingSkill._id,
actorUserId: actor._id,
phase: 'versions',
})
await ctx.db.insert('auditLogs', {
actorUserId: actor._id,
action: 'slug.reclaim.sync',
targetType: 'skill',
targetId: existingSkill._id,
metadata: {
slug,
evictedSlug,
squatterUserId: existingSkill.ownerUserId,
rightfulOwnerUserId: args.rightfulOwnerUserId,
reason: 'Synchronous eviction during GitHub restore',
},
createdAt: now,
})
return { ok: true as const, action: 'evicted' as const, evictedSlug }
},
})
function buildEvictedSlug(slug: string, now: number) {
const suffix = now.toString(36)
return `${slug}-evicted-${suffix}`
}
+179
View File
@@ -28,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'
@@ -81,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([
{
@@ -966,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 -1296
View File
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
import { CliPublishRequestSchema, parseArk } from 'clawhub-schema'
import { internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { assertAdmin } from '../lib/access'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { corsHeaders, mergeHeaders } from '../lib/httpHeaders'
export const MAX_RAW_FILE_BYTES = 200 * 1024
const SAFE_TEXT_FILE_CSP =
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
function isSvgLike(contentType: string | undefined, path: string) {
return contentType?.toLowerCase().includes('svg') || path.toLowerCase().endsWith('.svg')
}
export function safeTextFileResponse(params: {
textContent: string
path: string
contentType?: string
sha256: string
size: number
headers?: HeadersInit
}) {
const isSvg = isSvgLike(params.contentType, params.path)
// For any text response that a browser might try to render, lock it down.
// In particular, this prevents SVG <foreignObject> script execution from reading
// localStorage tokens on this origin.
const headers = mergeHeaders(
params.headers,
{
'Content-Type': params.contentType
? `${params.contentType}; charset=utf-8`
: 'text/plain; charset=utf-8',
'Cache-Control': 'private, max-age=60',
ETag: params.sha256,
'X-Content-SHA256': params.sha256,
'X-Content-Size': String(params.size),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': SAFE_TEXT_FILE_CSP,
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
},
corsHeaders(),
)
return new Response(params.textContent, { status: 200, headers })
}
export function json(value: unknown, status = 200, headers?: HeadersInit) {
return new Response(JSON.stringify(value), {
status,
headers: mergeHeaders(
{
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
headers,
corsHeaders(),
),
})
}
export function text(value: string, status: number, headers?: HeadersInit) {
return new Response(value, {
status,
headers: mergeHeaders(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
headers,
corsHeaders(),
),
})
}
export async function parseJsonPayload(request: Request, headers: HeadersInit) {
try {
const payload = (await request.json()) as Record<string, unknown>
return { ok: true as const, payload }
} catch {
return { ok: false as const, response: text('Invalid JSON', 400, headers) }
}
}
export async function requireApiTokenUserOrResponse(
ctx: ActionCtx,
request: Request,
headers: HeadersInit,
) {
try {
const auth = await requireApiTokenUser(ctx, request)
return { ok: true as const, userId: auth.userId, user: auth.user as Doc<'users'> }
} catch {
return { ok: false as const, response: text('Unauthorized', 401, headers) }
}
}
export function requireAdminOrResponse(user: Doc<'users'>, headers: HeadersInit) {
try {
assertAdmin(user)
return { ok: true as const }
} catch {
return { ok: false as const, response: text('Forbidden', 403, headers) }
}
}
export function getPathSegments(request: Request, prefix: string) {
const pathname = new URL(request.url).pathname
if (!pathname.startsWith(prefix)) return []
const rest = pathname.slice(prefix.length)
return rest
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => decodeURIComponent(segment))
}
export function toOptionalNumber(value: string | null) {
if (!value) return undefined
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : undefined
}
/**
* Batch resolve soul version tags to version strings.
* Collects all version IDs, fetches them in a single query, then maps back.
* Reduces N sequential queries to 1 batch query.
*/
export async function resolveSoulTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<'soulVersions'>>>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.souls.getVersionsByIdsInternal)
}
export async function resolveTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<'skillVersions'>>>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(ctx, tagsList, internal.skills.getVersionsByIdsInternal)
}
/**
* Batch resolve version tags to version strings.
* Collects all version IDs, fetches them in a single query, then maps back.
*
* Notes:
* - Uses `internal.*` queries to avoid expanding the public Convex API surface.
* - Sorts ids for stable query args (helps caching/log diffs).
*/
export async function resolveVersionTagsBatch<TTable extends 'skillVersions' | 'soulVersions'>(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<TTable>>>,
getVersionsByIdsQuery: unknown,
): Promise<Array<Record<string, string>>> {
const allVersionIds = new Set<Id<TTable>>()
for (const tags of tagsList) {
for (const versionId of Object.values(tags)) allVersionIds.add(versionId)
}
if (allVersionIds.size === 0) return tagsList.map(() => ({}))
const versionIds = [...allVersionIds].sort() as Array<Id<TTable>>
const versions =
((await ctx.runQuery(getVersionsByIdsQuery as never, { versionIds } as never)) as Array<{
_id: Id<TTable>
version: string
softDeletedAt?: unknown
}> | null) ?? []
const versionMap = new Map<Id<TTable>, string>()
for (const v of versions) {
if (!v?.softDeletedAt) versionMap.set(v._id, v.version)
}
return tagsList.map((tags) => {
const resolved: Record<string, string> = {}
for (const [tag, versionId] of Object.entries(tags)) {
const version = versionMap.get(versionId)
if (version) resolved[tag] = version
}
return resolved
})
}
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes)
const digest = await crypto.subtle.digest('SHA-256', data)
return toHex(new Uint8Array(digest))
}
function toHex(bytes: Uint8Array) {
let out = ''
for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
return out
}
type FileLike = {
name: string
size: number
type: string
arrayBuffer: () => Promise<ArrayBuffer>
}
type FileLikeEntry = FormDataEntryValue & FileLike
function toFileLike(entry: FormDataEntryValue): FileLikeEntry | null {
if (typeof entry === 'string') return null
const candidate = entry as Partial<FileLike>
if (typeof candidate.name !== 'string') return null
if (typeof candidate.size !== 'number') return null
if (typeof candidate.arrayBuffer !== 'function') return null
return entry as FileLikeEntry
}
export async function parseMultipartPublish(
ctx: ActionCtx,
request: Request,
): Promise<{
slug: string
displayName: string
version: string
changelog: string
tags?: string[]
forkOf?: { slug: string; version?: string }
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
}> {
const form = await request.formData()
const payloadRaw = form.get('payload')
if (!payloadRaw || typeof payloadRaw !== 'string') {
throw new Error('Missing payload')
}
let payload: Record<string, unknown>
try {
payload = JSON.parse(payloadRaw) as Record<string, unknown>
} catch {
throw new Error('Invalid JSON payload')
}
const files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}> = []
for (const entry of form.getAll('files')) {
const file = toFileLike(entry)
if (!file) continue
const path = file.name
const size = file.size
const contentType = file.type || undefined
const buffer = new Uint8Array(await file.arrayBuffer())
const sha256 = await sha256Hex(buffer)
const storageId = await ctx.storage.store(file as Blob)
files.push({ path, size, storageId, sha256, contentType })
}
const forkOf = payload.forkOf && typeof payload.forkOf === 'object' ? payload.forkOf : undefined
const body = {
slug: payload.slug,
displayName: payload.displayName,
version: payload.version,
changelog: typeof payload.changelog === 'string' ? payload.changelog : '',
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
...(payload.source ? { source: payload.source } : {}),
files,
...(forkOf ? { forkOf } : {}),
}
return parsePublishBody(body)
}
export function parsePublishBody(body: unknown) {
const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload')
if (parsed.files.length === 0) throw new Error('files required')
const tags = parsed.tags && parsed.tags.length > 0 ? parsed.tags : undefined
return {
slug: parsed.slug,
displayName: parsed.displayName,
version: parsed.version,
changelog: parsed.changelog,
tags,
source: parsed.source ?? undefined,
forkOf: parsed.forkOf
? {
slug: parsed.forkOf.slug,
version: parsed.forkOf.version ?? undefined,
}
: undefined,
files: parsed.files.map((file) => ({
...file,
storageId: file.storageId as Id<'_storage'>,
})),
}
}
export function softDeleteErrorToResponse(
entity: 'skill' | 'soul',
error: unknown,
headers: HeadersInit,
) {
const message = error instanceof Error ? error.message : `${entity} delete failed`
const lower = message.toLowerCase()
if (lower.includes('unauthorized')) return text('Unauthorized', 401, headers)
if (lower.includes('forbidden')) return text('Forbidden', 403, headers)
if (lower.includes('not found')) return text(message, 404, headers)
if (lower.includes('slug required')) return text('Slug required', 400, headers)
// Unknown: server-side failure. Keep body generic.
return text('Internal Server Error', 500, headers)
}
+495
View File
@@ -0,0 +1,495 @@
import { api, internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { getOptionalApiTokenUserId, requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
import { publishVersionForUser } from '../skills'
import {
MAX_RAW_FILE_BYTES,
getPathSegments,
json,
parseMultipartPublish,
parsePublishBody,
resolveTagsBatch,
safeTextFileResponse,
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from './shared'
type SearchSkillEntry = {
score: number
skill: {
slug?: string
displayName?: string
summary?: string | null
updatedAt?: number
} | null
version: { version?: string; createdAt?: number } | null
}
type ListSkillsResult = {
items: Array<{
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'skillVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type GetBySlugResult = {
skill: {
_id: Id<'skills'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'skillVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'skillVersions'> | null
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
moderationInfo?: {
isPendingScan: boolean
isMalwareBlocked: boolean
isSuspicious: boolean
isHiddenByMod: boolean
isRemoved: boolean
reason?: string
} | null
} | null
type ListVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const query = url.searchParams.get('q')?.trim() ?? ''
const limit = toOptionalNumber(url.searchParams.get('limit'))
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
if (!query) return json({ results: [] }, 200, rate.headers)
const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
highlightedOnly: highlightedOnly || undefined,
})) as SearchSkillEntry[]
return json(
{
results: results.map((result) => ({
score: result.score,
slug: result.skill?.slug,
displayName: result.skill?.displayName,
summary: result.skill?.summary ?? null,
version: result.version?.version ?? null,
updatedAt: result.skill?.updatedAt,
})),
},
200,
rate.headers,
)
}
export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
const hash = url.searchParams.get('hash')?.trim().toLowerCase()
if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers)
if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers)
const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash })
if (!resolved) return text('Skill not found', 404, rate.headers)
return json({ slug, match: resolved.match, latestVersion: resolved.latestVersion }, 200, rate.headers)
}
type SkillListSort =
| 'updated'
| 'downloads'
| 'stars'
| 'installsCurrent'
| 'installsAllTime'
| 'trending'
function parseListSort(value: string | null): SkillListSort {
const normalized = value?.trim().toLowerCase()
if (normalized === 'downloads') return 'downloads'
if (normalized === 'stars' || normalized === 'rating') return 'stars'
if (
normalized === 'installs' ||
normalized === 'install' ||
normalized === 'installscurrent' ||
normalized === 'installs-current'
) {
return 'installsCurrent'
}
if (normalized === 'installsalltime' || normalized === 'installs-all-time') {
return 'installsAllTime'
}
if (normalized === 'trending') return 'trending'
return 'updated'
}
export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
const sort = parseListSort(url.searchParams.get('sort'))
const cursor = sort === 'trending' ? undefined : rawCursor
const result = (await ctx.runQuery(api.skills.listPublicPage, {
limit,
cursor,
sort,
})) as ListSkillsResult
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveTagsBatch(
ctx,
result.items.map((item) => item.skill.tags),
)
const items = result.items.map((item, idx) => ({
slug: item.skill.slug,
displayName: item.skill.displayName,
summary: item.skill.summary ?? null,
tags: resolvedTagsList[idx],
stats: item.skill.stats,
createdAt: item.skill.createdAt,
updatedAt: item.skill.updatedAt,
latestVersion: item.latestVersion
? {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
}
: null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
async function describeOwnerVisibleSkillState(
ctx: ActionCtx,
request: Request,
slug: string,
): Promise<{ status: number; message: string } | null> {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return null
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request)
const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId)
if (!isOwner) return null
if (skill.softDeletedAt) {
return {
status: 410,
message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`,
}
}
if (skill.moderationStatus === 'hidden') {
if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') {
return {
status: 423,
message: 'Skill is hidden while security scan is pending. Try again in a few minutes.',
}
}
if (skill.moderationReason === 'quality.low') {
return {
status: 403,
message:
'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete <slug>" after review.',
}
}
return {
status: 403,
message: `Skill is hidden by moderation${
skill.moderationReason ? ` (${skill.moderationReason})` : ''
}.`,
}
}
if (skill.moderationStatus === 'removed') {
return { status: 410, message: 'Skill has been removed by moderation.' }
}
return null
}
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
if (segments.length === 1) {
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!result?.skill) {
const hidden = await describeOwnerVisibleSkillState(ctx, request, slug)
if (hidden) return text(hidden.message, hidden.status, rate.headers)
return text('Skill not found', 404, rate.headers)
}
const [tags] = await resolveTagsBatch(ctx, [result.skill.tags])
return json(
{
skill: {
slug: result.skill.slug,
displayName: result.skill.displayName,
summary: result.skill.summary ?? null,
tags,
stats: result.skill.stats,
createdAt: result.skill.createdAt,
updatedAt: result.skill.updatedAt,
},
latestVersion: result.latestVersion
? {
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
}
: null,
owner: result.owner
? {
handle: result.owner.handle ?? null,
userId: result.owner._id,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
: null,
moderation: result.moderationInfo
? {
isSuspicious: result.moderationInfo.isSuspicious ?? false,
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
}
: null,
},
200,
rate.headers,
)
}
if (second === 'versions' && segments.length === 2) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.skills.listVersionsPage, {
skillId: skill._id,
limit,
cursor,
})) as ListVersionsResult
const items = result.items
.filter((version) => !version.softDeletedAt)
.map((version) => ({
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
if (second === 'versions' && third && segments.length === 3) {
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers)
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skill._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
return json(
{
skill: { slug: skill.slug, displayName: skill.displayName },
version: {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SkillFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
},
200,
rate.headers,
)
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
let version = skillResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skillResult.skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skillResult.skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
return safeTextFileResponse({
textContent,
path: file.path,
contentType: file.contentType ?? undefined,
sha256: file.sha256,
size: file.size,
headers: rate.headers,
})
}
return text('Not found', 404, rate.headers)
}
export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
const { userId } = await requireApiTokenUser(ctx, request)
const contentType = request.headers.get('content-type') ?? ''
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
}
return text('Unsupported content type', 415, rate.headers)
}
export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
}
}
export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/skills/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('skill', error, rate.headers)
}
}
+340
View File
@@ -0,0 +1,340 @@
import { api, internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit'
import { publishSoulVersionForUser } from '../souls'
import {
MAX_RAW_FILE_BYTES,
getPathSegments,
json,
parseMultipartPublish,
parsePublishBody,
resolveSoulTagsBatch,
safeTextFileResponse,
softDeleteErrorToResponse,
text,
toOptionalNumber,
} from './shared'
type ListSoulsResult = {
items: Array<{
soul: {
_id: Id<'souls'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'soulVersions'>>
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'soulVersions'>
}
latestVersion: { version: string; createdAt: number; changelog: string } | null
}>
nextCursor: string | null
}
type GetSoulBySlugResult = {
soul: {
_id: Id<'souls'>
slug: string
displayName: string
summary?: string
tags: Record<string, Id<'soulVersions'>>
stats: unknown
createdAt: number
updatedAt: number
} | null
latestVersion: Doc<'soulVersions'> | null
owner: { handle?: string; displayName?: string; image?: string } | null
} | null
type ListSoulVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
nextCursor: string | null
}
type SoulFile = Doc<'soulVersions'>['files'][number]
export async function listSoulsV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.souls.listPublicPage, {
limit,
cursor,
})) as ListSoulsResult
// Batch resolve all tags in a single query instead of N queries
const resolvedTagsList = await resolveSoulTagsBatch(
ctx,
result.items.map((item) => item.soul.tags),
)
const items = result.items.map((item, idx) => ({
slug: item.soul.slug,
displayName: item.soul.displayName,
summary: item.soul.summary ?? null,
tags: resolvedTagsList[idx],
stats: item.soul.stats,
createdAt: item.soul.createdAt,
updatedAt: item.soul.updatedAt,
latestVersion: item.latestVersion
? {
version: item.latestVersion.version,
createdAt: item.latestVersion.createdAt,
changelog: item.latestVersion.changelog,
}
: null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
export async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length === 0) return text('Missing slug', 400, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
const second = segments[1]
const third = segments[2]
if (segments.length === 1) {
const result = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
if (!result?.soul) return text('Soul not found', 404, rate.headers)
const [tags] = await resolveSoulTagsBatch(ctx, [result.soul.tags])
return json(
{
soul: {
slug: result.soul.slug,
displayName: result.soul.displayName,
summary: result.soul.summary ?? null,
tags,
stats: result.soul.stats,
createdAt: result.soul.createdAt,
updatedAt: result.soul.updatedAt,
},
latestVersion: result.latestVersion
? {
version: result.latestVersion.version,
createdAt: result.latestVersion.createdAt,
changelog: result.latestVersion.changelog,
}
: null,
owner: result.owner
? {
handle: result.owner.handle ?? null,
displayName: result.owner.displayName ?? null,
image: result.owner.image ?? null,
}
: null,
},
200,
rate.headers,
)
}
if (second === 'versions' && segments.length === 2) {
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
const url = new URL(request.url)
const limit = toOptionalNumber(url.searchParams.get('limit'))
const cursor = url.searchParams.get('cursor')?.trim() || undefined
const result = (await ctx.runQuery(api.souls.listVersionsPage, {
soulId: soul._id,
limit,
cursor,
})) as ListSoulVersionsResult
const items = result.items
.filter((version) => !version.softDeletedAt)
.map((version) => ({
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
}))
return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers)
}
if (second === 'versions' && third && segments.length === 3) {
const soul = await ctx.runQuery(internal.souls.getSoulBySlugInternal, { slug })
if (!soul || soul.softDeletedAt) return text('Soul not found', 404, rate.headers)
const version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
soulId: soul._id,
version: third,
})
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
return json(
{
soul: { slug: soul.slug, displayName: soul.displayName },
version: {
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
files: version.files.map((file: SoulFile) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType ?? null,
})),
},
},
200,
rate.headers,
)
}
if (second === 'file' && segments.length === 2) {
const url = new URL(request.url)
const path = url.searchParams.get('path')?.trim()
if (!path) return text('Missing path', 400, rate.headers)
const versionParam = url.searchParams.get('version')?.trim()
const tagParam = url.searchParams.get('tag')?.trim()
const soulResult = (await ctx.runQuery(api.souls.getBySlug, { slug })) as GetSoulBySlugResult
if (!soulResult?.soul) return text('Soul not found', 404, rate.headers)
let version = soulResult.latestVersion
if (versionParam) {
version = await ctx.runQuery(api.souls.getVersionBySoulAndVersion, {
soulId: soulResult.soul._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = soulResult.soul.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.souls.getVersionById, { versionId })
}
}
if (!version) return text('Version not found', 404, rate.headers)
if (version.softDeletedAt) return text('Version not available', 410, rate.headers)
const normalized = path.trim()
const normalizedLower = normalized.toLowerCase()
const file =
version.files.find((entry) => entry.path === normalized) ??
version.files.find((entry) => entry.path.toLowerCase() === normalizedLower)
if (!file) return text('File not found', 404, rate.headers)
if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers)
const blob = await ctx.storage.get(file.storageId)
if (!blob) return text('File missing in storage', 410, rate.headers)
const textContent = await blob.text()
void ctx.runMutation(api.soulDownloads.increment, { soulId: soulResult.soul._id })
return safeTextFileResponse({
textContent,
path: file.path,
contentType: file.contentType ?? undefined,
sha256: file.sha256,
size: file.size,
headers: rate.headers,
})
}
return text('Not found', 404, rate.headers)
}
export async function publishSoulV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
try {
if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
const { userId } = await requireApiTokenUser(ctx, request)
const contentType = request.headers.get('content-type') ?? ''
try {
if (contentType.includes('application/json')) {
const body = await request.json()
const payload = parsePublishBody(body)
const result = await publishSoulVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
if (contentType.includes('multipart/form-data')) {
const payload = await parseMultipartPublish(ctx, request)
const result = await publishSoulVersionForUser(ctx, userId, payload)
return json({ ok: true, ...result }, 200, rate.headers)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Publish failed'
return text(message, 400, rate.headers)
}
return text('Unsupported content type', 415, rate.headers)
}
export async function soulsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length !== 2 || segments[1] !== 'undelete') {
return text('Not found', 404, rate.headers)
}
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
userId,
slug,
deleted: false,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('soul', error, rate.headers)
}
}
export async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/souls/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
userId,
slug,
deleted: true,
})
return json({ ok: true }, 200, rate.headers)
} catch (error) {
return softDeleteErrorToResponse('soul', error, rate.headers)
}
}
+51
View File
@@ -0,0 +1,51 @@
import { internal } from '../_generated/api'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import { getPathSegments, json, text } from './shared'
export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.addStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
export async function starsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/stars/')
if (segments.length !== 1) return text('Not found', 404, rate.headers)
const slug = segments[0]?.trim().toLowerCase() ?? ''
try {
const { userId } = await requireApiTokenUser(ctx, request)
const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug })
if (!skill) return text('Skill not found', 404, rate.headers)
const result = await ctx.runMutation(internal.stars.removeStarInternal, {
userId,
skillId: skill._id,
})
return json(result, 200, rate.headers)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
+244
View File
@@ -0,0 +1,244 @@
import { api, internal } from '../_generated/api'
import type { Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import {
getPathSegments,
json,
parseJsonPayload,
requireAdminOrResponse,
requireApiTokenUserOrResponse,
text,
toOptionalNumber,
} from './shared'
export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'write')
if (!rate.ok) return rate.response
const segments = getPathSegments(request, '/api/v1/users/')
if (segments.length !== 1) {
return text('Not found', 404, rate.headers)
}
const action = segments[0]
if (action !== 'ban' && action !== 'role' && action !== 'restore' && action !== 'reclaim') {
return text('Not found', 404, rate.headers)
}
const payloadResult = await parseJsonPayload(request, rate.headers)
if (!payloadResult.ok) return payloadResult.response
const payload = payloadResult.payload
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers)
if (!authResult.ok) return authResult.response
const actorUserId = authResult.userId
const actorUser = authResult.user
// Restore and reclaim have different parameter shapes, handle them separately
if (action === 'restore') {
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers)
}
if (action === 'reclaim') {
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers)
}
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
const reasonRaw = typeof payload.reason === 'string' ? payload.reason.trim() : ''
if (!handleRaw && !userIdRaw) {
return text('Missing userId or handle', 400, rate.headers)
}
const roleRaw = typeof payload.role === 'string' ? payload.role.trim().toLowerCase() : ''
if (action === 'role' && !roleRaw) {
return text('Missing role', 400, rate.headers)
}
const role = roleRaw === 'user' || roleRaw === 'moderator' || roleRaw === 'admin' ? roleRaw : null
if (action === 'role' && !role) {
return text('Invalid role', 400, rate.headers)
}
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
if (!targetUserId) {
const handle = handleRaw.toLowerCase()
const user = await ctx.runQuery(api.users.getByHandle, { handle })
if (!user?._id) return text('User not found', 404, rate.headers)
targetUserId = user._id
}
if (action === 'ban') {
const reason = reasonRaw.length > 0 ? reasonRaw : undefined
if (reason && reason.length > 500) {
return text('Reason too long (max 500 chars)', 400, rate.headers)
}
try {
const result = await ctx.runMutation(internal.users.banUserInternal, {
actorUserId,
targetUserId,
reason,
})
return json(result, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Ban failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, rate.headers)
}
if (message.toLowerCase().includes('not found')) {
return text(message, 404, rate.headers)
}
return text(message, 400, rate.headers)
}
}
if (!role) {
return text('Invalid role', 400, rate.headers)
}
try {
const result = await ctx.runMutation(internal.users.setRoleInternal, {
actorUserId,
targetUserId,
role,
})
return json({ ok: true, role: result.role ?? role }, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Role change failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, rate.headers)
}
if (message.toLowerCase().includes('not found')) {
return text(message, 404, rate.headers)
}
return text(message, 400, rate.headers)
}
}
/**
* POST /api/v1/users/restore
* Admin-only: restore skills from GitHub backup for a user.
* Body: { handle: string, slugs: string[], forceOverwriteSquatter?: boolean }
*/
async function handleAdminRestore(
ctx: ActionCtx,
_request: Request,
payload: Record<string, unknown>,
actorUserId: Id<'users'>,
headers: HeadersInit,
) {
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
if (!handle) return text('Missing handle', 400, headers)
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
if (slugs.length > 100) return text('Too many slugs (max 100)', 400, headers)
const forceOverwriteSquatter = Boolean(payload.forceOverwriteSquatter)
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
if (!targetUser?._id) return text('User not found', 404, headers)
try {
const result = await ctx.runAction(internal.githubRestore.restoreUserSkillsFromBackup, {
actorUserId,
ownerHandle: handle,
ownerUserId: targetUser._id,
slugs,
forceOverwriteSquatter,
})
return json(result, 200, headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'Restore failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, headers)
}
return text(message, 400, headers)
}
}
/**
* POST /api/v1/users/reclaim
* Admin-only: reclaim squatted slugs and reserve them for the rightful owner.
* Body: { handle: string, slugs: string[], reason?: string }
*/
async function handleAdminReclaim(
ctx: ActionCtx,
_request: Request,
payload: Record<string, unknown>,
actorUserId: Id<'users'>,
headers: HeadersInit,
) {
const handle = typeof payload.handle === 'string' ? payload.handle.trim().toLowerCase() : ''
if (!handle) return text('Missing handle', 400, headers)
const slugs = Array.isArray(payload.slugs) ? payload.slugs.filter((s): s is string => typeof s === 'string') : []
if (slugs.length === 0) return text('Missing slugs array', 400, headers)
if (slugs.length > 200) return text('Too many slugs (max 200)', 400, headers)
const reason = typeof payload.reason === 'string' ? payload.reason.trim() : undefined
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle })
if (!targetUser?._id) return text('User not found', 404, headers)
const results: Array<{ slug: string; ok: boolean; error?: string }> = []
for (const slug of slugs) {
try {
await ctx.runMutation(internal.skills.reclaimSlugInternal, {
actorUserId,
slug: slug.trim().toLowerCase(),
rightfulOwnerUserId: targetUser._id,
reason,
})
results.push({ slug, ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Reclaim failed'
results.push({ slug, ok: false, error: message })
}
}
const succeeded = results.filter((r) => r.ok).length
const failed = results.filter((r) => !r.ok).length
return json({ ok: true, results, succeeded, failed }, 200, headers)
}
export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
const url = new URL(request.url)
const limitRaw = toOptionalNumber(url.searchParams.get('limit'))
const query = url.searchParams.get('q') ?? url.searchParams.get('query') ?? ''
let actorUserId: Id<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
} catch {
return text('Unauthorized', 401, rate.headers)
}
const limit = Math.min(Math.max(limitRaw ?? 20, 1), 200)
try {
const result = await ctx.runQuery(internal.users.searchInternal, {
actorUserId,
query,
limit,
})
return json(result, 200, rate.headers)
} catch (error) {
const message = error instanceof Error ? error.message : 'User search failed'
if (message.toLowerCase().includes('forbidden')) {
return text('Forbidden', 403, rate.headers)
}
if (message.toLowerCase().includes('unauthorized')) {
return text('Unauthorized', 401, rate.headers)
}
return text(message, 400, rate.headers)
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { ActionCtx } from '../_generated/server'
import { requireApiTokenUser } from '../lib/apiTokenAuth'
import { applyRateLimit } from '../lib/httpRateLimit'
import { json, text } from './shared'
export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
const rate = await applyRateLimit(ctx, request, 'read')
if (!rate.ok) return rate.response
try {
const { user } = await requireApiTokenUser(ctx, request)
return json(
{
user: {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
},
},
200,
rate.headers,
)
} catch {
return text('Unauthorized', 401, rate.headers)
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Scheduler } from 'convex/server'
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
scheduler: Scheduler,
fn: unknown,
args: TArgs,
isDone: boolean,
continueCursor: string | null,
) {
if (isDone) return
void scheduler.runAfter(0, fn as never, {
...args,
cursor: continueCursor ?? undefined,
} as never)
}
+18
View File
@@ -0,0 +1,18 @@
const EXT_TO_TYPE: Record<string, string> = {
md: 'text/markdown',
mdx: 'text/markdown',
json: 'application/json',
json5: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
toml: 'application/toml',
svg: 'image/svg+xml',
}
export function guessContentTypeForPath(path: string) {
const trimmed = path.trim().toLowerCase()
if (!trimmed) return 'application/octet-stream'
const ext = trimmed.split('.').at(-1) ?? ''
return EXT_TO_TYPE[ext] ?? 'application/octet-stream'
}
+17
View File
@@ -0,0 +1,17 @@
export type EmbeddingVisibility =
| 'latest'
| 'latest-approved'
| 'archived'
| 'archived-approved'
| 'deleted'
export function embeddingVisibilityFor(isLatest: boolean, isApproved: boolean): Exclude<
EmbeddingVisibility,
'deleted'
> {
if (isLatest && isApproved) return 'latest-approved'
if (isLatest) return 'latest'
if (isApproved) return 'archived-approved'
return 'archived'
}
+144 -18
View File
@@ -2,14 +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'),
getGitHubProviderAccountIdInternal: Symbol('getGitHubProviderAccountIdInternal'),
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
setGitHubCreatedAtInternal: Symbol('setGitHubCreatedAtInternal'),
syncGitHubProfileInternal: Symbol('syncGitHubProfileInternal'),
},
},
}))
@@ -37,7 +40,6 @@ describe('requireGitHubAccountAge', () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS,
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
@@ -48,9 +50,10 @@ describe('requireGitHubAccountAge', () => {
expect(fetchMock).not.toHaveBeenCalled()
expect(runMutation).not.toHaveBeenCalled()
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
expect(runQuery).not.toHaveBeenCalledWith(internal.users.getGitHubProviderAccountIdInternal, {
userId: 'users:1',
})
expect(runQuery).not.toHaveBeenCalledWith(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId: 'users:1' },
)
})
it('rejects deactivated users', async () => {
@@ -77,7 +80,6 @@ describe('requireGitHubAccountAge', () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
})
const runMutation = vi.fn()
@@ -95,7 +97,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -115,10 +116,9 @@ describe('requireGitHubAccountAge', () => {
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(),
})
})
@@ -127,7 +127,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce(null)
const runMutation = vi.fn()
@@ -146,7 +145,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('abc123')
const runMutation = vi.fn()
@@ -165,7 +163,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -182,7 +179,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -199,7 +195,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -216,7 +211,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -242,7 +236,6 @@ describe('requireGitHubAccountAge', () => {
.mockResolvedValueOnce({
_id: 'users:1',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
.mockResolvedValueOnce('12345')
const runMutation = vi.fn()
@@ -268,3 +261,136 @@ 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(),
})
})
})
+82 -15
View File
@@ -2,14 +2,33 @@ 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
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')
@@ -18,26 +37,19 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
let createdAt = user.githubCreatedAt ?? null
if (!createdAt) {
const providerAccountId = await ctx.runQuery(internal.users.getGitHubProviderAccountIdInternal, {
userId,
})
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')
}
if (!/^[0-9]+$/.test(providerAccountId)) {
throw new ConvexError('GitHub account lookup failed')
}
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
const token = process.env.GITHUB_TOKEN
if (token) {
headers.Authorization = `Bearer ${token}`
}
assertGitHubNumericId(providerAccountId)
// Fetch by immutable GitHub numeric ID to avoid username swap attacks entirely.
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
headers,
headers: buildGitHubHeaders(),
})
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
@@ -51,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,
})
}
@@ -71,3 +82,59 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
)
}
}
/**
* Sync the user's GitHub profile (username, avatar) from the GitHub API.
* This handles the case where a user renames their GitHub account.
* Uses the immutable GitHub numeric ID to fetch the current profile.
*/
export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt || user.deactivatedAt) return
const now = Date.now()
const lastSyncedAt = user.githubProfileSyncedAt ?? null
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
)
if (!providerAccountId) return
assertGitHubNumericId(providerAccountId)
const response = await fetch(`${GITHUB_API}/user/${providerAccountId}`, {
headers: buildGitHubHeaders(),
})
if (!response.ok) {
// Silently fail - this is a best-effort sync, not critical path
console.warn(`[syncGitHubProfile] GitHub API error for user ${userId}: ${response.status}`)
return
}
const payload = (await response.json()) as GitHubUser
const newLogin = payload.login?.trim()
const newImage = payload.avatar_url?.trim()
const profileName = payload.name?.trim()
if (!newLogin) return
const args: {
userId: Id<'users'>
name: string
image?: string
syncedAt: number
profileName?: string
} = {
userId,
name: newLogin,
image: newImage,
syncedAt: now,
}
if (profileName && profileName !== newLogin) {
args.profileName = profileName
}
await ctx.runMutation(internal.users.syncGitHubProfileInternal, args)
}
+19
View File
@@ -0,0 +1,19 @@
export const GITHUB_PROFILE_SYNC_WINDOW_MS = 6 * 60 * 60 * 1000
export function shouldScheduleGitHubProfileSync(
user:
| {
deletedAt?: number
deactivatedAt?: number
githubProfileSyncedAt?: number
}
| null
| undefined,
now: number,
) {
if (!user || user.deletedAt || user.deactivatedAt) return false
const lastSyncedAt = user.githubProfileSyncedAt ?? null
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return false
return true
}
+54
View File
@@ -0,0 +1,54 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitHubBackupContext } from './githubBackup'
import { readGitHubBackupFile } from './githubRestoreHelpers'
function makeContext(): GitHubBackupContext {
return {
token: 'token',
repo: 'owner/repo',
repoOwner: 'owner',
repoName: 'repo',
branch: 'main',
root: 'skills',
}
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('githubRestoreHelpers', () => {
it('decodes base64 payloads (including newlines) into bytes', async () => {
const content = 'SGVs\n bG8h' // "Hello!" with whitespace/newline
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ content, encoding: 'base64' }),
text: async () => '',
})),
)
const bytes = await readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')
expect(bytes).not.toBeNull()
expect(Buffer.from(bytes!).toString('utf8')).toBe('Hello!')
})
it('throws on unsupported GitHub content encoding', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
json: async () => ({ content: 'eA==', encoding: 'utf-16' }),
text: async () => '',
})),
)
await expect(readGitHubBackupFile(makeContext(), 'Owner', 'slug', 'SKILL.md')).rejects.toThrow(
/Unsupported GitHub content encoding/i,
)
})
})
+159
View File
@@ -0,0 +1,159 @@
'use node'
import type { GitHubBackupContext } from './githubBackup'
const GITHUB_API = 'https://api.github.com'
const META_FILENAME = '_meta.json'
const USER_AGENT = 'clawhub/skills-restore'
type GitHubContentsEntry = {
name?: string
path?: string
type?: string // 'file' | 'dir'
size?: number
}
type GitHubBlobResponse = {
content?: string
encoding?: string
size?: number
}
/**
* List all files in a skill's backup directory (excluding _meta.json).
* Uses the Contents API scoped to the target directory instead of fetching
* the entire repository tree, which is critical for bulk restore performance.
* Returns relative file paths (e.g. "SKILL.md", "lib/helper.ts").
*/
export async function listGitHubBackupFiles(
context: GitHubBackupContext,
ownerHandle: string,
slug: string,
): Promise<string[]> {
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
return listFilesRecursive(context, skillRoot, '')
}
/**
* Recursively list files under a directory using the GitHub Contents API.
* Each call is scoped to one directory, avoiding full-repo tree downloads.
*/
async function listFilesRecursive(
context: GitHubBackupContext,
basePath: string,
relativePath: string,
): Promise<string[]> {
const dirPath = relativePath ? `${basePath}/${relativePath}` : basePath
try {
const entries = await githubGet<GitHubContentsEntry[]>(
context.token,
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(dirPath)}?ref=${context.branch}`,
)
if (!Array.isArray(entries)) return []
const files: string[] = []
for (const entry of entries) {
if (!entry.name || !entry.type) continue
const entryRelative = relativePath ? `${relativePath}/${entry.name}` : entry.name
if (entry.type === 'file') {
// Skip the meta file
if (entry.name === META_FILENAME) continue
files.push(entryRelative)
} else if (entry.type === 'dir') {
// Recurse into subdirectories
const subFiles = await listFilesRecursive(context, basePath, entryRelative)
files.push(...subFiles)
}
}
return files
} catch (error) {
if (isNotFoundError(error)) return []
throw error
}
}
/**
* Read a single file from the GitHub backup repository.
* Returns the file content as a Uint8Array, or null if not found.
*/
export async function readGitHubBackupFile(
context: GitHubBackupContext,
ownerHandle: string,
slug: string,
filePath: string,
): Promise<Uint8Array | null> {
const skillRoot = buildSkillRoot(context.root, ownerHandle, slug)
const fullPath = `${skillRoot}/${filePath}`
try {
const response = await githubGet<GitHubBlobResponse>(
context.token,
`/repos/${context.repoOwner}/${context.repoName}/contents/${encodePath(fullPath)}?ref=${context.branch}`,
)
if (!response.content) return null
if (response.encoding && response.encoding !== 'base64') {
throw new Error(`Unsupported GitHub content encoding: ${response.encoding}`)
}
return fromBase64Bytes(response.content)
} catch (error) {
if (isNotFoundError(error)) return null
throw error
}
}
function buildSkillRoot(root: string, ownerHandle: string, slug: string) {
const ownerSegment = normalizeOwner(ownerHandle)
return `${root}/${ownerSegment}/${slug}`
}
function normalizeOwner(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || 'unknown'
}
function encodePath(path: string) {
return path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
}
function fromBase64Bytes(value: string) {
// GitHub may include newlines in the base64 payload.
const normalized = value.replace(/\s/g, '')
return new Uint8Array(Buffer.from(normalized, 'base64'))
}
async function githubGet<T>(token: string, path: string): Promise<T> {
const response = await fetch(`${GITHUB_API}${path}`, {
headers: {
Authorization: `token ${token}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
})
if (!response.ok) {
const message = await response.text()
throw new Error(`GitHub GET ${path} failed: ${message}`)
}
return (await response.json()) as T
}
function isNotFoundError(error: unknown) {
return (
error instanceof Error && (error.message.includes('404') || error.message.includes('Not Found'))
)
}
+16 -6
View File
@@ -1,19 +1,31 @@
/* @vitest-environment node */
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getClientIp } from './httpRateLimit'
describe('getClientIp', () => {
it('uses forwarded headers by default when cf-connecting-ip is missing', () => {
let prev: string | undefined
beforeEach(() => {
prev = process.env.TRUST_FORWARDED_IPS
})
afterEach(() => {
if (prev === undefined) {
delete process.env.TRUST_FORWARDED_IPS
} else {
process.env.TRUST_FORWARDED_IPS = prev
}
})
it('returns null when cf-connecting-ip is missing (CF-only default)', () => {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-for': '203.0.113.9',
},
})
delete process.env.TRUST_FORWARDED_IPS
expect(getClientIp(request)).toBe('203.0.113.9')
expect(getClientIp(request)).toBeNull()
})
it('can disable forwarded headers explicitly', () => {
it('keeps forwarded headers disabled when TRUST_FORWARDED_IPS=false', () => {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-for': '203.0.113.9',
@@ -21,7 +33,6 @@ describe('getClientIp', () => {
})
process.env.TRUST_FORWARDED_IPS = 'false'
expect(getClientIp(request)).toBeNull()
delete process.env.TRUST_FORWARDED_IPS
})
it('returns first ip from cf-connecting-ip', () => {
@@ -41,6 +52,5 @@ describe('getClientIp', () => {
})
process.env.TRUST_FORWARDED_IPS = 'true'
expect(getClientIp(request)).toBe('203.0.113.9')
delete process.env.TRUST_FORWARDED_IPS
})
})
+3 -2
View File
@@ -147,9 +147,10 @@ function shouldTrustForwardedIps() {
const value = String(process.env.TRUST_FORWARDED_IPS ?? '')
.trim()
.toLowerCase()
if (!value) return true
// Hardening default: CF-only. Forwarded headers are trivial to spoof unless you
// control the trusted proxy layer.
if (!value) return false
if (value === '1' || value === 'true' || value === 'yes') return true
if (value === '0' || value === 'false' || value === 'no') return false
return false
}
+128
View File
@@ -0,0 +1,128 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
type ReservedSlug = Doc<'reservedSlugs'>
const DEFAULT_ACTIVE_LIMIT = 25
function reservedSlugQuery(ctx: QueryCtx | MutationCtx, slug: string) {
return ctx.db
.query('reservedSlugs')
.withIndex('by_slug_active_deletedAt', (q) => q.eq('slug', slug).eq('releasedAt', undefined))
.order('desc')
}
export async function listActiveReservedSlugsForSlug(
ctx: QueryCtx | MutationCtx,
slug: string,
limit = DEFAULT_ACTIVE_LIMIT,
) {
return reservedSlugQuery(ctx, slug).take(limit)
}
export async function getLatestActiveReservedSlug(ctx: QueryCtx | MutationCtx, slug: string) {
return (await reservedSlugQuery(ctx, slug).take(1))[0] ?? null
}
export async function releaseDuplicateActiveReservations(
ctx: MutationCtx,
active: ReservedSlug[],
keepId: Id<'reservedSlugs'> | null | undefined,
releasedAt: number,
) {
for (const stale of active) {
if (keepId && stale._id === keepId) continue
await ctx.db.patch(stale._id, { releasedAt })
}
}
export async function reserveSlugForHardDeleteFinalize(
ctx: MutationCtx,
params: {
slug: string
originalOwnerUserId: Id<'users'>
deletedAt: number
expiresAt: number
},
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
if (latest) {
// Only extend reservation if it matches the owner being deleted.
// If it points elsewhere, it likely came from a reclaim flow; do not overwrite.
if (latest.originalOwnerUserId === params.originalOwnerUserId) {
await ctx.db.patch(latest._id, {
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
releasedAt: undefined,
})
}
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.deletedAt)
return
}
const inserted = await ctx.db.insert('reservedSlugs', {
slug: params.slug,
originalOwnerUserId: params.originalOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
})
await releaseDuplicateActiveReservations(ctx, active, inserted, params.deletedAt)
}
export async function upsertReservedSlugForRightfulOwner(
ctx: MutationCtx,
params: {
slug: string
rightfulOwnerUserId: Id<'users'>
deletedAt: number
expiresAt: number
reason?: string
},
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
let keepId: Id<'reservedSlugs'>
if (latest) {
keepId = latest._id
await ctx.db.patch(latest._id, {
originalOwnerUserId: params.rightfulOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
reason: params.reason ?? latest.reason,
releasedAt: undefined,
})
} else {
keepId = await ctx.db.insert('reservedSlugs', {
slug: params.slug,
originalOwnerUserId: params.rightfulOwnerUserId,
deletedAt: params.deletedAt,
expiresAt: params.expiresAt,
reason: params.reason,
})
}
await releaseDuplicateActiveReservations(ctx, active, keepId, params.deletedAt)
}
export async function enforceReservedSlugCooldownForNewSkill(
ctx: MutationCtx,
params: { slug: string; userId: Id<'users'>; now: number },
) {
const active = await listActiveReservedSlugsForSlug(ctx, params.slug)
const latest = active[0] ?? null
if (!latest) return
if (latest.expiresAt > params.now && latest.originalOwnerUserId !== params.userId) {
throw new Error(
`Slug "${params.slug}" is reserved for its previous owner until ${new Date(latest.expiresAt).toISOString()}. ` +
'Please choose a different slug.',
)
}
await ctx.db.patch(latest._id, { releasedAt: params.now })
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
}
+39 -17
View File
@@ -19,6 +19,7 @@ import { generateSkillSummary } from './skillSummary'
import {
buildEmbeddingText,
getFrontmatterMetadata,
getFrontmatterValue,
hashSkillFiles,
isTextFile,
parseClawdisMetadata,
@@ -63,10 +64,19 @@ export type PublishVersionArgs = {
}>
}
export type PublishOptions = {
bypassGitHubAccountAge?: boolean
bypassNewSkillRateLimit?: boolean
bypassQualityGate?: boolean
skipBackup?: boolean
skipWebhook?: boolean
}
export async function publishVersionForUser(
ctx: ActionCtx,
userId: Id<'users'>,
args: PublishVersionArgs,
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim()
const slug = args.slug.trim().toLowerCase()
@@ -79,7 +89,9 @@ export async function publishVersionForUser(
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
if (!options.bypassGitHubAccountAge) {
await requireGitHubAccountAge(ctx, userId)
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
})) as Doc<'skills'> | null
@@ -122,13 +134,18 @@ export async function publishVersionForUser(
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now()
const now = Date.now()
const frontmatterMetadata = getFrontmatterMetadata(frontmatter)
const summaryFromFrontmatter =
// Check for description in metadata.description (nested) or description (direct frontmatter field)
const metadataDescription =
frontmatterMetadata &&
typeof frontmatterMetadata === 'object' &&
!Array.isArray(frontmatterMetadata) &&
typeof (frontmatterMetadata as Record<string, unknown>).description === 'string'
? ((frontmatterMetadata as Record<string, unknown>).description as string)
: undefined
const directDescription = getFrontmatterValue(frontmatter, 'description')
// Prioritize the new description from frontmatter over the existing skill summary
// This ensures updates to the description are reflected on subsequent publishes (#301)
const summaryFromFrontmatter = metadataDescription ?? directDescription
const summary = await generateSkillSummary({
slug,
displayName,
@@ -137,7 +154,7 @@ export async function publishVersionForUser(
})
let qualityAssessment: QualityAssessment | null = null
if (isNewSkill) {
if (isNewSkill && !options.bypassQualityGate) {
const ownerActivity = (await ctx.runQuery(internal.skills.getOwnerSkillActivityInternal, {
ownerUserId: userId,
limit: QUALITY_ACTIVITY_LIMIT,
@@ -240,6 +257,7 @@ export async function publishVersionForUser(
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
bypassNewSkillRateLimit: options.bypassNewSkillRateLimit || undefined,
files: safeFiles.map((file) => ({
...file,
path: file.path,
@@ -273,24 +291,28 @@ export async function publishVersionForUser(
const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
void ctx.scheduler
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
if (!options.skipBackup) {
void ctx.scheduler
.runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
slug,
version,
displayName,
ownerHandle,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
console.error('GitHub backup scheduling failed', error)
})
}
if (!options.skipWebhook) {
void schedulePublishWebhook(ctx, {
slug,
version,
displayName,
ownerHandle,
files: safeFiles,
publishedAt: Date.now(),
})
.catch((error) => {
console.error('GitHub backup scheduling failed', error)
})
void schedulePublishWebhook(ctx, {
slug,
version,
displayName,
})
}
return publishResult
}
+16
View File
@@ -17,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()),
@@ -500,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()),
@@ -571,6 +586,7 @@ export default defineSchema({
apiTokens,
rateLimits,
downloadDedupes,
reservedSlugs,
githubBackupSyncState,
userSyncRoots,
userSkillInstalls,
+5
View File
@@ -45,6 +45,7 @@ describe('search helpers', () => {
skill: makePublicSkill({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' }),
version: null,
ownerHandle: 'steipete',
owner: null,
},
]
const runQuery = vi
@@ -154,6 +155,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'one',
owner: null,
},
{
embeddingId: 'skillEmbeddings:b',
@@ -165,6 +167,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'two',
owner: null,
},
]
const fallbackEntries = [
@@ -177,6 +180,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'one',
owner: null,
},
{
skill: makePublicSkill({
@@ -187,6 +191,7 @@ describe('search helpers', () => {
}),
version: null,
ownerHandle: 'three',
owner: null,
},
]
+38 -28
View File
@@ -1,18 +1,36 @@
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { QueryCtx } from './_generated/server'
import { action, internalQuery } from './_generated/server'
import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
import { generateEmbedding } from './lib/embeddings'
import { toPublicSkill, toPublicSoul } from './lib/public'
import { toPublicSkill, toPublicSoul, toPublicUser } from './lib/public'
import { matchesExactTokens, tokenize } from './lib/searchText'
import { isSkillSuspicious } from './lib/skillSafety'
type OwnerInfo = { handle: string | null; owner: ReturnType<typeof toPublicUser> | null }
function makeOwnerInfoGetter(ctx: Pick<QueryCtx, 'db'>) {
const ownerCache = new Map<Id<'users'>, Promise<OwnerInfo>>()
return (ownerUserId: Id<'users'>) => {
const cached = ownerCache.get(ownerUserId)
if (cached) return cached
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => ({
handle: ownerDoc?.handle ?? (ownerDoc?._id ? String(ownerDoc._id) : null),
owner: toPublicUser(ownerDoc),
}))
ownerCache.set(ownerUserId, ownerPromise)
return ownerPromise
}
}
type SkillSearchEntry = {
embeddingId?: Id<'skillEmbeddings'>
skill: NonNullable<ReturnType<typeof toPublicSkill>>
version: Doc<'skillVersions'> | null
ownerHandle: string | null
owner: ReturnType<typeof toPublicUser> | null
}
type SearchResult = SkillSearchEntry & { score: number }
@@ -211,17 +229,7 @@ export const hydrateResults = internalQuery({
nonSuspiciousOnly: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db
.get(ownerUserId)
.then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
const getOwnerInfo = makeOwnerInfoGetter(ctx)
const entries: Array<SkillSearchEntry | null> = await Promise.all(
args.embeddingIds.map(async (embeddingId) => {
@@ -230,13 +238,19 @@ export const hydrateResults = internalQuery({
const skill = await ctx.db.get(embedding.skillId)
if (!skill || skill.softDeletedAt) return null
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null
const [version, ownerHandle] = await Promise.all([
const [version, ownerInfo] = await Promise.all([
ctx.db.get(embedding.versionId),
getOwnerHandle(skill.ownerUserId),
getOwnerInfo(skill.ownerUserId),
])
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { embeddingId, skill: publicSkill, version, ownerHandle }
return {
embeddingId,
skill: publicSkill,
version,
ownerHandle: ownerInfo.handle,
owner: ownerInfo.owner,
}
}),
)
@@ -291,26 +305,22 @@ export const lexicalFallbackSkills = internalQuery({
)
if (matched.length === 0) return []
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
const cached = ownerHandleCache.get(ownerUserId)
if (cached) return cached
const handlePromise = ctx.db
.get(ownerUserId)
.then((owner) => owner?.handle ?? owner?._id ?? null)
ownerHandleCache.set(ownerUserId, handlePromise)
return handlePromise
}
const getOwnerInfo = makeOwnerInfoGetter(ctx)
const entries = await Promise.all(
matched.map(async (skill) => {
const [version, ownerHandle] = await Promise.all([
const [version, ownerInfo] = await Promise.all([
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : Promise.resolve(null),
getOwnerHandle(skill.ownerUserId),
getOwnerInfo(skill.ownerUserId),
])
const publicSkill = toPublicSkill(skill)
if (!publicSkill) return null
return { skill: publicSkill, version, ownerHandle }
return {
skill: publicSkill,
version,
ownerHandle: ownerInfo.handle,
owner: ownerInfo.owner,
}
}),
)
const validEntries = entries.filter((entry): entry is SkillSearchEntry => entry !== null)
+10
View File
@@ -84,6 +84,16 @@ describe('skills anti-spam guards', () => {
},
}
}
if (table === 'reservedSlugs') {
return {
withIndex: (name: string) => {
if (name === 'by_slug_active_deletedAt') {
return { order: () => ({ take: async () => [] }) }
}
throw new Error(`unexpected index ${name}`)
},
}
}
throw new Error(`unexpected table ${table}`)
}),
}
+405 -108
View File
@@ -27,6 +27,14 @@ import {
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,
@@ -50,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
@@ -155,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',
@@ -437,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,
@@ -455,6 +467,7 @@ type PublicSkillEntry = {
skill: NonNullable<ReturnType<typeof toPublicSkill>>
latestVersion: PublicSkillListVersion | null
ownerHandle: string | null
owner: ReturnType<typeof toPublicUser> | null
}
type PublicSkillListVersion = Pick<
@@ -479,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(
@@ -488,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,
}
}),
)
@@ -776,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) => {
@@ -1187,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()),
@@ -1420,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,
@@ -1664,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' ||
@@ -2099,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.
@@ -2622,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)
}
},
})
@@ -2666,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,
@@ -2745,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,
@@ -2790,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,
@@ -2812,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) => {
@@ -2971,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(),
@@ -3013,16 +3312,16 @@ export const insertVersion = internalMutation({
),
embedding: v.array(v.number()),
},
handler: async (ctx, args) => {
const userId = args.userId
const user = await ctx.db.get(userId)
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
handler: async (ctx, args) => {
const userId = args.userId
const user = await ctx.db.get(userId)
if (!user || user.deletedAt || user.deactivatedAt) throw new Error('User not found')
const now = Date.now()
const now = Date.now()
let skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
let skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (skill && skill.ownerUserId !== userId) {
@@ -3048,15 +3347,26 @@ export const insertVersion = internalMutation({
throw new Error('Only the owner can publish updates')
}
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
}
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
skill = { ...skill, ownerUserId: userId }
}
const qualityAssessment = args.qualityAssessment
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
const moderationNotes = isQualityQuarantine
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,
@@ -3065,13 +3375,18 @@ export const insertVersion = internalMutation({
similarRecentCount: qualityAssessment.similarRecentCount,
reason: qualityAssessment.reason,
signals: qualityAssessment.signals,
evaluatedAt: now,
}
: undefined
evaluatedAt: now,
}
: undefined
if (!skill) {
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
enforceNewSkillRateLimit(ownerTrustSignals)
if (!skill) {
// 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
@@ -3134,7 +3449,7 @@ export const insertVersion = internalMutation({
official: undefined,
deprecated: undefined,
},
moderationStatus: 'hidden',
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
quality: qualityRecord,
@@ -3205,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,
@@ -3223,7 +3538,7 @@ export const insertVersion = internalMutation({
embedding: args.embedding,
isLatest: true,
isApproved,
visibility: visibilityFor(true, isApproved),
visibility: embeddingVisibilityFor(true, isApproved),
updatedAt: now,
})
@@ -3235,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,
})
}
@@ -3285,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,
@@ -3311,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))
+5 -11
View File
@@ -3,6 +3,7 @@ import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertModerator, requireUser, requireUserFromAction } from './lib/access'
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import { toPublicSoul, toPublicUser } from './lib/public'
import { getFrontmatterValue, hashSkillFiles } from './lib/skills'
import { generateSoulChangelogPreview } from './lib/soulChangelog'
@@ -357,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(),
})
}
@@ -479,7 +480,7 @@ export const insertVersion = internalMutation({
embedding: args.embedding,
isLatest: true,
isApproved: true,
visibility: visibilityFor(true, true),
visibility: embeddingVisibilityFor(true, true),
updatedAt: now,
})
@@ -491,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,
})
}
@@ -547,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,
})
}
@@ -565,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))
+194 -48
View File
@@ -2,9 +2,10 @@ import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import type { ActionCtx, MutationCtx } from './_generated/server'
import { internalAction, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { syncGitHubProfile } from './lib/githubAccount'
import { toPublicUser } from './lib/public'
import { buildUserSearchResults } from './lib/userSearch'
@@ -21,17 +22,6 @@ export const getByIdInternal = internalQuery({
handler: async (ctx, args) => ctx.db.get(args.userId),
})
export const getGitHubProviderAccountIdInternal = internalQuery({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const account = await ctx.db
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', args.userId).eq('provider', 'github'))
.unique()
return account?.providerAccountId ?? null
},
})
export const searchInternal = internalQuery({
args: {
actorUserId: v.id('users'),
@@ -56,21 +46,97 @@ export const searchInternal = internalQuery({
return { items, total: result.total }
},
})
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) => {
@@ -191,7 +257,6 @@ export const deleteAccount = mutation({
isAnonymous: undefined,
bio: undefined,
githubCreatedAt: undefined,
githubFetchedAt: undefined,
updatedAt: now,
})
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId })
@@ -335,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, {
@@ -369,11 +436,11 @@ 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(
@@ -400,6 +467,7 @@ async function unbanUserWithActor(
}
const now = Date.now()
const bannedAt = target.deletedAt
await ctx.db.patch(targetUserId, {
deletedAt: undefined,
banReason: undefined,
@@ -407,18 +475,97 @@ async function unbanUserWithActor(
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 },
metadata: { reason: reason || undefined, restoredSkills: restoredCount },
createdAt: now,
})
return { ok: true as const, alreadyUnbanned: false }
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.
@@ -442,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
@@ -477,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',
@@ -487,7 +633,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
trigger: 'vt.malicious',
sha256hash: args.sha256hash,
slug: args.slug,
deletedSkills: skills.length,
hiddenSkills: hiddenCount,
},
createdAt: now,
})
@@ -496,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 }
},
})
+1
View File
@@ -10,6 +10,7 @@ read_when:
## Web auth (GitHub OAuth)
- Convex Auth + GitHub OAuth App.
- GitHub is the only supported login provider.
- Env vars:
- `AUTH_GITHUB_ID`
- `AUTH_GITHUB_SECRET`
+10 -2
View File
@@ -27,8 +27,8 @@ Headers:
IP source:
- Uses `cf-connecting-ip` first, then falls back to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip`.
- Set `TRUST_FORWARDED_IPS=false` to disable forwarded-header fallback.
- Uses `cf-connecting-ip` (Cloudflare) for client IP by default.
- Set `TRUST_FORWARDED_IPS=true` to opt in to `x-real-ip`, `x-forwarded-for`, or `fly-client-ip` (non-Cloudflare deployments).
## Public endpoints (no auth)
@@ -156,6 +156,14 @@ Publishes a new version.
Soft-delete / restore a skill (moderator/admin only).
Status codes:
- `200`: ok
- `401`: unauthorized
- `403`: forbidden
- `404`: skill/user not found
- `500`: internal server error
### `POST /api/v1/users/ban`
Ban a user and hard-delete owned skills (moderator/admin only).
-1
View File
@@ -62,7 +62,6 @@ read_when:
- 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; set when `githubCreatedAt` is populated)
- Gate applies to web uploads, CLI publish, and GitHub import.
- If GitHub responds `403` or `429`, publish fails with:
- `GitHub API rate limit exceeded — please try again in a few minutes`
+45
View File
@@ -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)
})
@@ -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,
})
})
})
+1 -1
View File
@@ -75,7 +75,7 @@ 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) {
+16 -6
View File
@@ -10,6 +10,7 @@ import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicUser } from '../lib/publicUser'
import { canManageSkill, isModerator } from '../lib/roles'
import { useAuthStatus } from '../lib/useAuthStatus'
import { UserBadge } from './UserBadge'
const SkillDiffCard = lazy(() =>
import('./SkillDiffCard').then((m) => ({ default: m.SkillDiffCard })),
@@ -138,7 +139,12 @@ function LlmAnalysisDetail({ analysis }: { analysis: LlmAnalysis }) {
<button
type="button"
className="analysis-detail-header"
onClick={() => setIsOpen((prev) => !prev)}
onClick={() => {
// Drag-select to copy summary text should not toggle open/closed.
const selection = window.getSelection()
if (selection && !selection.isCollapsed) return
setIsOpen((prev) => !prev)
}}
aria-expanded={isOpen}
>
<span className="analysis-summary-text">{analysis.summary}</span>
@@ -646,11 +652,15 @@ export function SkillDetailPage({
{skill.stats.installsCurrent ?? 0} current · {skill.stats.installsAllTime ?? 0}{' '}
all-time
</div>
{owner?.handle ? (
<div className="stat">
by <a href={`/u/${owner.handle}`}>@{owner.handle}</a>
</div>
) : null}
<div className="stat">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
</div>
{forkOf && forkOfHref ? (
<div className="stat">
{forkOfLabel}{' '}
+56
View File
@@ -0,0 +1,56 @@
import type { PublicUser } from '../lib/publicUser'
type UserBadgeProps = {
user: PublicUser | null | undefined
fallbackHandle?: string | null
prefix?: string
size?: 'sm' | 'md'
link?: boolean
showName?: boolean
}
export function UserBadge({
user,
fallbackHandle,
prefix = 'by',
size = 'sm',
link = true,
showName = false,
}: UserBadgeProps) {
const handle = user?.handle ?? user?.name ?? fallbackHandle ?? null
const href = user?.handle ? `/u/${encodeURIComponent(user.handle)}` : null
const label = handle ? `@${handle}` : 'user'
const image = user?.image ?? null
const displayName = user?.displayName?.trim() || null
const hasUsefulName =
showName && Boolean(displayName) && Boolean(handle) && displayName!.toLowerCase() !== handle!.toLowerCase()
const initial = (user?.displayName ?? user?.name ?? handle ?? 'u').charAt(0).toUpperCase()
return (
<span className={`user-badge user-badge-${size}`}>
{prefix ? <span className="user-badge-prefix">{prefix}</span> : null}
<span className="user-avatar" aria-hidden="true">
{image ? (
<img className="user-avatar-img" src={image} alt="" loading="lazy" />
) : (
<span className="user-avatar-fallback">{initial}</span>
)}
</span>
{hasUsefulName ? (
<>
<span className="user-name">{displayName}</span>
<span className="user-name-sep" aria-hidden="true">
·
</span>
</>
) : null}
{link && href ? (
<a className="user-handle" href={href}>
{label}
</a>
) : (
<span className="user-handle">{label}</span>
)}
</span>
)
}
+34 -20
View File
@@ -5,10 +5,10 @@ import { api } from '../../convex/_generated/api'
import { InstallSwitcher } from '../components/InstallSwitcher'
import { SkillCard } from '../components/SkillCard'
import { SoulCard } from '../components/SoulCard'
import { UserBadge } from '../components/UserBadge'
import { getSkillBadges } from '../lib/badges'
import type { PublicSkill, PublicSoul } from '../lib/publicUser'
import type { PublicSkill, PublicSoul, PublicUser } from '../lib/publicUser'
import { getSiteMode } from '../lib/site'
import { mapPublicSkillPageEntries } from '../lib/skillPageEntries'
export const Route = createFileRoute('/')({
component: Home,
@@ -23,21 +23,19 @@ function SkillsHome() {
type SkillPageEntry = {
skill: PublicSkill
ownerHandle?: string | null
owner?: PublicUser | null
latestVersion?: unknown
}
const highlighted =
(useQuery(api.skills.list, {
batch: 'highlighted',
limit: 6,
}) as PublicSkill[]) ?? []
(useQuery(api.skills.listHighlightedPublic, { limit: 6 }) as SkillPageEntry[]) ?? []
const popularResult = useQuery(api.skills.listPublicPageV2, {
paginationOpts: { cursor: null, numItems: 12 },
sort: 'downloads',
dir: 'desc',
nonSuspiciousOnly: true,
}) as { page: SkillPageEntry[] } | undefined
const popular = mapPublicSkillPageEntries(popularResult?.page)
const popular = popularResult?.page ?? []
return (
<main>
@@ -87,16 +85,24 @@ function SkillsHome() {
{highlighted.length === 0 ? (
<div className="card">No highlighted skills yet.</div>
) : (
highlighted.map((skill) => (
highlighted.map((entry) => (
<SkillCard
key={skill._id}
skill={skill}
badge={getSkillBadges(skill)}
key={entry.skill._id}
skill={entry.skill}
badge={getSkillBadges(entry.skill)}
summaryFallback="A fresh skill bundle."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
{entry.skill.stats.stars} · {entry.skill.stats.downloads} · {' '}
{entry.skill.stats.installsAllTime ?? 0}
</div>
</div>
}
/>
@@ -112,15 +118,23 @@ function SkillsHome() {
{popular.length === 0 ? (
<div className="card">No skills yet. Be the first.</div>
) : (
popular.map((skill) => (
popular.map((entry) => (
<SkillCard
key={skill._id}
skill={skill}
key={entry.skill._id}
skill={entry.skill}
summaryFallback="Agent-ready skill pack."
meta={
<div className="stat">
{skill.stats.stars} · {skill.stats.downloads} · {' '}
{skill.stats.installsAllTime ?? 0}
<div className="skill-card-footer-rows">
<UserBadge
user={entry.owner}
fallbackHandle={entry.ownerHandle ?? null}
prefix="by"
link={false}
/>
<div className="stat">
{entry.skill.stats.stars} · {entry.skill.stats.downloads} · {' '}
{entry.skill.stats.installsAllTime ?? 0}
</div>
</div>
}
/>
+18 -6
View File
@@ -4,8 +4,9 @@ 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',
@@ -52,6 +53,7 @@ type SkillListEntry = {
}
} | null
ownerHandle?: string | null
owner?: PublicUser | null
searchScore?: number
}
@@ -60,6 +62,7 @@ type SkillSearchEntry = {
version: Doc<'skillVersions'> | null
score: number
ownerHandle?: string | null
owner?: PublicUser | null
}
function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
@@ -221,6 +224,7 @@ export function SkillsIndex() {
skill: entry.skill,
latestVersion: entry.version,
ownerHandle: entry.ownerHandle ?? null,
owner: entry.owner ?? null,
searchScore: entry.score,
}))
}
@@ -454,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}
@@ -464,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>
}
/>
@@ -478,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">
@@ -497,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.
+123 -13
View File
@@ -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) {
@@ -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 {