From 7b2bdbd08f86d6719eef9331ac224baf0fb51539 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Feb 2026 00:17:34 -0800 Subject: [PATCH] feat: harden moderation and upload safety --- CHANGELOG.md | 2 + convex/_generated/api.d.ts | 2 + convex/lib/githubAccount.test.ts | 108 +++++++++++++++++ convex/lib/githubAccount.ts | 56 +++++++++ convex/lib/skillPublish.ts | 4 + convex/lib/soulPublish.ts | 4 + convex/schema.ts | 2 + convex/skills.ts | 143 ++++++++++++++++++++++- convex/users.ts | 24 +++- docs/README.md | 1 + docs/security.md | 49 ++++++++ docs/spec.md | 6 +- src/__tests__/skill-detail-page.test.tsx | 40 ++++--- src/components/SkillDetailPage.tsx | 70 ++++++++++- src/lib/og.ts | 2 +- src/lib/site.test.ts | 2 +- src/routes/management.tsx | 23 +++- 17 files changed, 504 insertions(+), 34 deletions(-) create mode 100644 convex/lib/githubAccount.test.ts create mode 100644 convex/lib/githubAccount.ts create mode 100644 docs/security.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3318287b..803634f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6352d184..75db0237 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/lib/githubAccount.test.ts b/convex/lib/githubAccount.test.ts new file mode 100644 index 00000000..09c64239 --- /dev/null +++ b/convex/lib/githubAccount.test.ts @@ -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) + }) +}) diff --git a/convex/lib/githubAccount.ts b/convex/lib/githubAccount.ts new file mode 100644 index 00000000..e80618f5 --- /dev/null +++ b/convex/lib/githubAccount.ts @@ -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' + }.`, + ) + } +} diff --git a/convex/lib/skillPublish.ts b/convex/lib/skillPublish.ts index c72910c9..aacbcf1b 100644 --- a/convex/lib/skillPublish.ts +++ b/convex/lib/skillPublish.ts @@ -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) diff --git a/convex/lib/soulPublish.ts b/convex/lib/soulPublish.ts index a6150f94..85f0586d 100644 --- a/convex/lib/soulPublish.ts +++ b/convex/lib/soulPublish.ts @@ -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) diff --git a/convex/schema.ts b/convex/schema.ts index caf9bd28..30b145a2 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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()), diff --git a/convex/skills.ts b/convex/skills.ts index be38017a..933fa84d 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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> = { + 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 } }, diff --git a/convex/users.ts b/convex/users.ts index a6e47b41..cc98d69b 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -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) { diff --git a/docs/README.md b/docs/README.md index ef82c503..7d8d39e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..9d3c3745 --- /dev/null +++ b/docs/security.md @@ -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. diff --git a/docs/spec.md b/docs/spec.md index c467e1ae..09c90a85 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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 diff --git a/src/__tests__/skill-detail-page.test.tsx b/src/__tests__/skill-detail-page.test.tsx index b13363b8..857999b9 100644 --- a/src/__tests__/skill-detail-page.test.tsx +++ b/src/__tests__/skill-detail-page.test.tsx @@ -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() 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() 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() expect(screen.getByText(/Loading skill/i)).toBeTruthy() diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index d6b54c54..577fc395 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -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({

{skill.summary ?? 'No summary provided.'}

+ {isStaff && staffModerationNote ? ( +
{staffModerationNote}
+ ) : null} {nixPlugin ? (
Bundles the skill pack, CLI binary, and config requirements in one Nix install. @@ -246,6 +301,11 @@ export function SkillDetailPage({ {badge}
))} + {isStaff && staffVisibilityTag ? ( +
+ {staffVisibilityTag} +
+ ) : null}
{isAuthenticated ? ( ) : null} + {staff ? ( + + ) : null} {admin ? ( <>