diff --git a/functions/api/download/[type]/[provider]/[id].js b/functions/api/download/[type]/[provider]/[id].js index 94f7ddcec..3d584347e 100644 --- a/functions/api/download/[type]/[provider]/[id].js +++ b/functions/api/download/[type]/[provider]/[id].js @@ -1,19 +1,10 @@ +import { + FILE_DOWNLOAD_PROVIDERS, + FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS +} from "../../../../../lib/download-providers.js"; + const VALID_ID = /^[a-zA-Z0-9_-]+$/; -const ALLOWED_PROVIDERS = [ - 'cursor', 'claude-code', 'gemini', 'codex', 'agents', 'kiro', - 'universal', 'universal-prefixed', -]; - -const PROVIDER_CONFIG_DIRS = { - 'cursor': '.cursor', - 'claude-code': '.claude', - 'gemini': '.gemini', - 'codex': '.codex', - 'agents': '.agents', - 'kiro': '.kiro', -}; - export async function onRequestGet(context) { const { type, provider, id } = context.params; @@ -21,7 +12,7 @@ export async function onRequestGet(context) { return Response.json({ error: "Invalid type" }, { status: 400 }); } - if (!provider || !ALLOWED_PROVIDERS.includes(provider)) { + if (!provider || !FILE_DOWNLOAD_PROVIDERS.includes(provider)) { return Response.json({ error: "Invalid provider" }, { status: 400 }); } @@ -29,7 +20,7 @@ export async function onRequestGet(context) { return Response.json({ error: "Invalid file ID" }, { status: 400 }); } - const configDir = PROVIDER_CONFIG_DIRS[provider]; + const configDir = FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS[provider]; if (!configDir) { return Response.json({ error: "Invalid provider" }, { status: 400 }); } diff --git a/functions/api/download/bundle/[provider].js b/functions/api/download/bundle/[provider].js index 707c702d6..1b444ca37 100644 --- a/functions/api/download/bundle/[provider].js +++ b/functions/api/download/bundle/[provider].js @@ -1,9 +1,9 @@ -const ALLOWED_PROVIDERS = ['universal', 'universal-prefixed']; +import { BUNDLE_DOWNLOAD_PROVIDERS } from "../../../../lib/download-providers.js"; export async function onRequestGet(context) { const { provider } = context.params; - if (!provider || !ALLOWED_PROVIDERS.includes(provider)) { + if (!provider || !BUNDLE_DOWNLOAD_PROVIDERS.includes(provider)) { return Response.json({ error: "Invalid provider" }, { status: 400 }); } diff --git a/lib/download-providers.js b/lib/download-providers.js new file mode 100644 index 000000000..b7f6917cd --- /dev/null +++ b/lib/download-providers.js @@ -0,0 +1,24 @@ +export const FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS = Object.freeze({ + cursor: '.cursor', + 'claude-code': '.claude', + gemini: '.gemini', + codex: '.codex', + agents: '.agents', + kiro: '.kiro', + opencode: '.opencode', + pi: '.pi', +}); + +export const FILE_DOWNLOAD_PROVIDERS = Object.freeze( + Object.keys(FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS) +); + +export const BUNDLE_DOWNLOAD_PROVIDERS = Object.freeze([ + 'universal', + 'universal-prefixed', +]); + +export const DOWNLOAD_PROVIDERS = Object.freeze([ + ...FILE_DOWNLOAD_PROVIDERS, + ...BUNDLE_DOWNLOAD_PROVIDERS, +]); diff --git a/server/lib/api-handlers.js b/server/lib/api-handlers.js index c6097cec6..288fdbc5a 100644 --- a/server/lib/api-handlers.js +++ b/server/lib/api-handlers.js @@ -3,7 +3,14 @@ import { basename, join, dirname } from "path"; import { existsSync } from "fs"; import { fileURLToPath } from "url"; import { readPatterns, parseFrontmatter } from "../../scripts/lib/utils.js"; -import { isValidId, isAllowedProvider, isAllowedType, sanitizeFilename } from "./validation.js"; +import { FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS } from "../../lib/download-providers.js"; +import { + isAllowedBundleProvider, + isAllowedFileProvider, + isAllowedType, + isValidId, + sanitizeFilename +} from "./validation.js"; // Get project root directory (works in both Node.js and Bun, including Vercel) const __filename = fileURLToPath(import.meta.url); @@ -69,18 +76,7 @@ export async function getCommandSource(id) { // Get the appropriate file path for a provider export function getFilePath(type, provider, id) { const distDir = join(PROJECT_ROOT, "dist"); - - // Provider config directory mapping - const providerPaths = { - 'cursor': '.cursor', - 'claude-code': '.claude', - 'gemini': '.gemini', - 'codex': '.codex', - 'agents': '.agents', - 'kiro': '.kiro', - }; - - const configDir = providerPaths[provider]; + const configDir = FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS[provider]; if (!configDir) return null; // Everything is a skill now @@ -97,7 +93,7 @@ export async function handleFileDownload(type, provider, id) { return new Response("Invalid type", { status: 400 }); } - if (!isAllowedProvider(provider)) { + if (!isAllowedFileProvider(provider)) { return new Response("Invalid provider", { status: 400 }); } @@ -142,7 +138,7 @@ export async function getPatterns() { // Handle bundle download export async function handleBundleDownload(provider) { - if (!isAllowedProvider(provider)) { + if (!isAllowedBundleProvider(provider)) { return new Response("Invalid provider", { status: 400 }); } diff --git a/server/lib/validation.js b/server/lib/validation.js index 0dfa6f0b4..b2a1f9285 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -1,12 +1,17 @@ // Shared validation helpers for input sanitization +import { + BUNDLE_DOWNLOAD_PROVIDERS, + DOWNLOAD_PROVIDERS, + FILE_DOWNLOAD_PROVIDERS, +} from '../../lib/download-providers.js'; + // Only allow alphanumeric, hyphens, and underscores in IDs export const VALID_ID = /^[a-zA-Z0-9_-]+$/; -export const ALLOWED_PROVIDERS = [ - 'cursor', 'claude-code', 'gemini', 'codex', 'agents', 'kiro', - 'universal', 'universal-prefixed', -]; +export const ALLOWED_PROVIDERS = DOWNLOAD_PROVIDERS; +export const ALLOWED_FILE_PROVIDERS = FILE_DOWNLOAD_PROVIDERS; +export const ALLOWED_BUNDLE_PROVIDERS = BUNDLE_DOWNLOAD_PROVIDERS; export const ALLOWED_TYPES = ['skill', 'command']; export function isValidId(id) { @@ -17,6 +22,14 @@ export function isAllowedProvider(provider) { return ALLOWED_PROVIDERS.includes(provider); } +export function isAllowedFileProvider(provider) { + return ALLOWED_FILE_PROVIDERS.includes(provider); +} + +export function isAllowedBundleProvider(provider) { + return ALLOWED_BUNDLE_PROVIDERS.includes(provider); +} + export function isAllowedType(type) { return ALLOWED_TYPES.includes(type); } diff --git a/tests/server/download-validation.test.js b/tests/server/download-validation.test.js new file mode 100644 index 000000000..c6d0b2478 --- /dev/null +++ b/tests/server/download-validation.test.js @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'path'; +import { + ALLOWED_BUNDLE_PROVIDERS, + ALLOWED_FILE_PROVIDERS, + isAllowedBundleProvider, + isAllowedFileProvider, + isAllowedProvider, +} from '../../server/lib/validation.js'; +import { getFilePath, handleFileDownload } from '../../server/lib/api-handlers.js'; + +describe('download provider validation', () => { + test('allows opencode and pi as individual download providers', () => { + expect(ALLOWED_FILE_PROVIDERS).toContain('opencode'); + expect(ALLOWED_FILE_PROVIDERS).toContain('pi'); + expect(isAllowedFileProvider('opencode')).toBe(true); + expect(isAllowedFileProvider('pi')).toBe(true); + }); + + test('separates file downloads from bundle downloads', () => { + expect(ALLOWED_BUNDLE_PROVIDERS).toContain('universal'); + expect(ALLOWED_BUNDLE_PROVIDERS).toContain('universal-prefixed'); + expect(isAllowedBundleProvider('universal')).toBe(true); + expect(isAllowedProvider('universal')).toBe(true); + expect(isAllowedFileProvider('universal')).toBe(false); + }); +}); + +describe('download file paths', () => { + test('maps opencode skills into the .opencode config directory', () => { + expect(getFilePath('skill', 'opencode', 'frontend-design')).toBe( + path.join(process.cwd(), 'dist', 'opencode', '.opencode', 'skills', 'frontend-design', 'SKILL.md') + ); + }); + + test('maps pi commands into the .pi config directory', () => { + expect(getFilePath('command', 'pi', 'audit')).toBe( + path.join(process.cwd(), 'dist', 'pi', '.pi', 'skills', 'audit', 'SKILL.md') + ); + }); + + test('rejects bundle-only providers on the individual download route', async () => { + const response = await handleFileDownload('skill', 'universal', 'frontend-design'); + expect(response.status).toBe(400); + }); +});