mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-22 03:35:29 -04:00
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:
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user