Compare commits

...
20 changed files with 1135 additions and 62 deletions
+1
View File
@@ -32,6 +32,7 @@
- Comments: hide entries authored by deleted/deactivated users in `comments:listBySkill`.
- Admin API: `POST /api/v1/users/reclaim` now performs non-destructive root-slug owner transfer
(preserves existing skill versions/stats/metadata) and clears active slug reservations.
- Publish/import: surface slug-collision errors cleanly and preflight conflicting slugs before upload/import (#605) (thanks @tristanmanchester).
- VirusTotal: use shared AV-engine fallback verdict mapping for pending/backfill flows and keep undetected-only results pending (#591) (thanks @Shuai-DaiDai).
- CLI publish: use a longer multipart upload timeout and normalize abort rejections into proper Errors (#550) (thanks @MunemHashmi).
- CLI: forward optional auth tokens for `search` and `explore` against authenticated registries (#608) (thanks @artdaal).
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import {
enforceReservedSlugCooldownForNewSkill,
formatReservedSlugCooldownMessage,
} from './reservedSlugs'
describe('reservedSlugs', () => {
it('throws a user-facing error when slug is actively reserved by another user', async () => {
const now = Date.now()
const db = {
query: vi.fn((table: string) => {
if (table !== 'reservedSlugs') throw new Error(`unexpected table ${table}`)
return {
withIndex: (name: string) => {
if (name !== 'by_slug_active_deletedAt') {
throw new Error(`unexpected index ${name}`)
}
return {
order: () => ({
take: async () => [
{
_id: 'reservedSlugs:1',
slug: 'taken-skill',
originalOwnerUserId: 'users:owner',
deletedAt: now - 1000,
expiresAt: now + 60_000,
releasedAt: undefined,
},
],
}),
}
},
}
}),
patch: vi.fn(async () => {}),
}
await expect(
enforceReservedSlugCooldownForNewSkill(
{ db } as never,
{ slug: 'taken-skill', userId: 'users:caller' as never, now },
),
).rejects.toThrow(formatReservedSlugCooldownMessage('taken-skill', now + 60_000))
})
})
+9 -5
View File
@@ -1,3 +1,4 @@
import { ConvexError } from 'convex/values'
import type { Doc, Id } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'
@@ -5,6 +6,13 @@ type ReservedSlug = Doc<'reservedSlugs'>
const DEFAULT_ACTIVE_LIMIT = 25
export function formatReservedSlugCooldownMessage(slug: string, expiresAt: number) {
return (
`Slug "${slug}" is reserved for its previous owner until ${new Date(expiresAt).toISOString()}. ` +
'Please choose a different slug.'
)
}
function reservedSlugQuery(ctx: QueryCtx | MutationCtx, slug: string) {
return ctx.db
.query('reservedSlugs')
@@ -116,13 +124,9 @@ export async function enforceReservedSlugCooldownForNewSkill(
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.',
)
throw new ConvexError(formatReservedSlugCooldownMessage(params.slug, latest.expiresAt))
}
await ctx.db.patch(latest._id, { releasedAt: params.now })
await releaseDuplicateActiveReservations(ctx, active, latest._id, params.now)
}
+126
View File
@@ -120,6 +120,132 @@ describe('skills anti-spam guards', () => {
).rejects.toThrow(/max 5 new skills per hour/i)
})
it('returns a user-facing slug-taken message when publishing to another owner slug', async () => {
let authAccountLookupCount = 0
const db = {
get: vi.fn(async (id: string) => {
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
if (id === 'users:owner') {
return {
_id: 'users:owner',
handle: 'alice',
deletedAt: undefined,
deactivatedAt: undefined,
}
}
return null
}),
query: vi.fn((table: string) => {
if (table === 'skills') {
return {
withIndex: (name: string) => {
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
return {
unique: async () => ({
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:owner',
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
}),
}
},
}
}
if (table === 'authAccounts') {
return {
withIndex: (name: string) => {
if (name !== 'userIdAndProvider') throw new Error(`unexpected auth index ${name}`)
return {
unique: async () => {
authAccountLookupCount += 1
return authAccountLookupCount === 1
? { providerAccountId: 'owner-gh' }
: { providerAccountId: 'caller-gh' }
},
}
},
}
}
throw new Error(`unexpected table ${table}`)
}),
}
await expect(
insertVersionHandler(
{ db } as never,
createPublishArgs({
userId: 'users:caller',
slug: 'taken-skill',
}) as never,
),
).rejects.toThrow('Slug is already taken. Choose a different slug. Existing skill: /alice/taken-skill')
})
it('does not include a URL in slug-taken message when conflicting owner is deleted', async () => {
let authAccountLookupCount = 0
const db = {
get: vi.fn(async (id: string) => {
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
if (id === 'users:owner') {
return {
_id: 'users:owner',
handle: 'alice',
deletedAt: Date.now(),
deactivatedAt: undefined,
}
}
return null
}),
query: vi.fn((table: string) => {
if (table === 'skills') {
return {
withIndex: (name: string) => {
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
return {
unique: async () => ({
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:owner',
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
}),
}
},
}
}
if (table === 'authAccounts') {
return {
withIndex: (name: string) => {
if (name !== 'userIdAndProvider') throw new Error(`unexpected auth index ${name}`)
return {
unique: async () => {
authAccountLookupCount += 1
return authAccountLookupCount === 1
? { providerAccountId: 'owner-gh' }
: { providerAccountId: 'caller-gh' }
},
}
},
}
}
throw new Error(`unexpected table ${table}`)
}),
}
await expect(
insertVersionHandler(
{ db } as never,
createPublishArgs({
userId: 'users:caller',
slug: 'taken-skill',
}) as never,
),
).rejects.toThrow('Slug is already taken. Choose a different slug.')
})
it('keeps suspicious skills visible for low-trust publishers', async () => {
const patch = vi.fn(async () => {})
const version = { _id: 'skillVersions:1', skillId: 'skills:1' }
+328
View File
@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { formatReservedSlugCooldownMessage } from './lib/reservedSlugs'
vi.mock('@convex-dev/auth/server', () => ({
getAuthUserId: vi.fn(),
}))
import { getAuthUserId } from '@convex-dev/auth/server'
import { checkSlugAvailability } from './skills'
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>
}
type SkillDoc = {
_id: string
slug: string
ownerUserId: string
softDeletedAt?: number
moderationStatus?: 'active' | 'hidden' | 'removed'
moderationFlags?: string[]
}
type ReservationDoc = {
_id: string
slug: string
originalOwnerUserId: string
deletedAt: number
expiresAt: number
releasedAt?: number
}
const checkSlugAvailabilityHandler = (
checkSlugAvailability as unknown as WrappedHandler<{ slug: string }>
)._handler
function createCtx(options: {
skill: SkillDoc | null
reservation?: ReservationDoc | null
owner?: { _id: string; handle?: string | null; deletedAt?: number; deactivatedAt?: number } | null
callerId?: string
ownerProviderAccountId?: string | null
callerProviderAccountId?: string | null
}) {
const callerId = options.callerId ?? 'users:caller'
let authAccountLookupCount = 0
const db = {
get: vi.fn(async (id: string) => {
if (id === callerId) {
return { _id: callerId, deletedAt: undefined, deactivatedAt: undefined }
}
if (options.owner && id === options.owner._id) return options.owner
return null
}),
query: vi.fn((table: string) => {
if (table === 'skills') {
return {
withIndex: (name: string) => {
if (name !== 'by_slug') throw new Error(`unexpected skills index ${name}`)
return {
unique: async () => options.skill,
}
},
}
}
if (table === 'reservedSlugs') {
return {
withIndex: (name: string) => {
if (name !== 'by_slug_active_deletedAt') {
throw new Error(`unexpected reservedSlugs index ${name}`)
}
return {
order: () => ({
take: async () => (options.reservation ? [options.reservation] : []),
}),
}
},
}
}
if (table === 'authAccounts') {
return {
withIndex: (name: string) => {
if (name !== 'userIdAndProvider') {
throw new Error(`unexpected authAccounts index ${name}`)
}
return {
unique: async () => {
authAccountLookupCount += 1
if (authAccountLookupCount === 1) {
return options.ownerProviderAccountId
? { providerAccountId: options.ownerProviderAccountId }
: null
}
return options.callerProviderAccountId
? { providerAccountId: options.callerProviderAccountId }
: null
},
}
},
}
}
throw new Error(`unexpected table ${table}`)
}),
}
return { db }
}
describe('skills.checkSlugAvailability', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns taken without URL for non-public collisions', async () => {
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:owner',
softDeletedAt: 123,
moderationStatus: 'active',
moderationFlags: undefined,
},
owner: {
_id: 'users:owner',
handle: 'alice',
deletedAt: undefined,
deactivatedAt: undefined,
},
ownerProviderAccountId: 'owner-gh',
callerProviderAccountId: 'caller-gh',
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string
url: string | null
}
expect(result).toEqual({
available: false,
reason: 'taken',
message: 'Slug is already taken. Choose a different slug.',
url: null,
})
})
it('returns taken with URL for public collisions', async () => {
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:owner',
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
},
owner: {
_id: 'users:owner',
handle: 'alice',
deletedAt: undefined,
deactivatedAt: undefined,
},
ownerProviderAccountId: 'owner-gh',
callerProviderAccountId: 'caller-gh',
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string
url: string | null
}
expect(result).toEqual({
available: false,
reason: 'taken',
message: 'Slug is already taken. Choose a different slug. Existing skill: /alice/taken-skill',
url: '/alice/taken-skill',
})
})
it('returns available when slug belongs to current user', async () => {
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:caller',
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
},
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string | null
url: string | null
}
expect(result).toEqual({
available: true,
reason: 'available',
message: null,
url: null,
})
})
it('returns reserved when active reservation belongs to another user', async () => {
const now = 1_700_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: null,
reservation: {
_id: 'reservedSlugs:1',
slug: 'taken-skill',
originalOwnerUserId: 'users:owner',
deletedAt: now - 1_000,
expiresAt: now + 60_000,
releasedAt: undefined,
},
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string
url: string | null
}
expect(result).toEqual({
available: false,
reason: 'reserved',
message: formatReservedSlugCooldownMessage('taken-skill', now + 60_000),
url: null,
})
})
it('returns available when reservation has expired', async () => {
const now = 1_700_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: null,
reservation: {
_id: 'reservedSlugs:1',
slug: 'taken-skill',
originalOwnerUserId: 'users:owner',
deletedAt: now - 120_000,
expiresAt: now - 60_000,
releasedAt: undefined,
},
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string | null
url: string | null
}
expect(result).toEqual({
available: true,
reason: 'available',
message: null,
url: null,
})
})
it('returns available when ownership can be healed via shared GitHub identity', async () => {
vi.mocked(getAuthUserId).mockResolvedValue('users:caller' as never)
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: 'skills:1',
slug: 'taken-skill',
ownerUserId: 'users:owner',
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
},
owner: {
_id: 'users:owner',
handle: 'alice',
deletedAt: undefined,
deactivatedAt: undefined,
},
ownerProviderAccountId: 'shared-gh',
callerProviderAccountId: 'shared-gh',
}) as never,
{ slug: 'taken-skill' } as never,
)) as {
available: boolean
reason: string
message: string | null
url: string | null
}
expect(result).toEqual({
available: true,
reason: 'available',
message: null,
url: null,
})
})
})
+111 -3
View File
@@ -27,6 +27,7 @@ import {
adjustGlobalPublicSkillsCount,
countPublicSkillsForGlobalStats,
getPublicSkillVisibilityDelta,
isPublicSkillDoc,
readGlobalPublicSkillsCount,
} from './lib/globalStats'
import { buildTrendingLeaderboard } from './lib/leaderboards'
@@ -41,6 +42,7 @@ import { embeddingVisibilityFor } from './lib/embeddingVisibility'
import { scheduleNextBatchIfNeeded } from './lib/batching'
import {
enforceReservedSlugCooldownForNewSkill,
formatReservedSlugCooldownMessage,
getLatestActiveReservedSlug,
listActiveReservedSlugsForSlug,
reserveSlugForHardDeleteFinalize,
@@ -119,6 +121,20 @@ function stripSuspiciousFlag(flags: string[] | undefined) {
return next.length ? next : undefined
}
function buildConflictingSkillUrl(skill: Doc<'skills'>, owner: Doc<'users'> | null | undefined) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null
const ownerParam = owner.handle?.trim() || String(owner._id)
if (!ownerParam) return null
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`
}
function buildSlugTakenErrorMessage(skill: Doc<'skills'>, owner: Doc<'users'> | null | undefined) {
const base = 'Slug is already taken. Choose a different slug.'
const url = buildConflictingSkillUrl(skill, owner)
if (!url) return base
return `${base} Existing skill: ${url}`
}
function normalizeScannerSuspiciousReason(reason: string | undefined) {
if (!reason) return reason
if (!reason.startsWith('scanner.') || !reason.endsWith('.suspicious')) return reason
@@ -800,6 +816,97 @@ export const getBySlug = query({
},
})
export const checkSlugAvailability = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const { userId } = await requireUser(ctx)
const slug = args.slug.trim().toLowerCase()
if (!slug) {
return {
available: false,
reason: 'taken' as const,
message: 'Slug is required.',
url: null,
}
}
const skill = await ctx.db
.query('skills')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (!skill) {
const reservation = await getLatestActiveReservedSlug(ctx, slug)
if (
reservation &&
reservation.expiresAt > Date.now() &&
reservation.originalOwnerUserId !== userId
) {
return {
available: false,
reason: 'reserved' as const,
message: formatReservedSlugCooldownMessage(slug, reservation.expiresAt),
url: null,
}
}
return {
available: true,
reason: 'available' as const,
message: null,
url: null,
}
}
if (skill.ownerUserId === userId) {
return {
available: true,
reason: 'available' as const,
message: null,
url: null,
}
}
const owner = await ctx.db.get(skill.ownerUserId)
const url = buildConflictingSkillUrl(skill, owner)
const slugTakenMessage = buildSlugTakenErrorMessage(skill, owner)
if (!owner || owner.deletedAt || owner.deactivatedAt) {
return {
available: false,
reason: 'taken' as const,
message: slugTakenMessage,
url,
}
}
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
getGitHubProviderAccountId(ctx, skill.ownerUserId),
getGitHubProviderAccountId(ctx, userId),
])
if (
canHealSkillOwnershipByGitHubProviderAccountId(
ownerProviderAccountId,
callerProviderAccountId,
)
) {
return {
available: true,
reason: 'available' as const,
message: null,
url: null,
}
}
return {
available: false,
reason: 'taken' as const,
message: slugTakenMessage,
url,
}
},
})
export const getBySlugForStaff = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
@@ -3600,8 +3707,9 @@ export const insertVersion = internalMutation({
// Fallback: Convex Auth can create duplicate `users` records. Heal ownership ONLY
// when the underlying GitHub identity matches (authAccounts.providerAccountId).
const owner = await ctx.db.get(skill.ownerUserId)
const slugTakenMessage = buildSlugTakenErrorMessage(skill, owner)
if (!owner || owner.deletedAt || owner.deactivatedAt) {
throw new Error('Only the owner can publish updates')
throw new ConvexError(slugTakenMessage)
}
const [ownerProviderAccountId, callerProviderAccountId] = await Promise.all([
@@ -3616,7 +3724,7 @@ export const insertVersion = internalMutation({
callerProviderAccountId,
)
) {
throw new Error('Only the owner can publish updates')
throw new ConvexError(slugTakenMessage)
}
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now })
@@ -3756,7 +3864,7 @@ export const insertVersion = internalMutation({
.withIndex('by_skill_version', (q) => q.eq('skillId', skill._id).eq('version', args.version))
.unique()
if (existingVersion) {
throw new Error('Version already exists')
throw new ConvexError('Version already exists')
}
const versionId = await ctx.db.insert('skillVersions', {
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import { insertVersion } from './souls'
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>
}
const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<string, unknown>>)
._handler
describe('souls.insertVersion', () => {
it('throws a soul-specific ownership error for non-owners', async () => {
const db = {
get: vi.fn(async (id: string) => {
if (id === 'users:caller') return { _id: 'users:caller', deletedAt: undefined }
return null
}),
query: vi.fn((table: string) => {
if (table !== 'souls') throw new Error(`unexpected table ${table}`)
return {
withIndex: (name: string) => {
if (name !== 'by_slug') throw new Error(`unexpected index ${name}`)
return {
order: () => ({
take: async () => [
{
_id: 'souls:1',
slug: 'demo-soul',
ownerUserId: 'users:owner',
softDeletedAt: undefined,
},
],
}),
}
},
}
}),
}
await expect(
insertVersionHandler(
{ db } as never,
{
userId: 'users:caller',
slug: 'demo-soul',
displayName: 'Demo Soul',
version: '1.0.0',
changelog: 'Initial',
changelogSource: 'user',
tags: ['latest'],
fingerprint: 'f'.repeat(64),
files: [
{
path: 'SOUL.md',
size: 100,
storageId: '_storage:1',
sha256: 'a'.repeat(64),
contentType: 'text/markdown',
},
],
parsed: {
frontmatter: {},
metadata: {},
},
embedding: [0.1, 0.2],
} as never,
),
).rejects.toThrow('Only the owner can publish soul updates')
})
})
+1 -1
View File
@@ -405,7 +405,7 @@ export const insertVersion = internalMutation({
let soul: Doc<'souls'> | null = soulMatches[0] ?? null
if (soul && soul.ownerUserId !== userId) {
throw new Error('Only the owner can publish updates')
throw new ConvexError('Only the owner can publish soul updates')
}
const now = Date.now()
+1 -1
View File
@@ -90,7 +90,7 @@ describe('http bun runtime', () => {
expect(args).toContain('https://registry.example/v1/ping')
expect(args).toContain('Accept: application/json')
expect(args).toContain('Authorization: Bearer clh_token')
})
}, 15_000)
it('uses curl for apiRequest POST with json body', async () => {
const spawnSync = vi.fn().mockReturnValue({
+129
View File
@@ -0,0 +1,129 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ImportGitHub } from '../routes/import'
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (config: { component: unknown }) => config,
useNavigate: () => vi.fn(),
}))
const previewImport = vi.fn()
const previewCandidate = vi.fn()
const importSkill = vi.fn()
const useQueryMock = vi.fn()
const useAuthStatusMock = vi.fn()
let useActionCallCount = 0
vi.mock('convex/react', () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useAction: () => {
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3]
useActionCallCount += 1
return action
},
}))
vi.mock('../lib/useAuthStatus', () => ({
useAuthStatus: () => useAuthStatusMock(),
}))
describe('Import route', () => {
beforeEach(() => {
previewImport.mockReset()
previewCandidate.mockReset()
importSkill.mockReset()
useQueryMock.mockReset()
useAuthStatusMock.mockReset()
useActionCallCount = 0
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: 'users:1', handle: 'me' },
})
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
return null
})
previewImport.mockResolvedValue({
candidates: [
{
path: 'skill',
readmePath: 'skill/SKILL.md',
name: 'Taken Skill',
description: null,
},
],
})
previewCandidate.mockResolvedValue({
resolved: {
owner: 'octo',
repo: 'repo',
ref: 'main',
commit: 'abcdef1234567890',
path: 'skill',
repoUrl: 'https://github.com/octo/repo',
originalUrl: 'https://github.com/octo/repo',
},
candidate: {
path: 'skill',
readmePath: 'skill/SKILL.md',
name: 'Taken Skill',
description: null,
},
defaults: {
selectedPaths: ['skill/SKILL.md'],
slug: 'taken-skill',
displayName: 'Taken Skill',
version: '1.0.0',
tags: ['latest'],
},
files: [
{
path: 'skill/SKILL.md',
size: 120,
defaultSelected: true,
},
],
})
})
it('blocks import preflight when slug availability reports a collision', async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
if (
args &&
typeof args === 'object' &&
'slug' in (args as Record<string, unknown>) &&
(args as Record<string, unknown>).slug === 'taken-skill'
) {
return {
available: false,
reason: 'taken',
message: 'Slug is already taken. Choose a different slug.',
url: '/alice/taken-skill',
}
}
return null
})
render(<ImportGitHub />)
fireEvent.change(screen.getByPlaceholderText('https://github.com/owner/repo'), {
target: { value: 'https://github.com/octo/repo' },
})
fireEvent.click(screen.getByRole('button', { name: /detect/i }))
await waitFor(() => {
expect(previewImport).toHaveBeenCalled()
expect(previewCandidate).toHaveBeenCalled()
})
expect(await screen.findByText(/Slug is already taken\. Choose a different slug\./i)).toBeTruthy()
expect(screen.getByRole('link', { name: '/alice/taken-skill' })).toBeTruthy()
expect(screen.getByRole('button', { name: /import \+ publish/i }).getAttribute('disabled')).not.toBeNull()
})
})
+44
View File
@@ -251,4 +251,48 @@ describe('Upload route', () => {
fireEvent.click(publishButton)
expect(await screen.findByText(/Changelog is required/i)).toBeTruthy()
})
it('blocks publish in preflight when slug availability reports a collision', async () => {
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === 'skip') return undefined
if (
args &&
typeof args === 'object' &&
'slug' in (args as Record<string, unknown>) &&
(args as Record<string, unknown>).slug === 'taken-skill'
) {
return {
available: false,
reason: 'taken',
message: 'Slug is already taken. Choose a different slug.',
url: '/alice/taken-skill',
}
}
return null
})
render(<Upload />)
fireEvent.change(screen.getByPlaceholderText('skill-name'), {
target: { value: 'taken-skill' },
})
fireEvent.change(screen.getByPlaceholderText('My skill'), {
target: { value: 'Taken Skill' },
})
fireEvent.change(screen.getByPlaceholderText('1.0.0'), {
target: { value: '1.2.3' },
})
fireEvent.change(screen.getByPlaceholderText('latest, stable'), {
target: { value: 'latest' },
})
fireEvent.change(screen.getByPlaceholderText('Describe what changed in this skill...'), {
target: { value: 'Initial drop.' },
})
const file = new File(['hello'], 'SKILL.md', { type: 'text/markdown' })
const input = screen.getByTestId('upload-input') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
expect(await screen.findByText(/Slug is already taken\. Choose a different slug\./i)).toBeTruthy()
expect(screen.getByRole('link', { name: '/alice/taken-skill' })).toBeTruthy()
expect(screen.getByRole('button', { name: /publish skill/i }).getAttribute('disabled')).not.toBeNull()
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { getUserFacingConvexError } from './convexError'
describe('getUserFacingConvexError', () => {
it('falls back when data is generic wrapper text', () => {
expect(
getUserFacingConvexError({ data: 'Server Error Called by client' }, 'Publish failed'),
).toBe('Publish failed')
})
it('unwraps convex wrapper text from Error messages', () => {
expect(
getUserFacingConvexError(
new Error('[CONVEX A] [Request ID: abc] Server Error Called by client ConvexError: Bad input'),
'fallback',
),
).toBe('Bad input')
})
it('preserves ownership errors as-is after cleanup', () => {
expect(
getUserFacingConvexError(new Error('Only the owner can publish soul updates'), 'fallback'),
).toBe('Only the owner can publish soul updates')
})
it('returns fallback for unknown errors', () => {
expect(getUserFacingConvexError('wat', 'Publish failed')).toBe('Publish failed')
})
})
+49
View File
@@ -0,0 +1,49 @@
type ConvexLikeErrorData =
| string
| {
message?: unknown
}
| null
| undefined
type ConvexLikeError = {
data?: ConvexLikeErrorData
message?: unknown
}
function cleanupConvexMessage(message: string) {
return message
.replace(/\[CONVEX[^\]]*\]\s*/g, '')
.replace(/\[Request ID:[^\]]*\]\s*/g, '')
.replace(/^Server Error Called by client\s*/i, '')
.replace(/^ConvexError:\s*/i, '')
.trim()
}
export function getUserFacingConvexError(error: unknown, fallback: string) {
const candidates: string[] = []
const maybe = error as ConvexLikeError
if (maybe && typeof maybe === 'object' && 'data' in maybe) {
if (typeof maybe.data === 'string') candidates.push(maybe.data)
if (maybe.data && typeof maybe.data === 'object' && typeof maybe.data.message === 'string') {
candidates.push(maybe.data.message)
}
}
if (error instanceof Error && typeof error.message === 'string') {
candidates.push(error.message)
} else if (maybe && typeof maybe.message === 'string') {
candidates.push(maybe.message)
}
for (const raw of candidates) {
const cleaned = cleanupConvexMessage(raw)
if (!cleaned) continue
if (/^server error$/i.test(cleaned)) continue
if (/^internal server error$/i.test(cleaned)) continue
return cleaned
}
return fallback
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { getPublicSlugCollision } from './slugCollision'
describe('getPublicSlugCollision', () => {
it('returns null when availability result is missing', () => {
expect(
getPublicSlugCollision({
isSoulMode: false,
slug: 'demo',
result: undefined,
}),
).toBeNull()
})
it('returns null when slug is available', () => {
expect(
getPublicSlugCollision({
isSoulMode: false,
slug: 'demo',
result: {
available: true,
reason: 'available',
message: null,
url: null,
},
}),
).toBeNull()
})
it('returns collision with link when query reports unavailable with URL', () => {
expect(
getPublicSlugCollision({
isSoulMode: false,
slug: 'demo',
result: {
available: false,
reason: 'taken',
message: 'Slug is already taken. Choose a different slug.',
url: '/alice/demo',
},
}),
).toEqual({
message: 'Slug is already taken. Choose a different slug.',
url: '/alice/demo',
})
})
it('returns generic collision message when backend message is empty', () => {
expect(
getPublicSlugCollision({
isSoulMode: false,
slug: 'demo',
result: {
available: false,
reason: 'reserved',
message: ' ',
url: null,
},
}),
).toEqual({
message: 'Slug is already taken. Choose a different slug.',
url: null,
})
})
})
+28
View File
@@ -0,0 +1,28 @@
type SlugAvailabilityResult =
| {
available: boolean
reason: 'available' | 'taken' | 'reserved'
message: string | null
url: string | null
}
| null
export type PublicSlugCollision = {
message: string
url: string | null
}
export function getPublicSlugCollision(params: {
isSoulMode: boolean
slug: string
result: SlugAvailabilityResult | undefined
}): PublicSlugCollision | null {
if (params.isSoulMode) return null
const normalizedSlug = params.slug.trim().toLowerCase()
if (!normalizedSlug) return null
if (!params.result || params.result.available) return null
return {
message: params.result.message?.trim() || 'Slug is already taken. Choose a different slug.',
url: params.result.url ?? null,
}
}
+3
View File
@@ -20,6 +20,9 @@ describe('uploadUtils', () => {
it('formats publish errors from Convex-like payloads', () => {
expect(formatPublishError({ data: ' whoops ' })).toBe('whoops')
expect(formatPublishError({ data: { message: ' nope ' } })).toBe('nope')
expect(formatPublishError({ data: 'Server Error Called by client' })).toBe(
'Publish failed. Please try again.',
)
})
it('cleans up Error messages and provides a fallback', () => {
+2 -23
View File
@@ -1,4 +1,5 @@
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'clawhub-schema'
import { getUserFacingConvexError } from './convexError'
export async function uploadFile(uploadUrl: string, file: File) {
const response = await fetch(uploadUrl, {
@@ -38,29 +39,7 @@ export function formatBytes(bytes: number) {
}
export function formatPublishError(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(/^ConvexError:\s*/i, '')
.replace(/^Server Error Called by client\s*/i, '')
.trim()
if (cleaned && cleaned !== 'Server Error') return cleaned
}
return 'Publish failed. Please try again.'
return getUserFacingConvexError(error, 'Publish failed. Please try again.')
}
export function isTextFile(file: File) {
+52 -6
View File
@@ -1,7 +1,9 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useAction } from 'convex/react'
import { useAction, useQuery } from 'convex/react'
import { useMemo, useState } from 'react'
import { api } from '../../convex/_generated/api'
import { getUserFacingConvexError } from '../lib/convexError'
import { getPublicSlugCollision } from '../lib/slugCollision'
import { formatBytes } from '../lib/uploadUtils'
import { useAuthStatus } from '../lib/useAuthStatus'
@@ -37,7 +39,9 @@ type CandidatePreview = {
files: Array<{ path: string; size: number; defaultSelected: boolean }>
}
function ImportGitHub() {
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export function ImportGitHub() {
const { isAuthenticated, isLoading, me } = useAuthStatus()
const previewImport = useAction(api.githubImport.previewGitHubImport)
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
@@ -58,6 +62,30 @@ function ImportGitHub() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false)
const trimmedSlug = slug.trim()
const slugAvailability = useQuery(
api.skills.checkSlugAvailability,
isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug)
? { slug: trimmedSlug.toLowerCase() }
: 'skip',
) as
| {
available: boolean
reason: 'available' | 'taken' | 'reserved'
message: string | null
url: string | null
}
| null
| undefined
const slugCollision = useMemo(
() =>
getPublicSlugCollision({
isSoulMode: false,
slug: trimmedSlug,
result: slugAvailability,
}),
[slugAvailability, trimmedSlug],
)
const selectedCount = useMemo(() => Object.values(selected).filter(Boolean).length, [selected])
const selectedBytes = useMemo(() => {
@@ -88,7 +116,7 @@ function ImportGitHub() {
setStatus(`Found ${items.length} skills. Pick one.`)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview failed')
setError(getUserFacingConvexError(e, 'Preview failed'))
} finally {
setIsBusy(false)
}
@@ -116,7 +144,7 @@ function ImportGitHub() {
setSelected(nextSelected)
setStatus('Ready to import.')
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview failed')
setError(getUserFacingConvexError(e, 'Preview failed'))
} finally {
setIsBusy(false)
}
@@ -146,6 +174,10 @@ function ImportGitHub() {
const doImport = async () => {
if (!preview) return
if (slugCollision) {
setError(slugCollision.message)
return
}
setIsBusy(true)
setError(null)
setStatus('Importing…')
@@ -170,7 +202,7 @@ function ImportGitHub() {
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : 'unknown')
await navigate({ to: '/$owner/$slug', params: { owner: ownerParam, slug: nextSlug } })
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed')
setError(getUserFacingConvexError(e, 'Import failed'))
setStatus(null)
} finally {
setIsBusy(false)
@@ -400,12 +432,26 @@ function ImportGitHub() {
!slug.trim() ||
!displayName.trim() ||
!version.trim() ||
selectedCount === 0
selectedCount === 0 ||
Boolean(slugCollision)
}
onClick={() => void doImport()}
>
Import + publish
</button>
{slugCollision ? (
<div className="upload-muted">
{slugCollision.message}
{slugCollision.url ? (
<>
{' '}
<a href={slugCollision.url} className="upload-link">
{slugCollision.url}
</a>
</>
) : null}
</div>
) : null}
</div>
</div>
</>
+40
View File
@@ -3,6 +3,7 @@ import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import semver from 'semver'
import { api } from '../../convex/_generated/api'
import { getPublicSlugCollision } from '../lib/slugCollision'
import { getSiteMode } from '../lib/site'
import { expandDroppedItems, expandFilesWithReport } from '../lib/uploadFiles'
import { useAuthStatus } from '../lib/useAuthStatus'
@@ -121,6 +122,29 @@ export function Upload() {
const trimmedSlug = slug.trim()
const trimmedName = displayName.trim()
const trimmedChangelog = changelog.trim()
const slugAvailability = useQuery(
api.skills.checkSlugAvailability,
!isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug)
? { slug: trimmedSlug.toLowerCase() }
: 'skip',
) as
| {
available: boolean
reason: 'available' | 'taken' | 'reserved'
message: string | null
url: string | null
}
| null
| undefined
const slugCollision = useMemo(
() =>
getPublicSlugCollision({
isSoulMode,
slug: trimmedSlug,
result: slugAvailability,
}),
[isSoulMode, slugAvailability, trimmedSlug],
)
useEffect(() => {
if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return
@@ -229,6 +253,9 @@ export function Upload() {
if (totalBytes > maxBytes) {
issues.push('Total file size exceeds 50MB.')
}
if (slugCollision) {
issues.push(slugCollision.message)
}
return {
issues,
ready: issues.length === 0,
@@ -242,6 +269,7 @@ export function Upload() {
hasRequiredFile,
totalBytes,
requiredFileLabel,
slugCollision,
])
useEffect(() => {
@@ -273,6 +301,10 @@ export function Upload() {
}
return
}
if (slugCollision) {
setError(slugCollision.message)
return
}
setError(null)
if (totalBytes > maxBytes) {
setError('Total size exceeds 50MB per version.')
@@ -475,6 +507,14 @@ export function Upload() {
))}
</ul>
)}
{slugCollision?.url ? (
<div className="stat">
Existing skill:{' '}
<a href={slugCollision.url} className="upload-link">
{slugCollision.url}
</a>
</div>
) : null}
</div>
<div className="card upload-panel">
+2 -23
View File
@@ -1,4 +1,5 @@
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from 'clawhub-schema'
import { getUserFacingConvexError } from '../../lib/convexError'
export async function uploadFile(uploadUrl: string, file: File) {
const response = await fetch(uploadUrl, {
@@ -38,29 +39,7 @@ export function formatBytes(bytes: number) {
}
export function formatPublishError(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 'Publish failed. Please try again.'
return getUserFacingConvexError(error, 'Publish failed. Please try again.')
}
export function isTextFile(file: File) {