90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
import { ApiError } from "./errors.js";
|
|
|
|
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
|
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
|
const RESERVED_USER_MCP_NAMES = new Set(["openwork-cloud"]);
|
|
const RESERVED_USER_MCP_PREFIXES = ["openwork-connect-"];
|
|
|
|
export function validateSkillName(name: string): void {
|
|
if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
|
|
throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
|
|
}
|
|
}
|
|
|
|
export function validateDescription(description: string | undefined): void {
|
|
if (!description || description.length < 1 || description.length > 1024) {
|
|
throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
|
|
}
|
|
}
|
|
|
|
export function validatePluginSpec(spec: string): void {
|
|
if (!spec || spec.trim().length === 0) {
|
|
throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
|
|
}
|
|
}
|
|
|
|
export function sanitizeCommandName(name: string): string {
|
|
const trimmed = name.trim().replace(/^\/+/, "");
|
|
return trimmed;
|
|
}
|
|
|
|
export function validateCommandName(name: string): void {
|
|
if (!name || !COMMAND_NAME_REGEX.test(name)) {
|
|
throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
|
|
}
|
|
}
|
|
|
|
export function validateMcpName(name: string): void {
|
|
if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
|
|
throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
|
|
}
|
|
}
|
|
|
|
export function validateUserMcpName(name: string): void {
|
|
validateMcpName(name);
|
|
if (RESERVED_USER_MCP_NAMES.has(name.toLowerCase())
|
|
|| RESERVED_USER_MCP_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) {
|
|
throw new ApiError(409, "reserved_mcp_name", `${name} is reserved for OpenWork Connect`);
|
|
}
|
|
}
|
|
|
|
export function validateMcpConfig(config: Record<string, unknown>): void {
|
|
const type = config.type;
|
|
if (type !== "local" && type !== "remote") {
|
|
throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
|
|
}
|
|
if (type === "local") {
|
|
const command = config.command;
|
|
if (
|
|
!Array.isArray(command) ||
|
|
command.length === 0 ||
|
|
command.some((part) => typeof part !== "string" || part.trim().length === 0)
|
|
) {
|
|
throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
|
|
}
|
|
}
|
|
if (type === "remote") {
|
|
const url = config.url;
|
|
if (!url || typeof url !== "string" || url.trim().length === 0) {
|
|
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
|
|
}
|
|
const normalizedUrl = url.trim();
|
|
if (url !== normalizedUrl) {
|
|
throw new ApiError(400, "invalid_mcp_config", "Remote MCP url must not include surrounding whitespace");
|
|
}
|
|
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
|
throw new ApiError(400, "invalid_mcp_config", "Remote MCP url must start with http(s)://");
|
|
}
|
|
try {
|
|
const parsed = new URL(normalizedUrl);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
throw new ApiError(400, "invalid_mcp_config", "Remote MCP url must use http(s)");
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiError) throw error;
|
|
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires a valid url");
|
|
}
|
|
}
|
|
}
|