diff --git a/convex/downloads.ts b/convex/downloads.ts index ac03a28..fbcf70d 100644 --- a/convex/downloads.ts +++ b/convex/downloads.ts @@ -2,6 +2,7 @@ import { v } from 'convex/values' import { zipSync } from 'fflate' import { api } from './_generated/api' import { httpAction, mutation } from './_generated/server' +import { CORS_HEADERS } from './lib/cors' import { insertStatEvent } from './skillStatEvents' export const downloadZip = httpAction(async (ctx, request) => { @@ -11,12 +12,12 @@ 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: CORS_HEADERS }) } 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: CORS_HEADERS }) } const skill = skillResult.skill @@ -35,10 +36,10 @@ 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: CORS_HEADERS }) } if (version.softDeletedAt) { - return new Response('Version not available', { status: 410 }) + return new Response('Version not available', { status: 410, headers: CORS_HEADERS }) } const files: Record = {} @@ -61,6 +62,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', + ...CORS_HEADERS, }, }) }) diff --git a/convex/http.ts b/convex/http.ts index ec3902f..0509578 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -30,6 +30,11 @@ import { starsPostRouterV1Http, whoamiV1Http, } from './httpApiV1' +import { corsPreflightResponse } from './lib/cors' +import { httpAction } from './_generated/server' + +// CORS preflight handler for browser clients +const corsPreflightHttp = httpAction(async () => corsPreflightResponse()) const http = httpRouter() @@ -191,4 +196,11 @@ http.route({ handler: cliSkillUndeleteHttp, }) +// CORS preflight handler for all API routes +http.route({ + pathPrefix: '/api/', + method: 'OPTIONS', + handler: corsPreflightHttp, +}) + export default http diff --git a/convex/httpApi.ts b/convex/httpApi.ts index 4d4dce6..30685e1 100644 --- a/convex/httpApi.ts +++ b/convex/httpApi.ts @@ -11,6 +11,7 @@ import type { Id } from './_generated/dataModel' import type { ActionCtx } from './_generated/server' import { httpAction } from './_generated/server' import { requireApiTokenUser } from './lib/apiTokenAuth' +import { CORS_HEADERS } from './lib/cors' import { publishVersionForUser } from './skills' type SearchSkillEntry = { @@ -244,6 +245,7 @@ function json(value: unknown, status = 200) { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', + ...CORS_HEADERS, }, }) } @@ -254,6 +256,7 @@ function text(value: string, status: number) { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', + ...CORS_HEADERS, }, }) } diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 7e4c6d9..e2849ca 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel' import type { ActionCtx } from './_generated/server' import { httpAction } from './_generated/server' import { requireApiTokenUser } from './lib/apiTokenAuth' +import { CORS_HEADERS } from './lib/cors' import { hashToken } from './lib/tokens' import { publishVersionForUser } from './skills' import { publishSoulVersionForUser } from './souls' @@ -394,6 +395,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) { 'Content-Security-Policy': "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", ...(isSvg ? { 'Content-Disposition': 'attachment' } : {}), + ...CORS_HEADERS, }) return new Response(textContent, { status: 200, headers }) } @@ -731,6 +733,7 @@ function json(value: unknown, status = 200, headers?: HeadersInit) { { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', + ...CORS_HEADERS, }, headers, ), @@ -744,6 +747,7 @@ function text(value: string, status: number, headers?: HeadersInit) { { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', + ...CORS_HEADERS, }, headers, ), @@ -1014,6 +1018,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) { 'Content-Security-Policy': "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", ...(isSvg ? { 'Content-Disposition': 'attachment' } : {}), + ...CORS_HEADERS, }) return new Response(textContent, { status: 200, headers }) } diff --git a/convex/lib/cors.ts b/convex/lib/cors.ts new file mode 100644 index 0000000..973b976 --- /dev/null +++ b/convex/lib/cors.ts @@ -0,0 +1,23 @@ +/** + * CORS headers for public API endpoints. + * + * ClawHub is a public skill registry. Browser-based clients (like Cove WebUI) + * need CORS headers to fetch skills directly from the API. + */ + +export const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', // 24 hours +} as const + +/** + * Handle CORS preflight (OPTIONS) requests. + */ +export function corsPreflightResponse(): Response { + return new Response(null, { + status: 204, + headers: CORS_HEADERS, + }) +}