mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: harden moderation and upload safety
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { getOnlyCrabsSiteUrl, getClawHubSiteUrl } from './site'
|
||||
import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
|
||||
|
||||
type SkillMetaSource = {
|
||||
slug: string
|
||||
|
||||
@@ -5,9 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
detectSiteMode,
|
||||
detectSiteModeFromUrl,
|
||||
getClawHubSiteUrl,
|
||||
getOnlyCrabsHost,
|
||||
getOnlyCrabsSiteUrl,
|
||||
getClawHubSiteUrl,
|
||||
getSiteDescription,
|
||||
getSiteMode,
|
||||
getSiteName,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user