fix(cors): complete CORS + tokenized CLI reads

This commit is contained in:
Peter Steinberger
2026-02-14 14:37:47 +01:00
parent a07ddf9897
commit 5c2ef1be3f
12 changed files with 108 additions and 24 deletions
+1
View File
@@ -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
+19 -7
View File
@@ -32,7 +32,7 @@ export const downloadZip = httpAction(async (ctx, request) => {
if (!skillResult?.skill) {
return new Response('Skill not found', {
status: 404,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}
@@ -41,20 +41,32 @@ export const downloadZip = httpAction(async (ctx, request) => {
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
@@ -75,13 +87,13 @@ export const downloadZip = httpAction(async (ctx, request) => {
if (!version) {
return new Response('Version not found', {
status: 404,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}
if (version.softDeletedAt) {
return new Response('Version not available', {
status: 410,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
})
}
+2
View File
@@ -244,6 +244,7 @@ function json(value: unknown, status = 200) {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
})
}
@@ -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': '*',
},
})
}
+20 -3
View File
@@ -503,14 +503,31 @@ async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
export const publishSkillV1Http = httpAction(publishSkillV1Handler)
export const preflightHandler = httpAction(async (_ctx, _request) => {
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',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Digest, X-Clawhub-Version',
'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 } : {}),
},
})
})
+1
View File
@@ -40,6 +40,7 @@ export async function applyRateLimit(
{
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
},
headers,
),
@@ -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(),
@@ -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'
@@ -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,
)
@@ -67,6 +71,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
targetVersion,
)}`,
token,
},
ApiV1SkillVersionResponseSchema,
)
@@ -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,
)
}
@@ -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()
@@ -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(),
@@ -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,
@@ -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')
})
})
+8 -5
View File
@@ -148,6 +148,9 @@ export async function cmdUpdate(
if (options.version && !semver.valid(options.version)) fail('--version must be valid semver')
const allowPrompt = isInteractive() && inputAllowed !== false
const cfg = await readGlobalConfig()
const token = cfg?.token ?? undefined
const registry = await getRegistry(opts, { cache: true })
const lock = await readLockfile(opts.workdir)
const slugs = slug ? [slug] : Object.keys(lock.skills)
@@ -165,7 +168,7 @@ export async function cmdUpdate(
// Always fetch skill metadata to check moderation status
const skillMeta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}`, token },
ApiV1SkillResponseSchema,
)
@@ -206,7 +209,7 @@ export async function cmdUpdate(
let resolveResult: ResolveResult
if (localFingerprint) {
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token)
} else {
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
}
@@ -259,7 +262,7 @@ export async function cmdUpdate(
spinner.start(`Updating ${entry} -> ${targetVersion}`)
}
await rm(target, { recursive: true, force: true })
const zip = await downloadZip(registry, { slug: entry, version: targetVersion })
const zip = await downloadZip(registry, { slug: entry, version: targetVersion, token })
await extractZipToDir(zip, target)
const existingOrigin = await readSkillOrigin(target)
@@ -411,13 +414,13 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
)
}
async function resolveSkillVersion(registry: string, slug: string, hash: string) {
async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) {
const url = new URL(ApiRoutes.resolve, registry)
url.searchParams.set('slug', slug)
url.searchParams.set('hash', hash)
return apiRequest(
registry,
{ method: 'GET', url: url.toString() },
{ method: 'GET', url: url.toString(), token },
ApiV1SkillResolveResponseSchema,
)
}
+1 -1
View File
@@ -109,7 +109,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
let done = 0
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
try {
return await checkRegistrySyncState(registry, skill, resolveSupport)
return await checkRegistrySyncState(registry, skill, resolveSupport, token)
} finally {
done += 1
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`
@@ -100,6 +100,7 @@ export async function checkRegistrySyncState(
registry: string,
skill: LocalSkill,
resolveSupport: { value: boolean | null },
token?: string,
): Promise<Candidate> {
if (resolveSupport.value !== false) {
try {
@@ -108,6 +109,7 @@ export async function checkRegistrySyncState(
{
method: 'GET',
path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`,
token,
},
ApiV1SkillResolveResponseSchema,
)
@@ -149,7 +151,7 @@ export async function checkRegistrySyncState(
const meta = await apiRequest(
registry,
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` },
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}`, token },
ApiV1SkillResponseSchema,
).catch(() => null)
@@ -163,7 +165,7 @@ export async function checkRegistrySyncState(
}
}
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion })
const zip = await downloadZip(registry, { slug: skill.slug, version: latestVersion, token })
const remote = hashSkillZip(zip).fingerprint
const matchVersion = remote === skill.fingerprint ? latestVersion : null
+7 -2
View File
@@ -105,11 +105,16 @@ describe('apiRequest', () => {
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
})
vi.stubGlobal('fetch', fetchMock)
const bytes = await downloadZip('https://example.com', { slug: 'demo', version: '1.0.0' })
const bytes = await downloadZip('https://example.com', {
slug: 'demo',
version: '1.0.0',
token: 'clh_token',
})
expect(Array.from(bytes)).toEqual([1, 2, 3])
const [url] = fetchMock.mock.calls[0] as [string]
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('slug=demo')
expect(url).toContain('version=1.0.0')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
vi.unstubAllGlobals()
})