feat(cors): implement CORS management commands and utilities

This commit is contained in:
Space-Banane
2026-04-14 22:38:27 +02:00
parent c912c791fa
commit 544f6bd714
10 changed files with 300 additions and 3 deletions
+2 -1
View File
@@ -2,4 +2,5 @@ node_modules
dist
build
pnpm-lock.yaml
testing
testing
.codex
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "shsf-cli",
"version": "2.2.10",
"version": "2.3.0",
"description": "",
"type": "module",
"files": [
+1 -1
View File
@@ -48,5 +48,5 @@ describe('Command Loading', () => {
console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`);
}
});
}, 20000);
});
+30
View File
@@ -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");
});
});
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import {
getFunctionCorsOrigins,
handleCorsCommandError,
resolveFunctionId,
setFunctionCorsOrigins,
validateSingleOrigin,
} from "../../utils/cors_commands.js";
export const addCorsDefinition = {
name: "add <origin>",
description: "Add a CORS allowlist origin (must start with http:// or https://).",
options: [{ name: "--id <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");
}
},
};
+30
View File
@@ -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 <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");
}
},
};
+27
View File
@@ -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 <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");
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import {
getFunctionCorsOrigins,
handleCorsCommandError,
resolveFunctionId,
setFunctionCorsOrigins,
validateSingleOrigin,
} from "../../utils/cors_commands.js";
export const removeCorsDefinition = {
name: "remove <origin>",
description: "Remove a CORS allowlist origin (must start with http:// or https://).",
options: [{ name: "--id <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");
}
},
};
+42
View File
@@ -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<string>();
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 };
}
+85
View File
@@ -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 <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<string[]> {
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<void> {
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}`);
}