Files
openwork/apps/server/src/validators.ts
T

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");
}
}
}