feat: harden moderation and upload safety

This commit is contained in:
Peter Steinberger
2026-02-02 00:17:34 -08:00
parent f654dc9325
commit 7b2bdbd08f
17 changed files with 504 additions and 34 deletions
+2
View File
@@ -4,6 +4,8 @@
### Added
- Admin: ban users and delete owned skills from management console.
- Moderation: auto-hide skills after 4 unique reports; per-user report cap; moderators can ban users.
- Uploads: require GitHub accounts to be at least 7 days old for skill + soul publish/import.
### Changed
+2
View File
@@ -28,6 +28,7 @@ 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_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
import type * as lib_githubImport from "../lib/githubImport.js";
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
@@ -89,6 +90,7 @@ declare const fullApi: ApiFromModules<{
"lib/badges": typeof lib_badges;
"lib/changelog": typeof lib_changelog;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubBackup": typeof lib_githubBackup;
"lib/githubImport": typeof lib_githubImport;
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
+108
View File
@@ -0,0 +1,108 @@
/* @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { internal } from '../_generated/api'
import { requireGitHubAccountAge } from './githubAccount'
vi.mock('../_generated/api', () => ({
internal: {
users: {
getByIdInternal: Symbol('getByIdInternal'),
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
},
},
}))
const ONE_DAY_MS = 24 * 60 * 60 * 1000
describe('requireGitHubAccountAge', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses cached githubCreatedAt when fresh', async () => {
const now = Date.now()
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: now - 10 * ONE_DAY_MS,
githubFetchedAt: now - ONE_DAY_MS,
})
const runMutation = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).not.toHaveBeenCalled()
expect(runMutation).not.toHaveBeenCalled()
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
})
it('rejects accounts younger than 7 days', async () => {
const now = Date.now()
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'newbie',
githubCreatedAt: now - 2 * ONE_DAY_MS,
githubFetchedAt: now - ONE_DAY_MS,
})
const runMutation = vi.fn()
vi.stubGlobal('fetch', vi.fn())
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
})
it('refreshes githubCreatedAt when cache is stale', async () => {
vi.useFakeTimers()
const now = new Date('2026-02-02T12:00:00Z')
vi.setSystemTime(now)
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
})
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
created_at: '2020-01-01T00:00:00Z',
}),
})
vi.stubGlobal('fetch', fetchMock)
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
expect(fetchMock).toHaveBeenCalledWith(
'https://api.github.com/users/steipete',
expect.objectContaining({ headers: { 'User-Agent': 'clawhub' } }),
)
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
userId: 'users:1',
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
githubFetchedAt: now.getTime(),
})
vi.useRealTimers()
})
it('throws when GitHub lookup fails', async () => {
const runQuery = vi.fn().mockResolvedValue({
_id: 'users:1',
handle: 'steipete',
githubCreatedAt: undefined,
githubFetchedAt: 0,
})
const runMutation = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
vi.stubGlobal('fetch', fetchMock)
await expect(
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
).rejects.toThrow(/GitHub account lookup failed/i)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { ConvexError } from 'convex/values'
import { internal } from '../_generated/api'
import type { Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
const GITHUB_API = 'https://api.github.com'
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
type GitHubUser = {
created_at?: string
}
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
if (!user || user.deletedAt) throw new ConvexError('User not found')
const handle = user.handle?.trim()
if (!handle) throw new ConvexError('GitHub handle required')
const now = Date.now()
let createdAt = user.githubCreatedAt ?? null
const fetchedAt = user.githubFetchedAt ?? 0
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
if (stale) {
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
headers: { 'User-Agent': 'clawhub' },
})
if (!response.ok) throw new ConvexError('GitHub account lookup failed')
const payload = (await response.json()) as GitHubUser
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
createdAt = parsed
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
userId,
githubCreatedAt: createdAt,
githubFetchedAt: now,
})
}
if (!createdAt) throw new ConvexError('GitHub account lookup failed')
const ageMs = now - createdAt
if (ageMs < MIN_ACCOUNT_AGE_MS) {
const remainingMs = MIN_ACCOUNT_AGE_MS - ageMs
const remainingDays = Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)))
throw new ConvexError(
`GitHub account must be at least 7 days old to upload skills. Try again in ${remainingDays} day${
remainingDays === 1 ? '' : 's'
}.`,
)
}
}
+4
View File
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from '../_generated/server'
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
import { generateChangelogForPublish } from './changelog'
import { generateEmbedding } from './embeddings'
import { requireGitHubAccountAge } from './githubAccount'
import type { PublicUser } from './public'
import {
buildEmbeddingText,
@@ -67,6 +68,9 @@ export async function publishVersionForUser(
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
const suppliedChangelog = args.changelog.trim()
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
+4
View File
@@ -4,6 +4,7 @@ import { internal } from '../_generated/api'
import type { Doc, Id } from '../_generated/dataModel'
import type { ActionCtx } from '../_generated/server'
import { generateEmbedding } from './embeddings'
import { requireGitHubAccountAge } from './githubAccount'
import {
buildEmbeddingText,
getFrontmatterMetadata,
@@ -90,6 +91,9 @@ export async function publishSoulVersionForUser(
if (!semver.valid(version)) {
throw new ConvexError('Version must be valid semver')
}
await requireGitHubAccountAge(ctx, userId)
const suppliedChangelog = args.changelog.trim()
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
+2
View File
@@ -17,6 +17,8 @@ const users = defineTable({
displayName: v.optional(v.string()),
bio: v.optional(v.string()),
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
githubCreatedAt: v.optional(v.number()),
githubFetchedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
+137 -6
View File
@@ -30,6 +30,8 @@ const MAX_LIST_LIMIT = 50
const MAX_PUBLIC_LIST_LIMIT = 200
const MAX_LIST_BULK_LIMIT = 200
const MAX_LIST_TAKE = 1000
const MAX_ACTIVE_REPORTS_PER_USER = 20
const AUTO_HIDE_REPORT_THRESHOLD = 3
function isSkillVersionId(
value: Id<'skillVersions'> | null | undefined,
@@ -100,6 +102,14 @@ async function hardDeleteSkill(
await ctx.db.delete(comment._id)
}
const reports = await ctx.db
.query('skillReports')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const report of reports) {
await ctx.db.delete(report._id)
}
const stars = await ctx.db
.query('stars')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
@@ -162,7 +172,8 @@ async function hardDeleteSkill(
if (related._id === skill._id) continue
if (related.canonicalSkillId === skill._id || related.forkOf?.skillId === skill._id) {
await ctx.db.patch(related._id, {
canonicalSkillId: related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId,
canonicalSkillId:
related.canonicalSkillId === skill._id ? undefined : related.canonicalSkillId,
forkOf: related.forkOf?.skillId === skill._id ? undefined : related.forkOf,
updatedAt: now,
})
@@ -326,7 +337,7 @@ export const getBySlug = query({
.unique()
if (!skill || skill.softDeletedAt) return null
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
const owner = await ctx.db.get(skill.ownerUserId)
const badges = await getSkillBadgeMap(ctx, skill._id)
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
@@ -372,6 +383,62 @@ export const getBySlug = query({
},
})
export const getBySlugForStaff = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertModerator(user)
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
if (!skill) return null
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
const badges = await getSkillBadgeMap(ctx, skill._id)
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null
return {
skill: { ...skill, badges },
latestVersion,
owner,
forkOf: forkOfSkill
? {
kind: skill.forkOf?.kind ?? 'fork',
version: skill.forkOf?.version ?? null,
skill: {
slug: forkOfSkill.slug,
displayName: forkOfSkill.displayName,
},
owner: {
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
userId: forkOfOwner?._id ?? null,
},
}
: null,
canonical: canonicalSkill
? {
skill: {
slug: canonicalSkill.slug,
displayName: canonicalSkill.displayName,
},
owner: {
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
userId: canonicalOwner?._id ?? null,
},
}
: null,
}
},
})
export const getSkillBySlugInternal = internalQuery({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -616,12 +683,35 @@ export const listDuplicateCandidates = query({
},
})
async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<'users'>) {
const reports = await ctx.db
.query('skillReports')
.withIndex('by_user', (q) => q.eq('userId', userId))
.collect()
let count = 0
for (const report of reports) {
const skill = await ctx.db.get(report.skillId)
if (!skill) continue
if (skill.softDeletedAt) continue
if (skill.moderationStatus === 'removed') continue
const owner = await ctx.db.get(skill.ownerUserId)
if (!owner || owner.deletedAt) continue
count += 1
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break
}
return count
}
export const report = mutation({
args: { skillId: v.id('skills'), reason: v.optional(v.string()) },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const skill = await ctx.db.get(args.skillId)
if (!skill || skill.softDeletedAt) throw new Error('Skill not found')
if (!skill || skill.softDeletedAt || skill.moderationStatus === 'removed') {
throw new Error('Skill not found')
}
const existing = await ctx.db
.query('skillReports')
@@ -629,6 +719,11 @@ export const report = mutation({
.unique()
if (existing) return { ok: true as const, reported: false, alreadyReported: true }
const activeReports = await countActiveReportsForUser(ctx, userId)
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
throw new Error('Report limit reached. Please wait for moderation before reporting more.')
}
const now = Date.now()
const reason = args.reason?.trim()
await ctx.db.insert('skillReports', {
@@ -638,11 +733,47 @@ export const report = mutation({
createdAt: now,
})
await ctx.db.patch(skill._id, {
reportCount: (skill.reportCount ?? 0) + 1,
const nextReportCount = (skill.reportCount ?? 0) + 1
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !skill.softDeletedAt
const updates: Partial<Doc<'skills'>> = {
reportCount: nextReportCount,
lastReportedAt: now,
updatedAt: now,
})
}
if (shouldAutoHide) {
Object.assign(updates, {
softDeletedAt: now,
moderationStatus: 'hidden',
moderationReason: 'auto.reports',
moderationNotes: 'Auto-hidden after 4 unique reports.',
hiddenAt: now,
lastReviewedAt: now,
})
}
await ctx.db.patch(skill._id, updates)
if (shouldAutoHide) {
const embeddings = await ctx.db
.query('skillEmbeddings')
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
.collect()
for (const embedding of embeddings) {
await ctx.db.patch(embedding._id, {
visibility: 'deleted',
updatedAt: now,
})
}
await ctx.db.insert('auditLogs', {
actorUserId: userId,
action: 'skill.auto_hide',
targetType: 'skill',
targetId: skill._id,
metadata: { reportCount: nextReportCount },
createdAt: now,
})
}
return { ok: true as const, reported: true, alreadyReported: false }
},
+21 -3
View File
@@ -1,8 +1,8 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internal } from './_generated/api'
import { internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, requireUser } from './lib/access'
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
import { assertAdmin, assertModerator, requireUser } from './lib/access'
import { toPublicUser } from './lib/public'
const DEFAULT_ROLE = 'user'
@@ -18,6 +18,21 @@ export const getByIdInternal = internalQuery({
handler: async (ctx, args) => ctx.db.get(args.userId),
})
export const updateGithubMetaInternal = internalMutation({
args: {
userId: v.id('users'),
githubCreatedAt: v.number(),
githubFetchedAt: v.number(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
githubCreatedAt: args.githubCreatedAt,
githubFetchedAt: args.githubFetchedAt,
updatedAt: args.githubFetchedAt,
})
},
})
export const me = query({
args: {},
handler: async (ctx) => {
@@ -125,12 +140,15 @@ export const banUser = mutation({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx)
assertAdmin(user)
assertModerator(user)
if (args.userId === user._id) throw new Error('Cannot ban yourself')
const target = await ctx.db.get(args.userId)
if (!target) throw new Error('User not found')
if (target.role === 'admin' && user.role !== 'admin') {
throw new Error('Forbidden')
}
const now = Date.now()
if (target.deletedAt) {
+1
View File
@@ -22,6 +22,7 @@ Reading order (new contributor):
Feature/ops docs (already present):
- `docs/spec.md`: product + implementation spec (data model + flows).
- `docs/security.md`: moderation, reporting, bans, upload gating.
- `docs/telemetry.md`: what `clawhub sync` reports; opt-out.
- `docs/webhook.md`: Discord webhook events/payload.
- `docs/diffing.md`: version-to-version diff UI spec.
+49
View File
@@ -0,0 +1,49 @@
---
summary: 'Security + moderation controls (reports, bans, upload gating).'
read_when:
- Working on moderation or abuse controls
- Reviewing upload restrictions
- Troubleshooting hidden/removed skills
---
# Security + Moderation
## Roles + permissions
- user: upload skills/souls (subject to GitHub age gate), report skills.
- moderator: hide/restore skills, view hidden skills, unhide, soft-delete, ban users (except admins).
- admin: all moderator actions + hard delete skills, change owners, change roles.
## Reporting + auto-hide
- Reports are unique per user + skill.
- Per-user cap: 20 **active** reports.
- Active = skill exists, not soft-deleted, not `moderationStatus = removed`,
and the owner is not banned.
- Auto-hide: when unique reports exceed 3 (4th report), the skill is:
- soft-deleted (`softDeletedAt`)
- `moderationStatus = hidden`
- `moderationReason = auto.reports`
- embeddings visibility set to `deleted`
- audit log entry: `skill.auto_hide`
- Public queries hide non-active moderation statuses; staff can still access via
staff-only queries and unhide/restore/delete/ban.
## Bans
- Banning a user:
- hard-deletes all owned skills
- revokes API tokens
- sets `deletedAt` on the user
- Moderators cannot ban admins; nobody can ban themselves.
- Report counters effectively reset because deleted/banned skills are no longer
considered active in the per-user report cap.
## Upload gate (GitHub account age)
- Skill + soul publish actions require GitHub account age ≥ 7 days.
- Lookup uses GitHub `created_at` and caches on the user:
- `githubCreatedAt` (source of truth)
- `githubFetchedAt` (fetch timestamp)
- Cache TTL: 24 hours.
- Gate applies to web uploads, CLI publish, and GitHub import.
+4 -2
View File
@@ -123,8 +123,9 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
## Auth + roles
- Convex Auth with GitHub OAuth App.
- Default role `user`; bootstrap `steipete` to `admin` on first login.
- Management console: moderators can hide/restore skills + mark duplicates; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
- Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
- Role changes are admin-only and audited.
- Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
## Upload flow (50MB per version)
1) Client requests upload session.
@@ -135,9 +136,10 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
- file extensions/text content
- SKILL.md exists and frontmatter parseable
- version uniqueness
- GitHub account age ≥ 7 days
5) Server stores files + metadata, sets `latest` tag, updates stats.
Soul upload flow: same as skills, but only `SOUL.md` is allowed in the bundle.
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
Seed data lives in `convex/seed.ts` for local dev.
## Versioning + tags
+25 -15
View File
@@ -42,7 +42,10 @@ describe('SkillDetailPage', () => {
})
it('shows a loading indicator while loading', () => {
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return undefined
})
render(<SkillDetailPage slug="weather" />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
@@ -50,26 +53,33 @@ describe('SkillDetailPage', () => {
})
it('shows not found when skill query resolves to null', async () => {
useQueryMock.mockImplementationOnce(() => null) // getBySlug
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return null
})
render(<SkillDetailPage slug="missing-skill" />)
expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
})
it('redirects legacy routes to canonical owner/slug', async () => {
useQueryMock.mockImplementationOnce(() => ({
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}))
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
if (args && typeof args === 'object' && 'skillId' in args) return []
return {
skill: {
_id: 'skills:1',
slug: 'weather',
displayName: 'Weather',
summary: 'Get current weather.',
ownerUserId: 'users:1',
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: { handle: 'steipete', name: 'Peter' },
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
}
})
render(<SkillDetailPage slug="weather" redirectToCanonical />)
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
+65 -5
View File
@@ -19,9 +19,9 @@ type SkillDetailPageProps = {
}
type SkillBySlugResult = {
skill: PublicSkill
skill: Doc<'skills'> | PublicSkill
latestVersion: Doc<'skillVersions'> | null
owner: PublicUser | null
owner: Doc<'users'> | PublicUser | null
forkOf: {
kind: 'fork' | 'duplicate'
version: string | null
@@ -36,6 +36,32 @@ type SkillBySlugResult = {
type SkillFile = Doc<'skillVersions'>['files'][number]
function formatReportError(error: unknown) {
if (error && typeof error === 'object' && 'data' in error) {
const data = (error as { data?: unknown }).data
if (typeof data === 'string' && data.trim()) return data.trim()
if (
data &&
typeof data === 'object' &&
'message' in data &&
typeof (data as { message?: unknown }).message === 'string'
) {
const message = (data as { message?: string }).message?.trim()
if (message) return message
}
}
if (error instanceof Error) {
const cleaned = error.message
.replace(/\[CONVEX[^\]]*\]\s*/g, '')
.replace(/\[Request ID:[^\]]*\]\s*/g, '')
.replace(/^Server Error Called by client\s*/i, '')
.replace(/^ConvexError:\s*/i, '')
.trim()
if (cleaned && cleaned !== 'Server Error') return cleaned
}
return 'Unable to submit report. Please try again.'
}
export function SkillDetailPage({
slug,
canonicalOwner,
@@ -43,7 +69,14 @@ export function SkillDetailPage({
}: SkillDetailPageProps) {
const navigate = useNavigate()
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
const isStaff = isModerator(me)
const staffResult = useQuery(api.skills.getBySlugForStaff, isStaff ? { slug } : 'skip') as
| SkillBySlugResult
| undefined
const publicResult = useQuery(api.skills.getBySlug, !isStaff ? { slug } : 'skip') as
| SkillBySlugResult
| undefined
const result = isStaff ? staffResult : publicResult
const toggleStar = useMutation(api.stars.toggle)
const reportSkill = useMutation(api.skills.report)
const addComment = useMutation(api.comments.add)
@@ -80,7 +113,6 @@ export function SkillDetailPage({
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
const canManage = canManageSkill(me, skill)
const isStaff = isModerator(me)
const ownerHandle = owner?.handle ?? owner?.name ?? null
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
@@ -104,6 +136,26 @@ export function SkillDetailPage({
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
: null
const staffSkill = isStaff && skill ? (skill as Doc<'skills'>) : null
const moderationStatus =
staffSkill?.moderationStatus ?? (staffSkill?.softDeletedAt ? 'hidden' : undefined)
const isHidden = moderationStatus === 'hidden' || Boolean(staffSkill?.softDeletedAt)
const isRemoved = moderationStatus === 'removed'
const isAutoHidden = isHidden && staffSkill?.moderationReason === 'auto.reports'
const staffVisibilityTag = isRemoved
? 'Removed'
: isAutoHidden
? 'Auto-hidden'
: isHidden
? 'Hidden'
: null
const staffModerationNote = staffVisibilityTag
? isAutoHidden
? 'Auto-hidden after 4+ unique reports.'
: isRemoved
? 'Removed from public view.'
: 'Hidden from public view.'
: null
useEffect(() => {
if (!wantsCanonicalRedirect || !ownerParam) return
@@ -207,6 +259,9 @@ export function SkillDetailPage({
</div>
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
@@ -246,6 +301,11 @@ export function SkillDetailPage({
{badge}
</div>
))}
{isStaff && staffVisibilityTag ? (
<div className={`tag${isAutoHidden || isRemoved ? ' tag-accent' : ''}`}>
{staffVisibilityTag}
</div>
) : null}
<div className="skill-actions">
{isAuthenticated ? (
<button
@@ -276,7 +336,7 @@ export function SkillDetailPage({
}
} catch (error) {
console.error('Failed to report skill', error)
window.alert('Unable to submit report. Please try again.')
window.alert(formatReportError(error))
}
}}
>
+1 -1
View File
@@ -1,4 +1,4 @@
import { getOnlyCrabsSiteUrl, getClawHubSiteUrl } from './site'
import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
type SkillMetaSource = {
slug: string
+1 -1
View File
@@ -5,9 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectSiteMode,
detectSiteModeFromUrl,
getClawHubSiteUrl,
getOnlyCrabsHost,
getOnlyCrabsSiteUrl,
getClawHubSiteUrl,
getSiteDescription,
getSiteMode,
getSiteName,
+22 -1
View File
@@ -64,7 +64,7 @@ function Management() {
| undefined
const selectedSlug = search.skill?.trim()
const selectedSkill = useQuery(
api.skills.getBySlug,
api.skills.getBySlugForStaff,
staff && selectedSlug ? { slug: selectedSlug } : 'skip',
) as SkillBySlugResult | undefined
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : 'skip') as
@@ -212,6 +212,11 @@ function Management() {
const isOfficial = isSkillOfficial(skill)
const isDeprecated = isSkillDeprecated(skill)
const badges = getSkillBadges(skill)
const ownerUserId = skill.ownerUserId ?? selectedOwnerUserId
const ownerHandle = owner?.handle ?? owner?.name ?? 'user'
const isOwnerAdmin = owner?.role === 'admin'
const canBanOwner =
staff && ownerUserId && ownerUserId !== me?._id && (admin || !isOwnerAdmin)
return (
<div key={skill._id} className="management-item">
@@ -325,6 +330,22 @@ function Management() {
Hard delete
</button>
) : null}
{staff ? (
<button
className="btn"
type="button"
disabled={!canBanOwner}
onClick={() => {
if (!ownerUserId || ownerUserId === me?._id) return
if (!window.confirm(`Ban @${ownerHandle} and delete their skills?`)) {
return
}
void banUser({ userId: ownerUserId })
}}
>
Ban user
</button>
) : null}
{admin ? (
<>
<button