Skip to content

CLI delete returns 'Unauthorized' for all errors (including 'Skill not found') #34

@sergical

Description

@sergical

Problem

The clawdhub delete <slug> command returns Unauthorized even when logged in as the skill owner.

Reproduction

clawdhub whoami
# ✔ sergical

clawdhub delete reflect-notes --yes
# ✖ Unauthorized

Root Cause

In convex/httpApiV1.ts line 471-489, the delete handler catches all errors and returns 401:

async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
  // ...
  try {
    const { userId } = await requireApiTokenUser(ctx, request)
    await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
      userId,
      slug,
      deleted: true,
    })
    return json({ ok: true }, 200, rate.headers)
  } catch {
    return text('Unauthorized', 401, rate.headers)  // <-- catches everything
  }
}

The mutation setSkillSoftDeletedInternal can throw several errors:

  • User not found
  • Slug required
  • Skill not found
  • Role assertion failure (for non-owners)

All of these currently surface as Unauthorized, which is misleading.

Suggested Fix

Differentiate between auth errors and other errors:

async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
  const rate = await applyRateLimit(ctx, request, 'write')
  if (!rate.ok) return rate.response

  const segments = getPathSegments(request, '/api/v1/skills/')
  if (segments.length !== 1) return text('Not found', 404, rate.headers)
  const slug = segments[0]?.trim().toLowerCase() ?? ''
  
  let userId: Id<'users'>
  try {
    const auth = await requireApiTokenUser(ctx, request)
    userId = auth.userId
  } catch {
    return text('Unauthorized', 401, rate.headers)
  }
  
  try {
    await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
      userId,
      slug,
      deleted: true,
    })
    return json({ ok: true }, 200, rate.headers)
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error'
    if (message.includes('not found')) {
      return text('Not found', 404, rate.headers)
    }
    if (message.includes('Only the owner') || message.includes('assertRole')) {
      return text('Forbidden', 403, rate.headers)
    }
    return text(message, 400, rate.headers)
  }
}

This pattern should probably be applied to other handlers that have the same issue (undelete, publish, etc.).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions