From 29e8ec2baee5bd7993ee7c5282cc934ca499057e Mon Sep 17 00:00:00 2001 From: Maude Bot Date: Sun, 1 Feb 2026 17:48:30 -0500 Subject: [PATCH] feat(api): add CORS headers for browser clients - Add convex/lib/cors.ts with shared CORS headers and preflight handler - Update httpApi.ts and httpApiV1.ts json/text helpers to include CORS headers - Update downloads.ts to include CORS headers on all responses - Add single OPTIONS handler for /api/* prefix in http.ts This enables browser-based clients (like Cove WebUI) to fetch skills directly from the ClawHub API without CORS errors. Allows: GET, POST, DELETE, OPTIONS from any origin (*) --- convex/downloads.ts | 10 ++++++---- convex/http.ts | 12 ++++++++++++ convex/httpApi.ts | 3 +++ convex/httpApiV1.ts | 5 +++++ convex/lib/cors.ts | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 convex/lib/cors.ts diff --git a/convex/downloads.ts b/convex/downloads.ts index ac03a28b..fbcf70df 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 ec3902f7..05095786 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 4d4dce6c..30685e1c 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 7e4c6d93..e2849cad 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 00000000..973b9764 --- /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, + }) +}