mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b3e512c9 | ||
|
|
b55c51ef49 | ||
|
|
60110b877e | ||
|
|
b93d4adb92 |
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user