Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
### Fixed
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).

## 0.6.1 - 2026-02-13

Expand Down
41 changes: 33 additions & 8 deletions convex/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,36 +19,54 @@ export const downloadZip = httpAction(async (ctx, request) => {
const tagParam = url.searchParams.get('tag')?.trim()

if (!slug) {
return new Response('Missing slug', { status: 400 })
return new Response('Missing slug', {
status: 400,
headers: { 'Access-Control-Allow-Origin': '*' },
})
}

const rate = await applyRateLimit(ctx, request, 'download')
if (!rate.ok) return rate.response

const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
if (!skillResult?.skill) {
return new Response('Skill not found', { status: 404 })
return new Response('Skill not found', {
status: 404,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}

// Block downloads based on moderation status.
const mod = skillResult.moderationInfo
if (mod?.isMalwareBlocked) {
return new Response(
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
{ status: 403 },
{
status: 403,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
},
)
}
if (mod?.isPendingScan) {
return new Response(
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
{ status: 423 },
{
status: 423,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
},
)
}
if (mod?.isRemoved) {
return new Response('This skill has been removed by a moderator.', { status: 410 })
return new Response('This skill has been removed by a moderator.', {
status: 410,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}
if (mod?.isHiddenByMod) {
return new Response('This skill is currently unavailable.', { status: 403 })
return new Response('This skill is currently unavailable.', {
status: 403,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}

const skill = skillResult.skill
Expand All @@ -67,10 +85,16 @@ export const downloadZip = httpAction(async (ctx, request) => {
}

if (!version) {
return new Response('Version not found', { status: 404 })
return new Response('Version not found', {
status: 404,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}
if (version.softDeletedAt) {
return new Response('Version not available', { status: 410 })
return new Response('Version not available', {
status: 410,
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}

const entries: Array<{ path: string; bytes: Uint8Array }> = []
Expand Down Expand Up @@ -108,6 +132,7 @@ export const downloadZip = httpAction(async (ctx, request) => {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
'Cache-Control': 'private, max-age=60',
'Access-Control-Allow-Origin': '*',
}),
})
})
Expand Down
7 changes: 7 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
usersListV1Http,
usersPostRouterV1Http,
whoamiV1Http,
preflightHandler,
} from './httpApiV1'

const http = httpRouter()
Expand Down Expand Up @@ -145,6 +146,12 @@ http.route({
handler: soulsDeleteRouterV1Http,
})

http.route({
pathPrefix: '/api/',
method: 'OPTIONS',
handler: preflightHandler,
})

// TODO: remove legacy /api routes after deprecation window.
http.route({
path: LegacyApiRoutes.download,
Expand Down
2 changes: 2 additions & 0 deletions convex/httpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function json(value: unknown, status = 200) {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
})
}
Expand All @@ -254,6 +255,7 @@ function text(value: string, status: number) {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
})
}
Expand Down
33 changes: 33 additions & 0 deletions convex/httpApiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
// reading localStorage tokens on this origin.
'Content-Security-Policy':
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'Access-Control-Allow-Origin': '*',
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
})
return new Response(textContent, { status: 200, headers })
Expand Down Expand Up @@ -502,6 +503,35 @@ async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {

export const publishSkillV1Http = httpAction(publishSkillV1Handler)

export const preflightHandler = httpAction(async (_ctx, request) => {
const requestedHeaders =
request.headers.get('access-control-request-headers')?.trim() ||
request.headers.get('Access-Control-Request-Headers')?.trim() ||
null
const requestedMethod =
request.headers.get('access-control-request-method')?.trim() ||
request.headers.get('Access-Control-Request-Method')?.trim() ||
null
const vary = [
...(requestedMethod ? ['Access-Control-Request-Method'] : []),
...(requestedHeaders ? ['Access-Control-Request-Headers'] : []),
].join(', ')

// No cookies/credentials supported; allow any origin for simple browser access.
// If we ever add cookie auth, this must switch to reflecting origin + Allow-Credentials.
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
'Access-Control-Allow-Headers':
requestedHeaders ?? 'Content-Type, Authorization, Digest, X-Clawhub-Version',
'Access-Control-Max-Age': '86400',
...(vary ? { Vary: vary } : {}),
},
})
})

type FileLike = {
name: string
size: number
Expand Down Expand Up @@ -968,6 +998,7 @@ function json(value: unknown, status = 200, headers?: HeadersInit) {
{
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
headers,
),
Expand All @@ -981,6 +1012,7 @@ function text(value: string, status: number, headers?: HeadersInit) {
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
headers,
),
Expand Down Expand Up @@ -1275,6 +1307,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
// reading localStorage tokens on this origin.
'Content-Security-Policy':
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'Access-Control-Allow-Origin': '*',
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
})
return new Response(textContent, { status: 200, headers })
Expand Down
1 change: 1 addition & 0 deletions convex/lib/httpRateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function applyRateLimit(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
headers,
),
Expand Down
5 changes: 5 additions & 0 deletions packages/clawdhub/src/cli/commands/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))

const mockReadGlobalConfig = vi.fn(async () => null)
vi.mock('../../config.js', () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
}))

const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
Expand Down
11 changes: 8 additions & 3 deletions packages/clawdhub/src/cli/commands/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ApiV1SkillVersionListResponseSchema,
ApiV1SkillVersionResponseSchema,
} from '../../schema/index.js'
import { readGlobalConfig } from '../../config.js'
import { getRegistry } from '../registry.js'
import type { GlobalOpts } from '../types.js'
import { createSpinner, fail, formatError } from '../ui.js'
Expand All @@ -31,12 +32,15 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
if (!trimmed) fail('Slug required')
if (options.version && options.tag) fail('Use either --version or --tag')

const cfg = await readGlobalConfig()
const token = cfg?.token ?? undefined

const registry = await getRegistry(opts, { cache: true })
const spinner = createSpinner('Fetching skill')
try {
const skillResult = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
ApiV1SkillResponseSchema,
)

Expand Down Expand Up @@ -67,6 +71,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
targetVersion,
)}`,
token,
},
ApiV1SkillVersionResponseSchema,
)
Expand All @@ -80,7 +85,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
spinner.text = `Fetching versions (${limit})`
versionsList = await apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: 'GET', url: url.toString(), token },
ApiV1SkillVersionListResponseSchema,
)
}
Expand All @@ -97,7 +102,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
url.searchParams.set('version', latestVersion)
}
spinner.text = `Fetching ${options.file}`
fileContent = await fetchText(registry, { url: url.toString() })
fileContent = await fetchText(registry, { url: url.toString(), token })
}

spinner.stop()
Expand Down
33 changes: 32 additions & 1 deletion packages/clawdhub/src/cli/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
getRegistry: () => mockGetRegistry(),
}))

const mockReadGlobalConfig = vi.fn(async () => null)
vi.mock('../../config.js', () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
}))

const mockSpinner = {
stop: vi.fn(),
fail: vi.fn(),
Expand Down Expand Up @@ -50,7 +55,7 @@ vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
}))

const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
const {
extractZipToDir,
hashSkillFiles,
Expand Down Expand Up @@ -189,3 +194,29 @@ describe('cmdUpdate', () => {
expect(args?.url).toBeUndefined()
})
})

describe('cmdInstall', () => {
it('passes optional auth token to API + download requests', async () => {
mockReadGlobalConfig.mockResolvedValue({ token: 'tkn' })
mockApiRequest.mockResolvedValue({
skill: { slug: 'demo', displayName: 'Demo', summary: null, tags: {}, stats: {}, createdAt: 0, updatedAt: 0 },
latestVersion: { version: '1.0.0' },
owner: null,
moderation: null,
})
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
vi.mocked(writeLockfile).mockResolvedValue()
vi.mocked(writeSkillOrigin).mockResolvedValue()
vi.mocked(extractZipToDir).mockResolvedValue()
vi.mocked(stat).mockRejectedValue(new Error('missing'))
vi.mocked(rm).mockResolvedValue()

await cmdInstall(makeOpts(), 'demo')

const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
expect(requestArgs?.token).toBe('tkn')
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? []
expect(zipArgs?.token).toBe('tkn')
})
})
Loading
Loading