mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6011487982 | ||
|
|
d9b283ee53 | ||
|
|
321056c0eb | ||
|
|
98714bdb90 |
Vendored
+6
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const EXT_TO_TYPE: Record<string, string> = {
|
||||
md: 'text/markdown',
|
||||
mdx: 'text/markdown',
|
||||
json: 'application/json',
|
||||
json5: 'application/json',
|
||||
yaml: 'application/yaml',
|
||||
yml: 'application/yaml',
|
||||
toml: 'application/toml',
|
||||
svg: 'image/svg+xml',
|
||||
}
|
||||
|
||||
export function guessContentTypeForPath(path: string) {
|
||||
const trimmed = path.trim().toLowerCase()
|
||||
if (!trimmed) return 'application/octet-stream'
|
||||
const ext = trimmed.split('.').at(-1) ?? ''
|
||||
return EXT_TO_TYPE[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type EmbeddingVisibility =
|
||||
| 'latest'
|
||||
| 'latest-approved'
|
||||
| 'archived'
|
||||
| 'archived-approved'
|
||||
| 'deleted'
|
||||
|
||||
export function embeddingVisibilityFor(isLatest: boolean, isApproved: boolean): Exclude<
|
||||
EmbeddingVisibility,
|
||||
'deleted'
|
||||
> {
|
||||
if (isLatest && isApproved) return 'latest-approved'
|
||||
if (isLatest) return 'latest'
|
||||
if (isApproved) return 'archived-approved'
|
||||
return 'archived'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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
@@ -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', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user