Skip to content

Commit 4328d4d

Browse files
fix(cors): complete CORS + tokenized CLI reads (#296)
* fix(cors): add Access-Control-Allow-Origin headers to API and downloads * fix: add CORS to error/raw paths & add CLI install auth * fix: add OPTIONS handler for CORS preflight * fix(cors): complete CORS + tokenized CLI reads * test(cli): fix config mock typing --------- Co-authored-by: Grenghis-Khan <63885013+Grenghis-Khan@users.noreply.github.com>
1 parent 28ee261 commit 4328d4d

File tree

14 files changed

+164
-28
lines changed

14 files changed

+164
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
### Fixed
1313
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
1414
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
15+
- 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).
1516

1617
## 0.6.1 - 2026-02-13
1718

convex/downloads.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,36 +19,54 @@ export const downloadZip = httpAction(async (ctx, request) => {
1919
const tagParam = url.searchParams.get('tag')?.trim()
2020

2121
if (!slug) {
22-
return new Response('Missing slug', { status: 400 })
22+
return new Response('Missing slug', {
23+
status: 400,
24+
headers: { 'Access-Control-Allow-Origin': '*' },
25+
})
2326
}
2427

2528
const rate = await applyRateLimit(ctx, request, 'download')
2629
if (!rate.ok) return rate.response
2730

2831
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
2932
if (!skillResult?.skill) {
30-
return new Response('Skill not found', { status: 404 })
33+
return new Response('Skill not found', {
34+
status: 404,
35+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
36+
})
3137
}
3238

3339
// Block downloads based on moderation status.
3440
const mod = skillResult.moderationInfo
3541
if (mod?.isMalwareBlocked) {
3642
return new Response(
3743
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
38-
{ status: 403 },
44+
{
45+
status: 403,
46+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
47+
},
3948
)
4049
}
4150
if (mod?.isPendingScan) {
4251
return new Response(
4352
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
44-
{ status: 423 },
53+
{
54+
status: 423,
55+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
56+
},
4557
)
4658
}
4759
if (mod?.isRemoved) {
48-
return new Response('This skill has been removed by a moderator.', { status: 410 })
60+
return new Response('This skill has been removed by a moderator.', {
61+
status: 410,
62+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
63+
})
4964
}
5065
if (mod?.isHiddenByMod) {
51-
return new Response('This skill is currently unavailable.', { status: 403 })
66+
return new Response('This skill is currently unavailable.', {
67+
status: 403,
68+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
69+
})
5270
}
5371

5472
const skill = skillResult.skill
@@ -67,10 +85,16 @@ export const downloadZip = httpAction(async (ctx, request) => {
6785
}
6886

6987
if (!version) {
70-
return new Response('Version not found', { status: 404 })
88+
return new Response('Version not found', {
89+
status: 404,
90+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
91+
})
7192
}
7293
if (version.softDeletedAt) {
73-
return new Response('Version not available', { status: 410 })
94+
return new Response('Version not available', {
95+
status: 410,
96+
headers: mergeHeaders(rate.headers, { 'Access-Control-Allow-Origin': '*' }),
97+
})
7498
}
7599

76100
const entries: Array<{ path: string; bytes: Uint8Array }> = []
@@ -108,6 +132,7 @@ export const downloadZip = httpAction(async (ctx, request) => {
108132
'Content-Type': 'application/zip',
109133
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
110134
'Cache-Control': 'private, max-age=60',
135+
'Access-Control-Allow-Origin': '*',
111136
}),
112137
})
113138
})

convex/http.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
usersListV1Http,
3232
usersPostRouterV1Http,
3333
whoamiV1Http,
34+
preflightHandler,
3435
} from './httpApiV1'
3536

3637
const http = httpRouter()
@@ -145,6 +146,12 @@ http.route({
145146
handler: soulsDeleteRouterV1Http,
146147
})
147148

149+
http.route({
150+
pathPrefix: '/api/',
151+
method: 'OPTIONS',
152+
handler: preflightHandler,
153+
})
154+
148155
// TODO: remove legacy /api routes after deprecation window.
149156
http.route({
150157
path: LegacyApiRoutes.download,

convex/httpApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ function json(value: unknown, status = 200) {
244244
headers: {
245245
'Content-Type': 'application/json',
246246
'Cache-Control': 'no-store',
247+
'Access-Control-Allow-Origin': '*',
247248
},
248249
})
249250
}
@@ -254,6 +255,7 @@ function text(value: string, status: number) {
254255
headers: {
255256
'Content-Type': 'text/plain; charset=utf-8',
256257
'Cache-Control': 'no-store',
258+
'Access-Control-Allow-Origin': '*',
257259
},
258260
})
259261
}

convex/httpApiV1.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
411411
// reading localStorage tokens on this origin.
412412
'Content-Security-Policy':
413413
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
414+
'Access-Control-Allow-Origin': '*',
414415
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
415416
})
416417
return new Response(textContent, { status: 200, headers })
@@ -502,6 +503,35 @@ async function publishSkillV1Handler(ctx: ActionCtx, request: Request) {
502503

503504
export const publishSkillV1Http = httpAction(publishSkillV1Handler)
504505

506+
export const preflightHandler = httpAction(async (_ctx, request) => {
507+
const requestedHeaders =
508+
request.headers.get('access-control-request-headers')?.trim() ||
509+
request.headers.get('Access-Control-Request-Headers')?.trim() ||
510+
null
511+
const requestedMethod =
512+
request.headers.get('access-control-request-method')?.trim() ||
513+
request.headers.get('Access-Control-Request-Method')?.trim() ||
514+
null
515+
const vary = [
516+
...(requestedMethod ? ['Access-Control-Request-Method'] : []),
517+
...(requestedHeaders ? ['Access-Control-Request-Headers'] : []),
518+
].join(', ')
519+
520+
// No cookies/credentials supported; allow any origin for simple browser access.
521+
// If we ever add cookie auth, this must switch to reflecting origin + Allow-Credentials.
522+
return new Response(null, {
523+
status: 204,
524+
headers: {
525+
'Access-Control-Allow-Origin': '*',
526+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
527+
'Access-Control-Allow-Headers':
528+
requestedHeaders ?? 'Content-Type, Authorization, Digest, X-Clawhub-Version',
529+
'Access-Control-Max-Age': '86400',
530+
...(vary ? { Vary: vary } : {}),
531+
},
532+
})
533+
})
534+
505535
type FileLike = {
506536
name: string
507537
size: number
@@ -968,6 +998,7 @@ function json(value: unknown, status = 200, headers?: HeadersInit) {
968998
{
969999
'Content-Type': 'application/json',
9701000
'Cache-Control': 'no-store',
1001+
'Access-Control-Allow-Origin': '*',
9711002
},
9721003
headers,
9731004
),
@@ -981,6 +1012,7 @@ function text(value: string, status: number, headers?: HeadersInit) {
9811012
{
9821013
'Content-Type': 'text/plain; charset=utf-8',
9831014
'Cache-Control': 'no-store',
1015+
'Access-Control-Allow-Origin': '*',
9841016
},
9851017
headers,
9861018
),
@@ -1275,6 +1307,7 @@ async function soulsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
12751307
// reading localStorage tokens on this origin.
12761308
'Content-Security-Policy':
12771309
"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
1310+
'Access-Control-Allow-Origin': '*',
12781311
...(isSvg ? { 'Content-Disposition': 'attachment' } : {}),
12791312
})
12801313
return new Response(textContent, { status: 200, headers })

convex/lib/httpRateLimit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export async function applyRateLimit(
4040
{
4141
'Content-Type': 'text/plain; charset=utf-8',
4242
'Cache-Control': 'no-store',
43+
'Access-Control-Allow-Origin': '*',
4344
},
4445
headers,
4546
),

packages/clawdhub/src/cli/commands/inspect.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
1616
getRegistry: () => mockGetRegistry(),
1717
}))
1818

19+
const mockReadGlobalConfig = vi.fn(async () => null as { token?: string } | null)
20+
vi.mock('../../config.js', () => ({
21+
readGlobalConfig: () => mockReadGlobalConfig(),
22+
}))
23+
1924
const mockSpinner = {
2025
stop: vi.fn(),
2126
fail: vi.fn(),

packages/clawdhub/src/cli/commands/inspect.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ApiV1SkillVersionListResponseSchema,
66
ApiV1SkillVersionResponseSchema,
77
} from '../../schema/index.js'
8+
import { readGlobalConfig } from '../../config.js'
89
import { getRegistry } from '../registry.js'
910
import type { GlobalOpts } from '../types.js'
1011
import { createSpinner, fail, formatError } from '../ui.js'
@@ -31,12 +32,15 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
3132
if (!trimmed) fail('Slug required')
3233
if (options.version && options.tag) fail('Use either --version or --tag')
3334

35+
const cfg = await readGlobalConfig()
36+
const token = cfg?.token ?? undefined
37+
3438
const registry = await getRegistry(opts, { cache: true })
3539
const spinner = createSpinner('Fetching skill')
3640
try {
3741
const skillResult = await apiRequest(
3842
registry,
39-
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
43+
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}`, token },
4044
ApiV1SkillResponseSchema,
4145
)
4246

@@ -67,6 +71,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
6771
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
6872
targetVersion,
6973
)}`,
74+
token,
7075
},
7176
ApiV1SkillVersionResponseSchema,
7277
)
@@ -80,7 +85,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
8085
spinner.text = `Fetching versions (${limit})`
8186
versionsList = await apiRequest(
8287
registry,
83-
{ method: 'GET', url: url.toString() },
88+
{ method: 'GET', url: url.toString(), token },
8489
ApiV1SkillVersionListResponseSchema,
8590
)
8691
}
@@ -97,7 +102,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec
97102
url.searchParams.set('version', latestVersion)
98103
}
99104
spinner.text = `Fetching ${options.file}`
100-
fileContent = await fetchText(registry, { url: url.toString() })
105+
fileContent = await fetchText(registry, { url: url.toString(), token })
101106
}
102107

103108
spinner.stop()

packages/clawdhub/src/cli/commands/skills.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ vi.mock('../registry.js', () => ({
1616
getRegistry: () => mockGetRegistry(),
1717
}))
1818

19+
const mockReadGlobalConfig = vi.fn(async () => null as { token?: string } | null)
20+
vi.mock('../../config.js', () => ({
21+
readGlobalConfig: () => mockReadGlobalConfig(),
22+
}))
23+
1924
const mockSpinner = {
2025
stop: vi.fn(),
2126
fail: vi.fn(),
@@ -50,7 +55,7 @@ vi.mock('node:fs/promises', () => ({
5055
stat: vi.fn(),
5156
}))
5257

53-
const { clampLimit, cmdExplore, cmdUpdate, formatExploreLine } = await import('./skills')
58+
const { clampLimit, cmdExplore, cmdInstall, cmdUpdate, formatExploreLine } = await import('./skills')
5459
const {
5560
extractZipToDir,
5661
hashSkillFiles,
@@ -189,3 +194,29 @@ describe('cmdUpdate', () => {
189194
expect(args?.url).toBeUndefined()
190195
})
191196
})
197+
198+
describe('cmdInstall', () => {
199+
it('passes optional auth token to API + download requests', async () => {
200+
mockReadGlobalConfig.mockResolvedValue({ token: 'tkn' })
201+
mockApiRequest.mockResolvedValue({
202+
skill: { slug: 'demo', displayName: 'Demo', summary: null, tags: {}, stats: {}, createdAt: 0, updatedAt: 0 },
203+
latestVersion: { version: '1.0.0' },
204+
owner: null,
205+
moderation: null,
206+
})
207+
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]))
208+
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} })
209+
vi.mocked(writeLockfile).mockResolvedValue()
210+
vi.mocked(writeSkillOrigin).mockResolvedValue()
211+
vi.mocked(extractZipToDir).mockResolvedValue()
212+
vi.mocked(stat).mockRejectedValue(new Error('missing'))
213+
vi.mocked(rm).mockResolvedValue()
214+
215+
await cmdInstall(makeOpts(), 'demo')
216+
217+
const [, requestArgs] = mockApiRequest.mock.calls[0] ?? []
218+
expect(requestArgs?.token).toBe('tkn')
219+
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? []
220+
expect(zipArgs?.token).toBe('tkn')
221+
})
222+
})

0 commit comments

Comments
 (0)