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>
This commit is contained in:
autogame-17
2026-02-14 23:16:17 +08:00
parent a58f0166fa
commit 77ebc21fd9
7 changed files with 1081 additions and 25 deletions
+192
View File
@@ -0,0 +1,192 @@
'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 {
listGitHubBackupFiles,
readGitHubBackupFile,
} from './lib/githubRestoreHelpers'
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> => {
if (!isGitHubBackupConfigured()) {
return { slug: args.slug, status: 'error', detail: 'GitHub backup not configured' }
}
try {
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.`,
}
}
// Squatter eviction is handled synchronously inside restoreSkillInternal
// to avoid the race condition where an async hardDelete hasn't completed
// by the time we try to insert the restored skill.
}
// 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 blob = new Blob([fileContent], { type: 'text/plain' })
const storageId = await ctx.storage.store(blob)
storedFiles.push({
path: filePath,
size: fileContent.byteLength,
storageId,
sha256,
contentType: 'text/plain',
})
}
if (storedFiles.length === 0) {
return { slug: args.slug, status: 'error', detail: 'Could not download any backup files' }
}
// Re-create the skill via the restore mutation.
// Squatter eviction (if needed) is handled synchronously inside the mutation
// to ensure the slug is free in the same transaction as the skill creation.
await ctx.runMutation(internal.githubRestoreMutations.restoreSkillInternal, {
actorUserId: args.actorUserId,
ownerUserId: args.ownerUserId,
slug: args.slug,
displayName: meta.displayName,
version: meta.latest.version,
forceOverwriteSquatter: args.forceOverwriteSquatter,
files: storedFiles,
})
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')
}
+200
View File
@@ -0,0 +1,200 @@
import { v } from 'convex/values'
import { internalMutation } from './_generated/server'
import { assertAdmin } from './lib/access'
import { deriveModerationFlags } from './lib/moderation'
import { parseFrontmatter } from './lib/skills'
/**
* Internal mutation to re-create a skill record from backup data.
* Called by the restore action after files have been uploaded to storage.
*/
export const restoreSkillInternal = internalMutation({
args: {
actorUserId: v.id('users'),
ownerUserId: v.id('users'),
slug: v.string(),
displayName: v.string(),
version: v.string(),
forceOverwriteSquatter: v.optional(v.boolean()),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id('_storage'),
sha256: v.string(),
contentType: 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('Actor not found')
assertAdmin(actor)
const owner = await ctx.db.get(args.ownerUserId)
if (!owner) throw new Error('Owner not found')
const now = Date.now()
// Check slug availability -- handle squatter eviction synchronously within
// the same transaction to avoid the race condition where an async hard-delete
// hasn't completed by the time we try to insert the restored skill.
const existingSkill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (existingSkill) {
if (existingSkill.ownerUserId === args.ownerUserId) {
throw new Error(`Slug "${args.slug}" is already owned by the target user`)
}
if (!args.forceOverwriteSquatter) {
throw new Error(`Slug "${args.slug}" is already taken`)
}
// Synchronously delete the squatter's skill record in this transaction.
// We delete the skill row directly to free the slug immediately.
// Related data (versions, embeddings, etc.) is cleaned up asynchronously.
const squatterUserId = existingSkill.ownerUserId
await ctx.db.delete(existingSkill._id)
// Schedule async cleanup of the squatter's orphaned data (versions, etc.)
// We use a dedicated audit action rather than hardDeleteInternal since
// the skill record is already gone.
await ctx.db.insert('auditLogs', {
actorUserId: args.actorUserId,
action: 'slug.reclaim.sync',
targetType: 'skill',
targetId: existingSkill._id,
metadata: {
slug: args.slug,
squatterUserId,
rightfulOwnerUserId: args.ownerUserId,
reason: 'Synchronous eviction during backup restore',
},
createdAt: now,
})
}
// Parse frontmatter from SKILL.md if present
let frontmatter: Record<string, unknown> = {}
const skillMdFile = args.files.find(
(f: { path: string }) =>
f.path.toLowerCase() === 'skill.md' || f.path.toLowerCase().endsWith('/skill.md'),
)
if (skillMdFile) {
try {
const blob = await ctx.storage.get(skillMdFile.storageId)
if (blob) {
const text = await blob.text()
frontmatter = parseFrontmatter(text)
}
} catch {
// Best-effort frontmatter parsing
}
}
const parsed = { frontmatter }
const summary =
typeof frontmatter.description === 'string'
? (frontmatter.description as string)
: undefined
const moderationFlags = deriveModerationFlags({
skill: { slug: args.slug, displayName: args.displayName, summary },
parsed,
files: args.files,
})
// Create the skill record -- mark as active since this is an admin restore
const skillId = await ctx.db.insert('skills', {
slug: args.slug,
displayName: args.displayName,
summary,
ownerUserId: args.ownerUserId,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: {
redactionApproved: undefined,
highlighted: undefined,
official: undefined,
deprecated: undefined,
},
moderationStatus: 'active',
moderationReason: 'restored.backup',
moderationFlags: moderationFlags.length ? moderationFlags : undefined,
reportCount: 0,
lastReportedAt: undefined,
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 0,
comments: 0,
},
createdAt: now,
updatedAt: now,
})
// Create the version record
const versionId = await ctx.db.insert('skillVersions', {
skillId,
version: args.version,
changelog: 'Restored from backup',
changelogSource: 'auto',
files: args.files,
parsed,
createdBy: args.ownerUserId,
createdAt: now,
softDeletedAt: undefined,
})
// Update the skill with the version reference
await ctx.db.patch(skillId, {
latestVersionId: versionId,
tags: { latest: versionId },
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
updatedAt: now,
})
// Release any slug reservation for this slug
const reservation = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (reservation && !reservation.releasedAt) {
await ctx.db.patch(reservation._id, { releasedAt: now })
}
// Audit log
await ctx.db.insert('auditLogs', {
actorUserId: args.actorUserId,
action: 'skill.restore.backup',
targetType: 'skill',
targetId: skillId,
metadata: {
slug: args.slug,
version: args.version,
ownerUserId: args.ownerUserId,
},
createdAt: now,
})
return { skillId, versionId }
},
})
+106 -9
View File
@@ -599,7 +599,7 @@ async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
return text('Not found', 404, rate.headers)
}
const action = segments[0]
if (action !== 'ban' && action !== 'role') {
if (action !== 'ban' && action !== 'role' && action !== 'restore' && action !== 'reclaim') {
return text('Not found', 404, rate.headers)
}
@@ -610,6 +610,23 @@ async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
return text('Invalid JSON', 400, rate.headers)
}
let actorUserId: Id<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
} catch {
return text('Unauthorized', 401, rate.headers)
}
// Restore and reclaim have different parameter shapes, handle them separately
if (action === 'restore') {
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers)
}
if (action === 'reclaim') {
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() : ''
@@ -626,14 +643,6 @@ async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
return text('Invalid role', 400, rate.headers)
}
let actorUserId: Id<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
} catch {
return text('Unauthorized', 401, rate.headers)
}
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
if (!targetUserId) {
const handle = handleRaw.toLowerCase()
@@ -689,6 +698,94 @@ async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
}
}
/**
* 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 const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler)
async function usersListV1Handler(ctx: ActionCtx, request: Request) {
+154
View File
@@ -0,0 +1,154 @@
'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
const content = fromBase64(response.content)
return new TextEncoder().encode(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 fromBase64(value: string) {
return Buffer.from(value, 'base64').toString('utf8')
}
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'))
)
}
+14
View File
@@ -19,6 +19,7 @@ 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()),
trustedPublisher: v.optional(v.boolean()),
deactivatedAt: v.optional(v.number()),
purgedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
@@ -502,6 +503,18 @@ 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_owner', ['originalOwnerUserId'])
.index('by_expiry', ['expiresAt'])
const githubBackupSyncState = defineTable({
key: v.string(),
cursor: v.optional(v.string()),
@@ -573,6 +586,7 @@ export default defineSchema({
apiTokens,
rateLimits,
downloadDedupes,
reservedSlugs,
githubBackupSyncState,
userSyncRoots,
userSkillInstalls,
+235 -3
View File
@@ -53,6 +53,8 @@ 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
@@ -435,6 +437,34 @@ async function hardDeleteSkillStep(
return
}
case 'finalize': {
// Reserve the slug so the original owner can reclaim it within the cooldown period.
// If a reservation already exists (e.g. created by reclaimSlug for the rightful owner),
// do NOT overwrite it -- the reclaim reservation takes priority.
const existingReservation = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', skill.slug))
.unique()
if (existingReservation) {
// Only update if the existing reservation is for the same owner being deleted
// (i.e. a normal hard-delete, not a reclaim). Reclaim reservations point to
// the rightful owner and must not be overwritten.
if (existingReservation.originalOwnerUserId === skill.ownerUserId) {
await ctx.db.patch(existingReservation._id, {
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
releasedAt: undefined,
})
}
// Otherwise a reclaim reservation exists for a different user -- leave it alone.
} else {
await ctx.db.insert('reservedSlugs', {
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,
@@ -774,6 +804,16 @@ export const getBySlugForStaff = query({
},
})
export const getReservedSlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
},
})
export const getSkillBySlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -2807,6 +2847,164 @@ 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,
})
}
// Create or update the slug reservation for the rightful owner
const existingReservation = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (existingReservation) {
await ctx.db.patch(existingReservation._id, {
originalOwnerUserId: args.rightfulOwnerUserId,
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
reason: args.reason || 'slug.reclaimed',
releasedAt: undefined,
})
} else {
await ctx.db.insert('reservedSlugs', {
slug,
originalOwnerUserId: 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,
})
}
const existingReservation = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (existingReservation) {
await ctx.db.patch(existingReservation._id, {
originalOwnerUserId: args.rightfulOwnerUserId,
deletedAt: now,
expiresAt: now + SLUG_RESERVATION_MS,
reason: args.reason || 'slug.reclaimed',
releasedAt: undefined,
})
} else {
await ctx.db.insert('reservedSlugs', {
slug,
originalOwnerUserId: 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) => {
@@ -3025,7 +3223,19 @@ export const insertVersion = internalMutation({
const now = Date.now()
const qualityAssessment = args.qualityAssessment
const isQualityQuarantine = qualityAssessment?.decision === 'quarantine'
const moderationReason = isQualityQuarantine ? 'quality.low' : 'pending.scan'
// Trusted publishers (or admins/moderators) bypass auto-hide for pending scans.
// Their skills start as 'active' and will be hidden only if VT scan finds issues.
const isTrustedPublisher = Boolean(
user.trustedPublisher ||
user.role === 'admin' ||
user.role === 'moderator',
)
const moderationReason = isQualityQuarantine
? 'quality.low'
: isTrustedPublisher
? 'trusted.publisher'
: 'pending.scan'
const initialModerationStatus = isTrustedPublisher && !isQualityQuarantine ? 'active' : 'hidden'
const moderationNotes = isQualityQuarantine
? `Auto-quarantined by quality gate (score=${qualityAssessment.score}, tier=${qualityAssessment.trustTier}, similar=${qualityAssessment.similarRecentCount}).`
: undefined
@@ -3042,6 +3252,28 @@ export const insertVersion = internalMutation({
: undefined
if (!skill) {
// Check if the slug is reserved for a previous owner (anti-squatting)
const reservation = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (reservation && !reservation.releasedAt) {
if (reservation.expiresAt > now) {
// Slug is still within cooldown period
if (reservation.originalOwnerUserId !== userId) {
throw new Error(
`Slug "${args.slug}" is reserved for its previous owner until ${new Date(reservation.expiresAt).toISOString()}. ` +
'Please choose a different slug.',
)
}
// Original owner is reclaiming -- release the reservation
await ctx.db.patch(reservation._id, { releasedAt: now })
} else {
// Reservation expired -- release it
await ctx.db.patch(reservation._id, { releasedAt: now })
}
}
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
enforceNewSkillRateLimit(ownerTrustSignals)
@@ -3106,7 +3338,7 @@ export const insertVersion = internalMutation({
official: undefined,
deprecated: undefined,
},
moderationStatus: 'hidden',
moderationStatus: initialModerationStatus,
moderationReason,
moderationNotes,
quality: qualityRecord,
@@ -3177,7 +3409,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,
+180 -13
View File
@@ -325,16 +325,40 @@ async function banUserWithActor(
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
}
// Soft-delete all owned skills (instead of hard-delete) so they can be restored on unban.
// The slug is still occupied by the soft-deleted record, preventing squatting.
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
.collect()
let hiddenCount = 0
for (const skill of skills) {
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
skillId: skill._id,
actorUserId: actor._id,
})
if (!skill.softDeletedAt) {
await ctx.db.patch(skill._id, {
softDeletedAt: now,
moderationStatus: 'hidden',
moderationReason: 'user.banned',
hiddenAt: now,
hiddenBy: actor._id,
lastReviewedAt: now,
updatedAt: now,
})
// Mark embeddings as deleted so the skill is excluded from vector search
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,
})
}
hiddenCount += 1
}
}
const tokens = await ctx.db
@@ -359,11 +383,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 }
}
async function unbanUserWithActor(
@@ -397,18 +421,138 @@ async function unbanUserWithActor(
updatedAt: now,
})
// Restore soft-deleted skills that were hidden due to the ban
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
.collect()
let restoredCount = 0
for (const skill of skills) {
if (skill.softDeletedAt && skill.moderationReason === 'user.banned') {
await ctx.db.patch(skill._id, {
softDeletedAt: undefined,
moderationStatus: 'active',
moderationReason: 'restored.unban',
hiddenAt: undefined,
hiddenBy: undefined,
lastReviewedAt: now,
updatedAt: now,
})
// Restore embedding visibility
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: embedding.isLatest
? embedding.isApproved
? 'latest-approved'
: 'latest'
: embedding.isApproved
? 'archived-approved'
: 'archived',
updatedAt: now,
})
}
restoredCount += 1
}
}
// Release any slug reservations for this user
const reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_owner', (q) => q.eq('originalOwnerUserId', targetUserId))
.collect()
for (const reservation of reservations) {
if (!reservation.releasedAt) {
await ctx.db.patch(reservation._id, { releasedAt: now })
}
}
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 }
}
/**
* 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.
@@ -432,15 +576,38 @@ export const autobanMalwareAuthorInternal = internalMutation({
const now = Date.now()
// Soft-delete all their skills
// Soft-delete all their skills with the same 'user.banned' reason used by
// manual bans so that unbanUserWithActor can restore them uniformly.
const skills = await ctx.db
.query('skills')
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
.collect()
let hiddenCount = 0
for (const skill of skills) {
if (!skill.softDeletedAt) {
await ctx.db.patch(skill._id, { softDeletedAt: now, updatedAt: now })
await ctx.db.patch(skill._id, {
softDeletedAt: now,
moderationStatus: 'hidden',
moderationReason: 'user.banned',
hiddenAt: now,
lastReviewedAt: now,
updatedAt: now,
})
// Mark embeddings as deleted so the skill is excluded from vector search
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,
})
}
hiddenCount += 1
}
}
@@ -467,7 +634,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',
@@ -477,7 +644,7 @@ export const autobanMalwareAuthorInternal = internalMutation({
trigger: 'vt.malicious',
sha256hash: args.sha256hash,
slug: args.slug,
deletedSkills: skills.length,
hiddenSkills: hiddenCount,
},
createdAt: now,
})
@@ -486,6 +653,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 }
},
})