Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 as { token?: string } | 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 as { token?: string } | 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