import { z } from 'zod'; import { APIResponseSchema, APIClient } from '@agentuity/api'; import { RegionResponseError } from './util.ts'; /** * Database name validation regex - must match catalyst server validation: * - Must start with a letter or underscore * - Can contain only lowercase letters, digits, and underscores * - Must be lowercase */ const DATABASE_NAME_REGEX = /^[a-z_][a-z0-9_]*$/; const MAX_DATABASE_NAME_LENGTH = 63; const MIN_DATABASE_NAME_LENGTH = 1; /** * S3 bucket name validation - must match catalyst server validation (AWS S3 rules): * - Length [3, 63] * - Only lowercase, numbers, dots, hyphens * - No adjacent periods * - Starts with lowercase or number * - Ends with lowercase or number * - No xn-- prefix * - No -s3alias suffix * - Not an IP address */ const BUCKET_NAME_REGEX = /^[a-z0-9.-]+$/; const MAX_BUCKET_NAME_LENGTH = 63; const MIN_BUCKET_NAME_LENGTH = 3; const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/; /** * Validates a database name for PostgreSQL compatibility. * Matches the server-side validation in catalyst. */ export function validateDatabaseName(name: string): { valid: boolean; error?: string } { if (name.length < MIN_DATABASE_NAME_LENGTH) { return { valid: false, error: 'database name is too short (minimum 1 character)' }; } if (name.length > MAX_DATABASE_NAME_LENGTH) { return { valid: false, error: `database name is too long (maximum ${MAX_DATABASE_NAME_LENGTH} characters)`, }; } if (name !== name.toLowerCase()) { return { valid: false, error: 'database name must be lowercase' }; } if (!DATABASE_NAME_REGEX.test(name)) { return { valid: false, error: 'database name must start with a letter or underscore and contain only lowercase letters, digits, and underscores', }; } if (name.startsWith('pg_')) { return { valid: false, error: "database name cannot start with 'pg_' (reserved by PostgreSQL)", }; } return { valid: true }; } /** * Validates an S3 bucket name according to AWS S3 naming rules. * Matches the server-side validation in catalyst. */ export function validateBucketName(name: string): { valid: boolean; error?: string } { if (name.length < MIN_BUCKET_NAME_LENGTH) { return { valid: false, error: `bucket name is too short (minimum ${MIN_BUCKET_NAME_LENGTH} characters)`, }; } if (name.length > MAX_BUCKET_NAME_LENGTH) { return { valid: false, error: `bucket name is too long (maximum ${MAX_BUCKET_NAME_LENGTH} characters)`, }; } if (!BUCKET_NAME_REGEX.test(name)) { return { valid: false, error: 'bucket name can only contain lowercase letters, numbers, dots, and hyphens', }; } if (name.includes('..')) { return { valid: false, error: 'bucket name cannot contain adjacent periods' }; } const firstChar = name[0]; if ( firstChar === undefined || !((firstChar >= 'a' && firstChar <= 'z') || (firstChar >= '0' && firstChar <= '9')) ) { return { valid: false, error: 'bucket name must start with a lowercase letter or number' }; } const lastChar = name[name.length - 1]; if ( lastChar === undefined || !((lastChar >= 'a' && lastChar <= 'z') || (lastChar >= '0' && lastChar <= '9')) ) { return { valid: false, error: 'bucket name must end with a lowercase letter or number' }; } if (name.startsWith('xn--')) { return { valid: false, error: 'bucket name cannot start with xn--' }; } if (name.endsWith('-s3alias')) { return { valid: false, error: 'bucket name cannot end with -s3alias' }; } if (isIPv4Address(name)) { return { valid: false, error: 'bucket name cannot be an IP address' }; } // Reserved prefixes (system-generated names) if (name.startsWith('ag-') || name.startsWith('ago-') || name.startsWith('agentuity')) { return { valid: false, error: 'bucket names starting with "ag-", "ago-", or "agentuity" are reserved for system use', }; } return { valid: true }; } function isIPv4Address(s: string): boolean { if (!IPV4_REGEX.test(s)) { return false; } const parts = s.split('.'); for (const part of parts) { const num = parseInt(part, 10); if (Number.isNaN(num) || num < 0 || num > 255) { return false; } } return true; } const MAX_NAMESPACE_NAME_LENGTH = 64; const MIN_NAMESPACE_NAME_LENGTH = 1; /** * Validates a key-value namespace name. * Matches the HTTP KV API path parameter limit in catalyst. */ export function validateNamespaceName(name: string): { valid: boolean; error?: string } { if (name.length < MIN_NAMESPACE_NAME_LENGTH) { return { valid: false, error: 'namespace name is too short (minimum 1 character)' }; } if (name.length > MAX_NAMESPACE_NAME_LENGTH) { return { valid: false, error: `namespace name is too long (maximum ${MAX_NAMESPACE_NAME_LENGTH} characters)`, }; } return { valid: true }; } export const ResourceSpec = z.object({ type: z.enum(['db', 's3', 'kv']).describe('the resource type'), name: z.string().optional().describe('optional custom name for db, s3, or kv'), description: z.string().optional().describe('optional description for db or s3'), }); export const CreateResourcesRequest = z.object({ resources: z.array(ResourceSpec).describe('list of resources to create'), }); export const CreatedResource = z.object({ type: z.string().describe('the resource type'), name: z.string().describe('the resource name'), env: z.record(z.string(), z.string()).describe('environment variables for the resource'), }); export const CreateResourcesResponse = z.object({ created: z.array(CreatedResource), }); export const CreateResourcesResponseSchema = APIResponseSchema(CreateResourcesResponse); export type CreateResourcesRequest = z.infer; export type CreateResourcesResponse = z.infer; export type CreatedResource = z.infer; /** * Create one or more resources (DB, S3, or KV) for an organization in a specific region * Requires CLI authentication * * @param client - Catalyst API client * @param orgId - Organization ID * @param region - Cloud region * @param resources - Array of resources to create * @returns */ export async function createResources( client: APIClient, orgId: string, region: string, resources: Array<{ type: 'db' | 's3' | 'kv'; name?: string; description?: string }> ): Promise { // Validate resource names before sending to server for (const resource of resources) { if (resource.type === 'db' && resource.name) { const validation = validateDatabaseName(resource.name); if (!validation.valid) { throw new RegionResponseError({ message: validation.error! }); } } if (resource.type === 's3' && resource.name) { const validation = validateBucketName(resource.name); if (!validation.valid) { throw new RegionResponseError({ message: validation.error! }); } } if (resource.type === 'kv' && resource.name) { const validation = validateNamespaceName(resource.name); if (!validation.valid) { throw new RegionResponseError({ message: validation.error! }); } } } const resp = await client.request( 'POST', `/resource/${orgId}/${region}`, CreateResourcesResponseSchema, { resources }, CreateResourcesRequest ); if (resp.success) { return resp.data.created; } throw new RegionResponseError({ message: resp.message, }); }