diff --git a/convex/downloads.ts b/convex/downloads.ts index 2f225ec5c1..b5a70909b6 100644 --- a/convex/downloads.ts +++ b/convex/downloads.ts @@ -11,12 +11,18 @@ 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 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: { 'Access-Control-Allow-Origin': '*' }, + }) } const skill = skillResult.skill @@ -35,10 +41,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: { 'Access-Control-Allow-Origin': '*' }, + }) } if (version.softDeletedAt) { - return new Response('Version not available', { status: 410 }) + return new Response('Version not available', { + status: 410, + headers: { 'Access-Control-Allow-Origin': '*' }, + }) } const entries: Array<{ path: string; bytes: Uint8Array }> = [] @@ -64,6 +76,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': '*', }, }) }) diff --git a/convex/http.ts b/convex/http.ts index 65c8dea199..ef2717253c 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -31,6 +31,7 @@ import { usersListV1Http, usersPostRouterV1Http, whoamiV1Http, + preflightHandler, } from './httpApiV1' const http = httpRouter() @@ -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, diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index c15018013c..36e216e0f9 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -393,6 +393,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 }) @@ -438,6 +439,18 @@ async function publishSkillV1Handler(ctx: ActionCtx, request: Request) { export const publishSkillV1Http = httpAction(publishSkillV1Handler) +export const preflightHandler = httpAction(async (_ctx, _request) => { + 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-Max-Age': '86400', + }, + }) +}) + type FileLike = { name: string size: number @@ -864,6 +877,7 @@ function json(value: unknown, status = 200, headers?: HeadersInit) { { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': '*', }, headers, ), @@ -877,6 +891,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, ), @@ -1146,6 +1161,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 }) diff --git a/packages/clawdhub/src/cli/commands/skills.ts b/packages/clawdhub/src/cli/commands/skills.ts index 23546496d9..a0e23e84d9 100644 --- a/packages/clawdhub/src/cli/commands/skills.ts +++ b/packages/clawdhub/src/cli/commands/skills.ts @@ -21,6 +21,7 @@ import { import { getRegistry } from '../registry.js' import type { GlobalOpts, ResolveResult } from '../types.js' import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js' +import { readGlobalConfig } from '../../config.js' export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) { if (!query) fail('Query required') @@ -61,6 +62,9 @@ export async function cmdInstall( const trimmed = slug.trim() if (!trimmed) fail('Slug required') + const cfg = await readGlobalConfig() + const token = cfg?.token ?? undefined + const registry = await getRegistry(opts, { cache: true }) await mkdir(opts.dir, { recursive: true }) const target = join(opts.dir, trimmed) @@ -78,7 +82,7 @@ export async function cmdInstall( ( await apiRequest( registry, - { method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` }, + { method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token }, ApiV1SkillResponseSchema, ) ).latestVersion?.version ?? @@ -86,7 +90,7 @@ export async function cmdInstall( if (!resolvedVersion) fail('Could not resolve latest version') spinner.text = `Downloading ${trimmed}@${resolvedVersion}` - const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion }) + const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token }) await extractZipToDir(zip, target) await writeSkillOrigin(target, { diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 7858801cc0..375b68dd06 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -157,19 +157,29 @@ export async function fetchText(registry: string, args: TextRequestArgs): Promis ) } -export async function downloadZip(registry: string, args: { slug: string; version?: string }) { +export async function downloadZip( + registry: string, + args: { slug: string; version?: string; token?: string }, +) { const url = new URL(ApiRoutes.download, registry) url.searchParams.set('slug', args.slug) if (args.version) url.searchParams.set('version', args.version) return pRetry( async () => { if (isBun) { - return await fetchBinaryViaCurl(url.toString()) + return await fetchBinaryViaCurl(url.toString(), args.token) } + const headers: Record = {} + if (args.token) headers.Authorization = `Bearer ${args.token}` + const controller = new AbortController() const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) - const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal }) + const response = await fetch(url.toString(), { + method: 'GET', + headers, + signal: controller.signal, + }) clearTimeout(timeout) if (!response.ok) { const message = (await response.text().catch(() => '')) || `HTTP ${response.status}` @@ -321,16 +331,22 @@ async function fetchTextViaCurl(url: string, args: { token?: string }) { return body } -async function fetchBinaryViaCurl(url: string) { +async function fetchBinaryViaCurl(url: string, token?: string) { const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-')) const filePath = join(tempDir, 'payload.bin') try { + const headers: string[] = [] + if (token) { + headers.push('-H', `Authorization: Bearer ${token}`) + } + const curlArgs = [ '--silent', '--show-error', '--location', '--max-time', String(REQUEST_TIMEOUT_SECONDS), + ...headers, '-o', filePath, '--write-out',