Compare commits

...
10 changed files with 381 additions and 254 deletions
+6
View File
@@ -31,6 +31,8 @@ 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_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";
@@ -43,6 +45,7 @@ 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";
@@ -109,6 +112,8 @@ declare const fullApi: ApiFromModules<{
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
"lib/badges": typeof lib_badges;
"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;
@@ -121,6 +126,7 @@ declare const fullApi: ApiFromModules<{
"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;
+3 -8
View File
@@ -15,6 +15,7 @@ import {
readGitHubBackupFile,
} from './lib/githubRestoreHelpers'
import { publishVersionForUser } from './lib/skillPublish'
import { guessContentTypeForPath } from './lib/contentTypes'
type RestoreResult = {
slug: string
@@ -110,7 +111,7 @@ export const restoreSkillFromBackup = internalAction({
if (!fileContent) continue
const sha256 = await sha256Hex(fileContent)
const contentType = guessContentType(filePath)
const contentType = guessContentTypeForPath(filePath)
const blob = new Blob([Buffer.from(fileContent)], { type: contentType })
const storageId = await ctx.storage.store(blob)
@@ -212,10 +213,4 @@ async function sha256Hex(bytes: Uint8Array) {
return hash.digest('hex')
}
function guessContentType(path: string) {
const lower = path.trim().toLowerCase()
if (lower.endsWith('.md')) return 'text/markdown'
if (lower.endsWith('.json')) return 'application/json'
if (lower.endsWith('.svg')) return 'image/svg+xml'
return 'text/plain'
}
// guessContentTypeForPath in lib/contentTypes.ts
+38 -25
View File
@@ -605,39 +605,25 @@ async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
return text('Not found', 404, rate.headers)
}
let payload: Record<string, unknown>
try {
payload = (await request.json()) as Record<string, unknown>
} catch {
return text('Invalid JSON', 400, rate.headers)
}
const payloadResult = await parseJsonPayload(request, rate.headers)
if (!payloadResult.ok) return payloadResult.response
const payload = payloadResult.payload
let actorUserId: Id<'users'>
let actorUser: Doc<'users'>
try {
const auth = await requireApiTokenUser(ctx, request)
actorUserId = auth.userId
actorUser = auth.user as Doc<'users'>
} catch {
return text('Unauthorized', 401, rate.headers)
}
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') {
try {
assertAdmin(actorUser)
} catch {
return text('Forbidden', 403, rate.headers)
}
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminRestore(ctx, request, payload, actorUserId, rate.headers)
}
if (action === 'reclaim') {
try {
assertAdmin(actorUser)
} catch {
return text('Forbidden', 403, rate.headers)
}
const admin = requireAdminOrResponse(actorUser, rate.headers)
if (!admin.ok) return admin.response
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers)
}
@@ -1019,6 +1005,33 @@ function text(value: string, status: number, headers?: HeadersInit) {
})
}
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) }
}
}
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) }
}
}
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) }
}
}
function getPathSegments(request: Request, prefix: string) {
const pathname = new URL(request.url).pathname
if (!pathname.startsWith(prefix)) return []
+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'
}
+131
View File
@@ -0,0 +1,131 @@
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
type ReservedSlug = Doc<'reservedSlugs'>
export function pickLatestActiveReservation(reservations: ReservedSlug[]) {
const active = reservations.filter((r) => !r.releasedAt)
const latest = active.sort((a, b) => b.deletedAt - a.deletedAt)[0] ?? null
return { active, latest }
}
export async function listReservedSlugsForSlug(
ctx: QueryCtx | MutationCtx,
slug: string,
limit = 10,
) {
return ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.take(limit)
}
export async function getLatestActiveReservedSlug(ctx: QueryCtx | MutationCtx, slug: string) {
const reservations = await listReservedSlugsForSlug(ctx, slug)
return pickLatestActiveReservation(reservations).latest
}
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 reservations = await listReservedSlugsForSlug(ctx, params.slug)
const { active, latest } = pickLatestActiveReservation(reservations)
if (latest) {
// Only extend the reservation if it matches the owner being deleted. If it points
// to someone else, it was likely created by reclaim and must not be overwritten.
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 reservations = await listReservedSlugsForSlug(ctx, params.slug)
const { active, latest } = pickLatestActiveReservation(reservations)
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 reservations = await listReservedSlugsForSlug(ctx, params.slug)
const { active, latest } = pickLatestActiveReservation(reservations)
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.',
)
}
// Original owner reclaiming, or reservation expired.
await ctx.db.patch(latest._id, { releasedAt: params.now })
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
}
+35 -135
View File
@@ -27,6 +27,13 @@ import {
import { buildTrendingLeaderboard } from './lib/leaderboards'
import { deriveModerationFlags } from './lib/moderation'
import { toPublicSkill, toPublicUser } from './lib/public'
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import {
enforceReservedSlugCooldownForNewSkill,
getLatestActiveReservedSlug,
reserveSlugForHardDeleteFinalize,
upsertReservedSlugForRightfulOwner,
} from './lib/reservedSlugs'
import {
fetchText,
type PublishResult,
@@ -439,40 +446,12 @@ 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 reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', skill.slug))
.take(10)
const activeReservations = reservations.filter((r) => !r.releasedAt)
const existingReservation = activeReservations.sort((a, b) => b.deletedAt - a.deletedAt)[0]
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,
})
}
// Best-effort: clean up duplicate active reservations (shouldn't exist).
for (const stale of activeReservations.filter((r) => r._id !== existingReservation?._id)) {
await ctx.db.patch(stale._id, { releasedAt: now })
}
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', {
@@ -816,12 +795,7 @@ export const getBySlugForStaff = query({
export const getReservedSlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
const reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.take(10)
const active = reservations.filter((r) => !r.releasedAt)
return active.sort((a, b) => b.deletedAt - a.deletedAt)[0] ?? null
return getLatestActiveReservedSlug(ctx, args.slug)
},
})
@@ -2687,7 +2661,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(),
})
}
@@ -2723,7 +2697,7 @@ export const setRedactionApproved = mutation({
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
isApproved: args.approved,
visibility: visibilityFor(embedding.isLatest, args.approved),
visibility: embeddingVisibilityFor(embedding.isLatest, args.approved),
updatedAt: now,
})
}
@@ -2803,7 +2777,7 @@ export const setSoftDeleted = mutation({
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
: embeddingVisibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
@@ -2916,36 +2890,13 @@ export const reclaimSlug = mutation({
})
}
// Create or update the slug reservation for the rightful owner
const reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.take(10)
const activeReservations = reservations.filter((r) => !r.releasedAt)
const existingReservation = activeReservations.sort((a, b) => b.deletedAt - a.deletedAt)[0]
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',
})
}
// Best-effort: clean up duplicate active reservations (shouldn't exist).
for (const stale of activeReservations.filter((r) => r._id !== existingReservation?._id)) {
await ctx.db.patch(stale._id, { releasedAt: 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,
@@ -2986,35 +2937,13 @@ export const reclaimSlugInternal = internalMutation({
})
}
const reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.take(10)
const activeReservations = reservations.filter((r) => !r.releasedAt)
const existingReservation = activeReservations.sort((a, b) => b.deletedAt - a.deletedAt)[0]
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',
})
}
// Best-effort: clean up duplicate active reservations (shouldn't exist).
for (const stale of activeReservations.filter((r) => r._id !== existingReservation?._id)) {
await ctx.db.patch(stale._id, { releasedAt: now })
}
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,
@@ -3305,29 +3234,7 @@ export const insertVersion = internalMutation({
if (!skill) {
// Anti-squatting: enforce reserved slug cooldown.
const reservations = await ctx.db
.query('reservedSlugs')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.take(10)
const activeReservations = reservations.filter((r) => !r.releasedAt)
const reservation = activeReservations.sort((a, b) => b.deletedAt - a.deletedAt)[0]
if (reservation) {
if (reservation.expiresAt > now && 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 reclaiming, or reservation expired.
await ctx.db.patch(reservation._id, { releasedAt: now })
}
// Best-effort: release any duplicate active reservations for same slug.
for (const stale of activeReservations.filter((r) => r._id !== reservation?._id)) {
await ctx.db.patch(stale._id, { releasedAt: now })
}
await enforceReservedSlugCooldownForNewSkill(ctx, { slug: args.slug, userId, now })
if (!args.bypassNewSkillRateLimit) {
const ownerTrustSignals = await getOwnerTrustSignals(ctx, user, now)
@@ -3484,7 +3391,7 @@ export const insertVersion = internalMutation({
embedding: args.embedding,
isLatest: true,
isApproved,
visibility: visibilityFor(true, isApproved),
visibility: embeddingVisibilityFor(true, isApproved),
updatedAt: now,
})
@@ -3496,7 +3403,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,
})
}
@@ -3554,7 +3461,7 @@ export const setSkillSoftDeletedInternal = internalMutation({
await ctx.db.patch(embedding._id, {
visibility: args.deleted
? 'deleted'
: visibilityFor(embedding.isLatest, embedding.isApproved),
: embeddingVisibilityFor(embedding.isLatest, embedding.isApproved),
updatedAt: now,
})
}
@@ -3572,13 +3479,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))
+127 -73
View File
@@ -5,6 +5,7 @@ import type { Doc, Id } from './_generated/dataModel'
import type { MutationCtx } from './_generated/server'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import { toPublicUser } from './lib/public'
import { buildUserSearchResults } from './lib/userSearch'
@@ -322,7 +323,17 @@ async function banUserWithActor(
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
}
const hiddenCount = await softDeleteSkillsForBan(ctx, targetUserId, now, { hiddenBy: actor._id })
const banSkillsResult = (await ctx.runMutation(
internal.users.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')
@@ -352,7 +363,7 @@ async function banUserWithActor(
createdAt: now,
})
return { ok: true as const, alreadyBanned: false, deletedSkills: hiddenCount }
return { ok: true as const, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
}
async function unbanUserWithActor(
@@ -387,36 +398,16 @@ 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) {
// Only restore skills we soft-deleted as part of the ban flow.
if (
skill.softDeletedAt &&
skill.softDeletedAt === bannedAt &&
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
await restoreSkillEmbeddingVisibility(ctx, skill._id, now)
restoredCount += 1
}
}
const restoreSkillsResult = (await ctx.runMutation(
internal.users.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,
@@ -427,7 +418,7 @@ async function unbanUserWithActor(
createdAt: now,
})
return { ok: true as const, alreadyUnbanned: false, restoredSkills: restoredCount }
return { ok: true as const, alreadyUnbanned: false, restoredSkills: restoredCount, scheduledSkills }
}
/**
@@ -521,7 +512,16 @@ export const autobanMalwareAuthorInternal = internalMutation({
const now = Date.now()
const hiddenCount = await softDeleteSkillsForBan(ctx, args.ownerUserId, now)
const banSkillsResult = (await ctx.runMutation(
internal.users.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
@@ -565,47 +565,107 @@ export const autobanMalwareAuthorInternal = internalMutation({
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
)
return { ok: true, alreadyBanned: false, deletedSkills: hiddenCount }
return { ok: true, alreadyBanned: false, deletedSkills: hiddenCount, scheduledSkills }
},
})
async function softDeleteSkillsForBan(
ctx: MutationCtx,
ownerUserId: Id<'users'>,
now: number,
options?: { hiddenBy?: Id<'users'> },
) {
// Soft-delete 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', ownerUserId))
.collect()
const BAN_SKILLS_BATCH_SIZE = 25
let hiddenCount = 0
for (const skill of skills) {
if (skill.softDeletedAt) continue
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_SKILLS_BATCH_SIZE })
// Only overwrite moderation fields for active skills. Keep existing hidden/removed
// moderation reasons intact.
const shouldMarkModeration = skill.moderationStatus === 'active'
let hiddenCount = 0
for (const skill of page) {
if (skill.softDeletedAt) continue
const patch: Partial<Doc<'skills'>> = { softDeletedAt: now, updatedAt: now }
if (shouldMarkModeration) {
patch.moderationStatus = 'hidden'
patch.moderationReason = 'user.banned'
patch.hiddenAt = now
if (options?.hiddenBy) patch.hiddenBy = options.hiddenBy
patch.lastReviewedAt = now
hiddenCount += 1
// Only overwrite moderation fields for active skills. Keep existing hidden/removed
// moderation reasons intact.
const shouldMarkModeration = skill.moderationStatus === '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 markSkillEmbeddingsDeleted(ctx, skill._id, args.bannedAt)
}
await ctx.db.patch(skill._id, patch)
await markSkillEmbeddingsDeleted(ctx, skill._id, now)
}
if (!isDone) {
await ctx.scheduler.runAfter(0, internal.users.applyBanToOwnedSkillsBatchInternal, {
...args,
cursor: continueCursor,
})
}
return hiddenCount
}
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_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 restoreSkillEmbeddingVisibility(ctx, skill._id, now)
restoredCount += 1
}
if (!isDone) {
await ctx.scheduler.runAfter(0, internal.users.restoreOwnedSkillsForUnbanBatchInternal, {
...args,
cursor: continueCursor,
})
}
return { ok: true as const, restoredCount, scheduled: !isDone }
},
})
async function markSkillEmbeddingsDeleted(ctx: MutationCtx, skillId: Id<'skills'>, now: number) {
const embeddings = await ctx.db
@@ -624,13 +684,7 @@ async function restoreSkillEmbeddingVisibility(ctx: MutationCtx, skillId: Id<'sk
.withIndex('by_skill', (q) => q.eq('skillId', skillId))
.collect()
for (const embedding of embeddings) {
const visibility = embedding.isLatest
? embedding.isApproved
? 'latest-approved'
: 'latest'
: embedding.isApproved
? 'archived-approved'
: 'archived'
const visibility = embeddingVisibilityFor(embedding.isLatest, embedding.isApproved)
await ctx.db.patch(embedding._id, { visibility, updatedAt: now })
}
}
@@ -12,7 +12,7 @@ vi.mock('../../config.js', () => ({
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
vi.mock('../registry.js', () => ({
getRegistry: (...args: unknown[]) => mockGetRegistry(...args),
getRegistry: () => mockGetRegistry(),
}))
const { cmdLogout } = await import('./auth')
@@ -63,4 +63,3 @@ describe('cmdLogout', () => {
})
})
})