From 544f6bd7141c72c37beb77f4fcd7d37129833a36 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Tue, 14 Apr 2026 22:38:27 +0200 Subject: [PATCH] feat(cors): implement CORS management commands and utilities --- .gitignore | 3 +- package.json | 2 +- src/__tests__/commands.test.ts | 2 +- src/__tests__/cors.test.ts | 30 ++++++++++++ src/commands/cors/add.ts | 41 ++++++++++++++++ src/commands/cors/clear.ts | 30 ++++++++++++ src/commands/cors/list.ts | 27 +++++++++++ src/commands/cors/remove.ts | 41 ++++++++++++++++ src/utils/cors.ts | 42 +++++++++++++++++ src/utils/cors_commands.ts | 85 ++++++++++++++++++++++++++++++++++ 10 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/cors.test.ts create mode 100644 src/commands/cors/add.ts create mode 100644 src/commands/cors/clear.ts create mode 100644 src/commands/cors/list.ts create mode 100644 src/commands/cors/remove.ts create mode 100644 src/utils/cors.ts create mode 100644 src/utils/cors_commands.ts diff --git a/.gitignore b/.gitignore index 7e93b2d..5731ed9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules dist build pnpm-lock.yaml -testing \ No newline at end of file +testing +.codex \ No newline at end of file diff --git a/package.json b/package.json index ed4f0ab..e5e247a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shsf-cli", - "version": "2.2.10", + "version": "2.3.0", "description": "", "type": "module", "files": [ diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index 5f22669..b9c897a 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -48,5 +48,5 @@ describe('Command Loading', () => { console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`); } - }); + }, 20000); }); diff --git a/src/__tests__/cors.test.ts b/src/__tests__/cors.test.ts new file mode 100644 index 0000000..5ec8b91 --- /dev/null +++ b/src/__tests__/cors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseCorsOriginsOption } from "../utils/cors.js"; + +describe("parseCorsOriginsOption", () => { + it("returns undefined when no value is provided", () => { + expect(parseCorsOriginsOption(undefined)).toEqual({}); + }); + + it("parses, normalizes, and deduplicates URLs", () => { + const parsed = parseCorsOriginsOption([ + "https://example.com", + "https://example.com/path", + "http://localhost:3000,http://localhost:3000/", + ]); + + expect(parsed).toEqual({ + corsOrigins: ["https://example.com", "http://localhost:3000"], + }); + }); + + it("returns an error for invalid URLs", () => { + const parsed = parseCorsOriginsOption(["not-a-url"]); + expect(parsed.error).toMatch("Invalid CORS origin URL"); + }); + + it("returns an error for unsupported protocols", () => { + const parsed = parseCorsOriginsOption(["ftp://example.com"]); + expect(parsed.error).toMatch("Only http and https are allowed"); + }); +}); diff --git a/src/commands/cors/add.ts b/src/commands/cors/add.ts new file mode 100644 index 0000000..940dc14 --- /dev/null +++ b/src/commands/cors/add.ts @@ -0,0 +1,41 @@ +import chalk from "chalk"; +import { + getFunctionCorsOrigins, + handleCorsCommandError, + resolveFunctionId, + setFunctionCorsOrigins, + validateSingleOrigin, +} from "../../utils/cors_commands.js"; + +export const addCorsDefinition = { + name: "add ", + description: "Add a CORS allowlist origin (must start with http:// or https://).", + options: [{ name: "--id ", description: "Function ID (falls back to .shsf.json default id)" }], + action: async (origin: string, options: { id?: string }) => { + const functionId = resolveFunctionId(options); + if (!functionId) return; + + const validated = validateSingleOrigin(origin); + if (validated.error) { + console.error(`${chalk.red("✗")} ${validated.error}`); + return; + } + + try { + const currentOrigins = await getFunctionCorsOrigins(functionId); + const normalizedOrigin = validated.origin as string; + + if (currentOrigins.includes(normalizedOrigin)) { + console.log(`${chalk.yellow("!")} Origin ${chalk.cyan(normalizedOrigin)} already exists for function ${chalk.cyan(functionId)}.`); + return; + } + + const updatedOrigins = [...currentOrigins, normalizedOrigin]; + await setFunctionCorsOrigins(functionId, updatedOrigins); + + console.log(`${chalk.green("✓")} Added CORS origin ${chalk.cyan(normalizedOrigin)} to function ${chalk.cyan(functionId)}.`); + } catch (error: any) { + handleCorsCommandError(error, "add CORS origin"); + } + }, +}; diff --git a/src/commands/cors/clear.ts b/src/commands/cors/clear.ts new file mode 100644 index 0000000..c0b9ccf --- /dev/null +++ b/src/commands/cors/clear.ts @@ -0,0 +1,30 @@ +import chalk from "chalk"; +import { + getFunctionCorsOrigins, + handleCorsCommandError, + resolveFunctionId, + setFunctionCorsOrigins, +} from "../../utils/cors_commands.js"; + +export const clearCorsDefinition = { + name: "clear", + description: "Clear all CORS allowlist origins for a function.", + options: [{ name: "--id ", description: "Function ID (falls back to .shsf.json default id)" }], + action: async (options: { id?: string }) => { + const functionId = resolveFunctionId(options); + if (!functionId) return; + + try { + const currentOrigins = await getFunctionCorsOrigins(functionId); + if (currentOrigins.length === 0) { + console.log(`${chalk.yellow("!")} CORS allowlist is already empty for function ${chalk.cyan(functionId)}.`); + return; + } + + await setFunctionCorsOrigins(functionId, []); + console.log(`${chalk.green("✓")} Cleared all CORS origins for function ${chalk.cyan(functionId)}.`); + } catch (error: any) { + handleCorsCommandError(error, "clear CORS origins"); + } + }, +}; diff --git a/src/commands/cors/list.ts b/src/commands/cors/list.ts new file mode 100644 index 0000000..9439673 --- /dev/null +++ b/src/commands/cors/list.ts @@ -0,0 +1,27 @@ +import chalk from "chalk"; +import { getFunctionCorsOrigins, handleCorsCommandError, resolveFunctionId } from "../../utils/cors_commands.js"; + +export const listCorsDefinition = { + name: "list", + description: "List CORS allowlist origins for a function.", + options: [{ name: "--id ", description: "Function ID (falls back to .shsf.json default id)" }], + action: async (options: { id?: string }) => { + const functionId = resolveFunctionId(options); + if (!functionId) return; + + try { + const origins = await getFunctionCorsOrigins(functionId); + if (origins.length === 0) { + console.log(`${chalk.yellow("!")} No CORS allowlist origins configured for function ${chalk.cyan(functionId)}.`); + return; + } + + console.log(`${chalk.blue("CORS Origins")} for function ${chalk.cyan(functionId)}:`); + origins.forEach((origin) => { + console.log(`- ${chalk.cyan(origin)}`); + }); + } catch (error: any) { + handleCorsCommandError(error, "list CORS origins"); + } + }, +}; diff --git a/src/commands/cors/remove.ts b/src/commands/cors/remove.ts new file mode 100644 index 0000000..d71ca21 --- /dev/null +++ b/src/commands/cors/remove.ts @@ -0,0 +1,41 @@ +import chalk from "chalk"; +import { + getFunctionCorsOrigins, + handleCorsCommandError, + resolveFunctionId, + setFunctionCorsOrigins, + validateSingleOrigin, +} from "../../utils/cors_commands.js"; + +export const removeCorsDefinition = { + name: "remove ", + description: "Remove a CORS allowlist origin (must start with http:// or https://).", + options: [{ name: "--id ", description: "Function ID (falls back to .shsf.json default id)" }], + action: async (origin: string, options: { id?: string }) => { + const functionId = resolveFunctionId(options); + if (!functionId) return; + + const validated = validateSingleOrigin(origin); + if (validated.error) { + console.error(`${chalk.red("✗")} ${validated.error}`); + return; + } + + try { + const currentOrigins = await getFunctionCorsOrigins(functionId); + const normalizedOrigin = validated.origin as string; + + if (!currentOrigins.includes(normalizedOrigin)) { + console.log(`${chalk.yellow("!")} Origin ${chalk.cyan(normalizedOrigin)} is not set for function ${chalk.cyan(functionId)}.`); + return; + } + + const updatedOrigins = currentOrigins.filter((existingOrigin) => existingOrigin !== normalizedOrigin); + await setFunctionCorsOrigins(functionId, updatedOrigins); + + console.log(`${chalk.green("✓")} Removed CORS origin ${chalk.cyan(normalizedOrigin)} from function ${chalk.cyan(functionId)}.`); + } catch (error: any) { + handleCorsCommandError(error, "remove CORS origin"); + } + }, +}; diff --git a/src/utils/cors.ts b/src/utils/cors.ts new file mode 100644 index 0000000..8e59073 --- /dev/null +++ b/src/utils/cors.ts @@ -0,0 +1,42 @@ +export function parseCorsOriginsOption(rawValue: unknown): { corsOrigins?: string[]; error?: string } { + if (rawValue === undefined || rawValue === null) { + return {}; + } + + const tokens = Array.isArray(rawValue) ? rawValue : [rawValue]; + const splitValues = tokens + .map((value) => String(value).trim()) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); + + if (splitValues.length === 0) { + return { error: "No valid values were provided for --cors-origins." }; + } + + const uniqueOrigins: string[] = []; + const seen = new Set(); + + for (const origin of splitValues) { + let parsed: URL; + + try { + parsed = new URL(origin); + } catch { + return { error: `Invalid CORS origin URL: ${origin}` }; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { error: `Invalid CORS origin protocol for ${origin}. Only http and https are allowed.` }; + } + + const normalizedOrigin = parsed.origin; + + if (!seen.has(normalizedOrigin)) { + seen.add(normalizedOrigin); + uniqueOrigins.push(normalizedOrigin); + } + } + + return { corsOrigins: uniqueOrigins }; +} diff --git a/src/utils/cors_commands.ts b/src/utils/cors_commands.ts new file mode 100644 index 0000000..d3641e1 --- /dev/null +++ b/src/utils/cors_commands.ts @@ -0,0 +1,85 @@ +import chalk from "chalk"; +import { getApiClient } from "../api.js"; +import { readMappingFile } from "./push_helpers.js"; +import { parseCorsOriginsOption } from "./cors.js"; + +export function resolveFunctionId(options: { id?: string }): string | null { + if (options.id) { + return options.id; + } + + const mapping = readMappingFile(); + if (mapping?.id) { + console.log(chalk.blue(`Using mapped id ${mapping.id} from .shsf.json`)); + return mapping.id; + } + + console.error( + `${chalk.red("✗")} Function ID is required. Use ${chalk.cyan("--id ")} or provide an ${chalk.cyan(".shsf.json")} mapping with an ${chalk.cyan("id")}.`, + ); + return null; +} + +export function validateSingleOrigin(origin: string): { origin?: string; error?: string } { + const parsed = parseCorsOriginsOption(origin); + if (parsed.error) return { error: parsed.error }; + + const normalized = parsed.corsOrigins?.[0]; + if (!normalized) { + return { error: "No valid origin provided." }; + } + + return { origin: normalized }; +} + +export function parseCorsOriginsString(corsOriginsRaw: unknown): string[] { + if (typeof corsOriginsRaw !== "string") { + return []; + } + + return corsOriginsRaw + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export async function getFunctionCorsOrigins(functionId: string): Promise { + const client = await getApiClient(); + const response = await client.get(`/api/function/${functionId}/cors-origins`); + + const rawValue = + response.data?.cors_origins ?? + response.data?.data?.cors_origins ?? + response.data?.data ?? + ""; + + return parseCorsOriginsString(rawValue); +} + +export async function setFunctionCorsOrigins(functionId: string, origins: string[]): Promise { + const client = await getApiClient(); + await client.patch(`/api/function/${functionId}/cors-origins`, { + cors_origins: origins.join(","), + }); +} + +export function handleCorsCommandError(error: any, action: string) { + if (error.response?.status === 404) { + console.error(`${chalk.red("✗")} Function not found.`); + return; + } + + if (error.response) { + console.error( + `${chalk.red("✗")} Failed to ${action}: ${chalk.yellow(error.response.data?.message || "Unknown error")}`, + ); + return; + } + + if (error.request) { + console.error(`${chalk.red("✗")} No response received from server.`); + return; + } + + console.error(`${chalk.red("✗")} Error: ${error.message}`); +}