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 (*)
This commit is contained in:
Maude Bot
2026-02-01 17:48:30 -05:00
parent f654dc9325
commit 29e8ec2bae
5 changed files with 49 additions and 4 deletions
+6 -4
View File
@@ -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<string, Uint8Array> = {}
@@ -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,
},
})
})
+12
View File
@@ -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
+3
View File
@@ -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,
},
})
}
+5
View File
@@ -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 })
}
+23
View File
@@ -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,
})
}