diff --git a/apps/processor/src/utils/security.ts b/apps/processor/src/utils/security.ts index 1facc2399..55c5c03ba 100644 --- a/apps/processor/src/utils/security.ts +++ b/apps/processor/src/utils/security.ts @@ -1,4 +1,9 @@ import path from 'path'; +// Import canonical file security utilities from @pagespace/lib +import { + DANGEROUS_MIME_TYPES as LIB_DANGEROUS_MIME_TYPES, + isDangerousMimeType as libIsDangerousMimeType +} from '@pagespace/lib'; export const SAFE_EXTENSION_PATTERN = /^[a-z0-9]{1,8}$/i; export const DEFAULT_EXTENSION = '.bin'; @@ -96,22 +101,7 @@ export function sanitizeFilename(filename: string | null | undefined): string { || 'file'; } -/** - * Dangerous MIME types that can execute JavaScript - */ -export const DANGEROUS_MIME_TYPES = [ - 'text/html', - 'application/xhtml+xml', - 'image/svg+xml', - 'application/xml', - 'text/xml', -] as const; - -/** - * Check if MIME type is dangerous (can execute scripts) - */ -export function isDangerousMimeType(mimeType: string | null | undefined): boolean { - if (!mimeType) return false; - const normalized = mimeType.toLowerCase().split(';')[0].trim(); - return DANGEROUS_MIME_TYPES.includes(normalized as any); -} +// Re-export file security utilities from @pagespace/lib (canonical source) +// These were previously duplicated here but are now consolidated +export const DANGEROUS_MIME_TYPES = LIB_DANGEROUS_MIME_TYPES; +export const isDangerousMimeType = libIsDangerousMimeType; diff --git a/apps/web/src/app/api/account/devices/route.ts b/apps/web/src/app/api/account/devices/route.ts index 5faa20174..182958b70 100644 --- a/apps/web/src/app/api/account/devices/route.ts +++ b/apps/web/src/app/api/account/devices/route.ts @@ -1,5 +1,5 @@ import { users, db, eq, deviceTokens, sql, and, isNull } from '@pagespace/db'; -import { loggers } from '@pagespace/lib/server'; +import { loggers, getClientIP } from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { getUserDeviceTokens, revokeAllUserDeviceTokens, decodeDeviceToken, createDeviceTokenRecord, revokeExpiredDeviceTokens } from '@pagespace/lib/device-auth-utils'; @@ -142,7 +142,7 @@ export async function DELETE(req: Request) { { deviceName: currentDeviceInfo.deviceName || undefined, userAgent: req.headers.get('user-agent') ?? undefined, - ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0] || req.headers.get('x-real-ip') || undefined, + ipAddress: getClientIP(req) !== 'unknown' ? getClientIP(req) : undefined, } ); diff --git a/apps/web/src/app/api/auth/device/refresh/route.ts b/apps/web/src/app/api/auth/device/refresh/route.ts index 1a484c418..899139160 100644 --- a/apps/web/src/app/api/auth/device/refresh/route.ts +++ b/apps/web/src/app/api/auth/device/refresh/route.ts @@ -11,6 +11,7 @@ import { getRefreshTokenMaxAge, generateCSRFToken, getSessionIdFromJWT, + getClientIP, } from '@pagespace/lib/server'; import { createId } from '@paralleldrive/cuid2'; import { loggers, logAuthEvent } from '@pagespace/lib/server'; @@ -35,10 +36,7 @@ export async function POST(req: Request) { const { deviceToken, deviceId, userAgent, appVersion } = validation.data; - const clientIP = - req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const deviceRecord = await validateDeviceToken(deviceToken); if (!deviceRecord) { diff --git a/apps/web/src/app/api/auth/google/callback/route.ts b/apps/web/src/app/api/auth/google/callback/route.ts index 546ca70fd..79368ddf3 100644 --- a/apps/web/src/app/api/auth/google/callback/route.ts +++ b/apps/web/src/app/api/auth/google/callback/route.ts @@ -1,7 +1,7 @@ import { users, refreshTokens } from '@pagespace/db'; import { db, eq, or } from '@pagespace/db'; import { z } from 'zod/v4'; -import { generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, resetRateLimit, RATE_LIMIT_CONFIGS, decodeToken, generateCSRFToken, getSessionIdFromJWT, validateOrCreateDeviceToken } from '@pagespace/lib/server'; +import { generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, resetRateLimit, RATE_LIMIT_CONFIGS, decodeToken, generateCSRFToken, getSessionIdFromJWT, validateOrCreateDeviceToken, getClientIP } from '@pagespace/lib/server'; import { serialize } from 'cookie'; import { createId } from '@paralleldrive/cuid2'; import { loggers, logAuthEvent } from '@pagespace/lib/server'; @@ -94,9 +94,7 @@ export async function GET(req: Request) { } // Rate limiting by IP address - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); if (!ipRateLimit.allowed) { diff --git a/apps/web/src/app/api/auth/google/signin/route.ts b/apps/web/src/app/api/auth/google/signin/route.ts index ebadc48af..01891ac9b 100644 --- a/apps/web/src/app/api/auth/google/signin/route.ts +++ b/apps/web/src/app/api/auth/google/signin/route.ts @@ -1,5 +1,5 @@ import { z } from 'zod/v4'; -import { checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server'; +import { checkRateLimit, RATE_LIMIT_CONFIGS, getClientIP } from '@pagespace/lib/server'; import { loggers } from '@pagespace/lib/server'; import crypto from 'crypto'; @@ -19,9 +19,7 @@ export async function POST(req: Request) { } // Rate limiting by IP address - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); if (!ipRateLimit.allowed) { diff --git a/apps/web/src/app/api/auth/login/route.ts b/apps/web/src/app/api/auth/login/route.ts index 23aaa0dfc..748c7a7bb 100644 --- a/apps/web/src/app/api/auth/login/route.ts +++ b/apps/web/src/app/api/auth/login/route.ts @@ -11,6 +11,7 @@ import { RATE_LIMIT_CONFIGS, decodeToken, validateOrCreateDeviceToken, + getClientIP, } from '@pagespace/lib/server'; import { serialize } from 'cookie'; import { createId } from '@paralleldrive/cuid2'; @@ -41,9 +42,7 @@ export async function POST(req: Request) { const { email, password, deviceId, deviceName, deviceToken: existingDeviceToken } = validation.data; // Rate limiting by IP address and email - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); const emailRateLimit = checkRateLimit(email.toLowerCase(), RATE_LIMIT_CONFIGS.LOGIN); diff --git a/apps/web/src/app/api/auth/logout/route.ts b/apps/web/src/app/api/auth/logout/route.ts index d900f01bb..f3cc5f37c 100644 --- a/apps/web/src/app/api/auth/logout/route.ts +++ b/apps/web/src/app/api/auth/logout/route.ts @@ -1,7 +1,7 @@ import { refreshTokens } from '@pagespace/db'; import { db, eq } from '@pagespace/db'; import { parse, serialize } from 'cookie'; -import { loggers, logAuthEvent } from '@pagespace/lib/server'; +import { loggers, logAuthEvent, getClientIP } from '@pagespace/lib/server'; import { trackAuthEvent } from '@pagespace/lib/activity-tracker'; import { revokeDeviceTokenByValue, revokeDeviceTokensByDevice } from '@pagespace/lib/device-auth-utils'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; @@ -16,9 +16,7 @@ export async function POST(req: Request) { const cookies = parse(cookieHeader || ''); const refreshTokenValue = cookies.refreshToken; - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); // Revoke device token to ensure proper device separation // This prevents token reuse when logging back in on different devices diff --git a/apps/web/src/app/api/auth/mobile/login/route.ts b/apps/web/src/app/api/auth/mobile/login/route.ts index a91af6186..8f43024ee 100644 --- a/apps/web/src/app/api/auth/mobile/login/route.ts +++ b/apps/web/src/app/api/auth/mobile/login/route.ts @@ -9,6 +9,7 @@ import { RATE_LIMIT_CONFIGS, decodeToken, validateOrCreateDeviceToken, + getClientIP, } from '@pagespace/lib/server'; import { generateCSRFToken, getSessionIdFromJWT } from '@pagespace/lib/server'; import { loggers, logAuthEvent } from '@pagespace/lib/server'; @@ -38,9 +39,7 @@ export async function POST(req: Request) { const { email, password, deviceId, platform, deviceName, appVersion, deviceToken: existingDeviceToken } = validation.data; // Rate limiting by IP address and email - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); const emailRateLimit = checkRateLimit(email.toLowerCase(), RATE_LIMIT_CONFIGS.LOGIN); diff --git a/apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts b/apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts index 54dc020c3..973ee6f32 100644 --- a/apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts +++ b/apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts @@ -58,6 +58,7 @@ import { generateCSRFToken, getSessionIdFromJWT, validateOrCreateDeviceToken, + getClientIP, } from '@pagespace/lib/server'; import { loggers, logAuthEvent } from '@pagespace/lib/server'; import { trackAuthEvent } from '@pagespace/lib/activity-tracker'; @@ -101,10 +102,7 @@ export async function POST(req: Request) { platform = requestPlatform; // Rate limiting by IP address - const clientIP = - req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN); if (!ipRateLimit.allowed) { diff --git a/apps/web/src/app/api/auth/mobile/refresh/route.ts b/apps/web/src/app/api/auth/mobile/refresh/route.ts index 81777c78b..76c7a97dd 100644 --- a/apps/web/src/app/api/auth/mobile/refresh/route.ts +++ b/apps/web/src/app/api/auth/mobile/refresh/route.ts @@ -10,6 +10,7 @@ import { RATE_LIMIT_CONFIGS, generateCSRFToken, getSessionIdFromJWT, + getClientIP, } from '@pagespace/lib/server'; import { z } from 'zod/v4'; import { loggers } from '@pagespace/lib/server'; @@ -31,10 +32,7 @@ export async function POST(req: Request) { const { deviceToken, deviceId } = validation.data; - const clientIP = - req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); // Rate limiting by IP address for refresh attempts const rateLimit = checkRateLimit(`refresh:device:${clientIP}`, RATE_LIMIT_CONFIGS.REFRESH); diff --git a/apps/web/src/app/api/auth/mobile/signup/route.ts b/apps/web/src/app/api/auth/mobile/signup/route.ts index 331ab8400..41881604d 100644 --- a/apps/web/src/app/api/auth/mobile/signup/route.ts +++ b/apps/web/src/app/api/auth/mobile/signup/route.ts @@ -10,6 +10,7 @@ import { createNotification, decodeToken, validateOrCreateDeviceToken, + getClientIP, } from '@pagespace/lib/server'; import { generateCSRFToken, getSessionIdFromJWT } from '@pagespace/lib/server'; import { createId } from '@paralleldrive/cuid2'; @@ -42,9 +43,7 @@ const signupSchema = z.object({ }); export async function POST(req: Request) { - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); let email: string | undefined; diff --git a/apps/web/src/app/api/auth/refresh/route.ts b/apps/web/src/app/api/auth/refresh/route.ts index 170a7f7e5..258d12aeb 100644 --- a/apps/web/src/app/api/auth/refresh/route.ts +++ b/apps/web/src/app/api/auth/refresh/route.ts @@ -1,6 +1,6 @@ import { users, refreshTokens, deviceTokens } from '@pagespace/db'; import { db, eq, sql, and, isNull } from '@pagespace/db'; -import { decodeToken, generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server'; +import { decodeToken, generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, RATE_LIMIT_CONFIGS, getClientIP } from '@pagespace/lib/server'; import { validateDeviceToken } from '@pagespace/lib/device-auth-utils'; import { serialize } from 'cookie'; import { parse } from 'cookie'; @@ -16,9 +16,7 @@ export async function POST(req: Request) { } // Rate limiting by IP address for refresh attempts - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); const rateLimit = checkRateLimit(`refresh:${clientIP}`, RATE_LIMIT_CONFIGS.REFRESH); diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts index 56cc04504..0c568b03a 100644 --- a/apps/web/src/app/api/auth/signup/route.ts +++ b/apps/web/src/app/api/auth/signup/route.ts @@ -1,7 +1,7 @@ import { users, userAiSettings, refreshTokens, db, eq } from '@pagespace/db'; import bcrypt from 'bcryptjs'; import { z } from 'zod/v4'; -import { generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, resetRateLimit, RATE_LIMIT_CONFIGS, createNotification, decodeToken, validateOrCreateDeviceToken } from '@pagespace/lib/server'; +import { generateAccessToken, generateRefreshToken, getRefreshTokenMaxAge, checkRateLimit, resetRateLimit, RATE_LIMIT_CONFIGS, createNotification, decodeToken, validateOrCreateDeviceToken, getClientIP } from '@pagespace/lib/server'; import { createId } from '@paralleldrive/cuid2'; import { loggers, logAuthEvent } from '@pagespace/lib/server'; import { trackAuthEvent } from '@pagespace/lib/activity-tracker'; @@ -38,9 +38,7 @@ const signupSchema = z.object({ }); export async function POST(req: Request) { - const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || - req.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(req); let email: string | undefined; diff --git a/apps/web/src/app/api/contact/route.ts b/apps/web/src/app/api/contact/route.ts index 063ff5b4c..09954e5af 100644 --- a/apps/web/src/app/api/contact/route.ts +++ b/apps/web/src/app/api/contact/route.ts @@ -1,7 +1,7 @@ import { contactSubmissions, db } from '@pagespace/db'; import { z } from 'zod/v4'; import { createId } from '@paralleldrive/cuid2'; -import { loggers } from '@pagespace/lib/server'; +import { loggers, getClientIP } from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; const AUTH_OPTIONS = { allow: ['jwt'] as const, requireCSRF: true }; @@ -14,9 +14,7 @@ const contactSchema = z.object({ }); export async function POST(request: Request) { - const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0] || - request.headers.get('x-real-ip') || - 'unknown'; + const clientIP = getClientIP(request); try { // Authenticate request (optional - allows unauthenticated contact forms) diff --git a/apps/web/src/app/api/mcp-ws/route.ts b/apps/web/src/app/api/mcp-ws/route.ts index 123542227..a10fed745 100644 --- a/apps/web/src/app/api/mcp-ws/route.ts +++ b/apps/web/src/app/api/mcp-ws/route.ts @@ -27,7 +27,7 @@ import { isToolExecuteMessage, isToolResultMessage, } from '@/lib/websocket'; -import { decodeToken } from '@pagespace/lib/server'; +import { decodeToken, getClientIP } from '@pagespace/lib/server'; import { getCookieValueFromHeader } from '@/lib/utils/get-cookie-value'; // Initialize cleanup interval on module load @@ -72,8 +72,7 @@ export async function UPGRADE( request: NextRequest ) { const requestUrl = request.url; - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; + const clientIp = getClientIP(request); // SECURITY CHECK 1: Verify secure connection in production if (!isSecureConnection(requestUrl, request)) { diff --git a/apps/web/src/app/api/track/route.ts b/apps/web/src/app/api/track/route.ts index fb99751ed..0058b8b56 100644 --- a/apps/web/src/app/api/track/route.ts +++ b/apps/web/src/app/api/track/route.ts @@ -5,6 +5,7 @@ import { NextResponse } from 'next/server'; import { trackActivity, trackFeature, trackError } from '@pagespace/lib/activity-tracker'; +import { getClientIP } from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; const AUTH_OPTIONS = { allow: ['jwt'] as const, requireCSRF: false }; @@ -19,9 +20,7 @@ export async function POST(request: Request) { } // Get client IP and user agent - const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || - request.headers.get('x-real-ip') || - 'unknown'; + const ip = getClientIP(request); const userAgent = request.headers.get('user-agent') || 'unknown'; // Parse tracking data diff --git a/apps/web/src/app/dashboard/storage/page.tsx b/apps/web/src/app/dashboard/storage/page.tsx index a89f04486..de6522e5f 100644 --- a/apps/web/src/app/dashboard/storage/page.tsx +++ b/apps/web/src/app/dashboard/storage/page.tsx @@ -36,6 +36,7 @@ import { import { formatDistanceToNow } from "date-fns"; import { toast } from "sonner"; import { fetchWithAuth } from "@/lib/auth/auth-fetch"; +import { formatBytes } from "@pagespace/lib/client-safe"; interface StorageInfo { quota: { @@ -100,13 +101,6 @@ const getFileIcon = (mimeType: string) => { return File; }; -const formatBytes = (bytes: number): string => { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -}; - export default function StorageDashboard() { const router = useRouter(); const [storageInfo, setStorageInfo] = useState(null); diff --git a/apps/web/src/lib/ai/core/mcp-tool-converter.ts b/apps/web/src/lib/ai/core/mcp-tool-converter.ts index a11dea720..bacedadd4 100644 --- a/apps/web/src/lib/ai/core/mcp-tool-converter.ts +++ b/apps/web/src/lib/ai/core/mcp-tool-converter.ts @@ -1,284 +1,19 @@ /** * MCP Tool Converter for Web Package - * Converts MCP tool schemas (JSON Schema) to Zod schemas for AI SDK - * Adapted from desktop package for browser/server compatibility - */ - -import { z } from 'zod'; -import type { MCPTool } from '@/types/mcp'; - -/** - * Maximum allowed length for tool and server names - * Prevents excessively long names that could cause issues - */ -const MAX_NAME_LENGTH = 64; - -/** - * Regular expression for valid tool/server names - * Only allows alphanumeric characters, hyphens, and underscores - * This prevents injection attacks via special characters - */ -const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/; - -/** - * Validates a tool name for security and format compliance - * @param toolName - The tool name to validate - * @throws Error if the tool name is invalid - */ -export function validateToolName(toolName: string): void { - if (!toolName || toolName.length === 0) { - throw new Error('Tool name cannot be empty'); - } - - if (toolName.length > MAX_NAME_LENGTH) { - throw new Error(`Tool name exceeds maximum length of ${MAX_NAME_LENGTH} characters`); - } - - if (!VALID_NAME_REGEX.test(toolName)) { - throw new Error( - 'Tool name contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.' - ); - } -} - -/** - * Validates a server name for security and format compliance - * @param serverName - The server name to validate - * @throws Error if the server name is invalid - */ -export function validateServerName(serverName: string): void { - if (!serverName || serverName.length === 0) { - throw new Error('Server name cannot be empty'); - } - - if (serverName.length > MAX_NAME_LENGTH) { - throw new Error(`Server name exceeds maximum length of ${MAX_NAME_LENGTH} characters`); - } - - if (!VALID_NAME_REGEX.test(serverName)) { - throw new Error( - 'Server name contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.' - ); - } -} - -/** - * Creates a safe namespaced tool name after validating inputs - * @param serverName - The MCP server name - * @param toolName - The tool name - * @returns Namespaced tool name in format: mcp:servername:toolname - * @throws Error if either name is invalid - */ -export function createSafeToolName(serverName: string, toolName: string): string { - validateServerName(serverName); - validateToolName(toolName); - return `mcp:${serverName}:${toolName}`; -} - -/** - * Converts a single JSON Schema property to a Zod schema - * Handles common JSON Schema types: string, number, boolean, object, array - */ -function jsonSchemaToZod( - schema: Record, - propertyName: string -): z.ZodTypeAny { - const type = schema.type as string; - const description = schema.description as string | undefined; - - let zodSchema: z.ZodTypeAny; - - switch (type) { - case 'string': - zodSchema = z.string(); - if (schema.enum && Array.isArray(schema.enum)) { - // Handle string enums - zodSchema = z.enum(schema.enum as [string, ...string[]]); - } - break; - - case 'number': - case 'integer': - zodSchema = z.number(); - if (typeof schema.minimum === 'number') { - zodSchema = (zodSchema as z.ZodNumber).min(schema.minimum); - } - if (typeof schema.maximum === 'number') { - zodSchema = (zodSchema as z.ZodNumber).max(schema.maximum); - } - if (type === 'integer') { - // Integer validation must come after min/max for proper error messages - zodSchema = zodSchema.refine((n) => Number.isInteger(n), { - message: 'Must be an integer', - }); - } - break; - - case 'boolean': - zodSchema = z.boolean(); - break; - - case 'object': - // Recursively convert nested object properties - const properties = schema.properties as Record> | undefined; - const required = (schema.required as string[]) || []; - - if (properties) { - const zodProperties: Record = {}; - for (const [propName, propSchema] of Object.entries(properties)) { - zodProperties[propName] = jsonSchemaToZod(propSchema, propName); - // Make optional if not in required array - if (!required.includes(propName)) { - zodProperties[propName] = zodProperties[propName].optional(); - } - } - zodSchema = z.object(zodProperties); - } else { - // Fallback for objects without defined properties - // z.record requires explicit key type (z.string()) - zodSchema = z.record(z.string(), z.unknown()); - } - break; - - case 'array': - const items = schema.items as Record | undefined; - if (items) { - const itemSchema = jsonSchemaToZod(items, `${propertyName}Item`); - zodSchema = z.array(itemSchema); - } else { - zodSchema = z.array(z.unknown()); - } - break; - - default: - // Fallback for unsupported types - console.warn(`Unsupported JSON Schema type "${type}" for property "${propertyName}", using z.unknown()`); - zodSchema = z.unknown(); - } - - // Add description if available - if (description) { - zodSchema = zodSchema.describe(description); - } - - return zodSchema; -} - -/** - * Converts MCP tool input schema (JSON Schema) to Zod object schema - */ -export function convertMCPToolSchemaToZod( - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - } -): z.ZodObject> { - const properties = inputSchema.properties || {}; - const required = inputSchema.required || []; - - const zodProperties: Record = {}; - - for (const [propName, propSchema] of Object.entries(properties)) { - try { - zodProperties[propName] = jsonSchemaToZod(propSchema as Record, propName); - - // Make optional if not in required array - if (!required.includes(propName)) { - zodProperties[propName] = zodProperties[propName].optional(); - } - } catch (error) { - console.warn( - `Failed to convert property "${propName}" in MCP tool schema:`, - error - ); - // Skip problematic properties - continue; - } - } - - return z.object(zodProperties); -} - -/** - * Converts an array of MCP tools to AI SDK tool format (schema only, no execute) - * Returns a map of tool name to tool definition - */ -export function convertMCPToolsToAISDKSchemas( - mcpTools: MCPTool[] -): Record> }> { - const toolSchemas: Record> }> = {}; - - for (const mcpTool of mcpTools) { - try { - // Validate and create safe tool name - const toolName = createSafeToolName(mcpTool.serverName, mcpTool.name); - - toolSchemas[toolName] = { - description: mcpTool.description || `Tool from MCP server: ${mcpTool.serverName}`, - parameters: convertMCPToolSchemaToZod(mcpTool.inputSchema), - }; - - console.log(`Converted MCP tool: ${toolName}`); - } catch (error) { - console.warn( - `Skipping MCP tool "${mcpTool.serverName}.${mcpTool.name}" due to conversion error:`, - error - ); - } - } - - console.log(`Successfully converted ${Object.keys(toolSchemas).length}/${mcpTools.length} MCP tools`); - return toolSchemas; -} - -/** - * Parses a namespaced MCP tool name back to server and tool name - * Supports both new format (mcp:servername:toolname) and legacy format (mcp__servername__toolname) - * - * @param namespacedName - Tool name in format: mcp:servername:toolname or mcp__servername__toolname (legacy) - * @returns Object with serverName and toolName, or null if invalid format - */ -export function parseMCPToolName(namespacedName: string): { - serverName: string; - toolName: string; -} | null { - // New format: mcp:servername:toolname - const newPrefix = 'mcp:'; - if (namespacedName.startsWith(newPrefix)) { - const parts = namespacedName.slice(newPrefix.length).split(':'); - if (parts.length < 2) { - return null; - } - - // Server name is first part, tool name is everything after first separator - const serverName = parts[0]; - const toolName = parts.slice(1).join(':'); - - return { serverName, toolName }; - } - - // Legacy format: mcp__servername__toolname (for backward compatibility) - const legacyPrefix = 'mcp__'; - if (namespacedName.startsWith(legacyPrefix)) { - const parts = namespacedName.slice(legacyPrefix.length).split('__'); - if (parts.length < 2) { - return null; - } - - const serverName = parts[0]; - const toolName = parts.slice(1).join('__'); - - return { serverName, toolName }; - } - - return null; -} - -/** - * Checks if a tool name is an MCP tool (client-side execution required) - * Supports both new format (mcp:) and legacy format (mcp__) - */ -export function isMCPTool(toolName: string): boolean { - return toolName.startsWith('mcp:') || toolName.startsWith('mcp__'); -} + * Re-exports from @pagespace/lib for backward compatibility + */ + +// Re-export all MCP tool converter functions from the shared package +export { + validateToolName, + validateServerName, + createSafeToolName, + convertMCPToolSchemaToZod, + convertMCPToolsToAISDKSchemas, + parseMCPToolName, + isMCPTool, + type MCPToolConversionOptions, +} from '@pagespace/lib'; + +// Re-export types +export type { MCPTool, ToolExecutionResult } from '@pagespace/lib'; diff --git a/apps/web/src/lib/utils/utils.ts b/apps/web/src/lib/utils/utils.ts index 325d913ea..5d4b8944f 100644 --- a/apps/web/src/lib/utils/utils.ts +++ b/apps/web/src/lib/utils/utils.ts @@ -1,33 +1,13 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +// Re-export from @pagespace/lib for consistency across the codebase +export { slugify, formatBytes, parseBytes } from "@pagespace/lib/client-safe" + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function slugify(text: string): string { - return text - .toString() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^\w-]+/g, '') - .replace(/--+/g, '-') - .replace(/^-+/, '') - .replace(/-+$/, ''); -} - -export function formatBytes(bytes: number, decimals = 2): string { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} - export function isElectron(): boolean { // Check if running in Electron desktop app if (typeof window === 'undefined') return false; diff --git a/docs/audits/helper-functions-audit.md b/docs/audits/helper-functions-audit.md new file mode 100644 index 000000000..600668793 --- /dev/null +++ b/docs/audits/helper-functions-audit.md @@ -0,0 +1,376 @@ +# Helper Functions Audit Report + +**Date:** 2025-12-21 +**Scope:** PageSpace monorepo - packages/lib, apps/web, apps/processor, apps/realtime +**Branch:** claude/audit-helper-functions-S8n8o + +--- + +## Executive Summary + +This audit identified **14 orphaned functions**, **14+ duplicate implementations**, **10 missing helper patterns**, and **4 cases of underutilized helpers**. The most critical issues are: + +1. **Duplicate file security functions** across packages/lib and apps/processor +2. **4 separate implementations of `formatBytes()`** +3. **12+ files with repeated IP extraction code** +4. **Orphaned device fingerprinting functions** (6+ unused) + +--- + +## 1. Orphaned/Unused Helper Functions + +### High Priority - Remove or Implement + +| Function | Location | Status | +|----------|----------|--------| +| `subscriptionAllows()` | `packages/lib/src/services/subscription-utils.ts` | Never called | +| `requireResource()` | `packages/lib/src/services/service-auth.ts` | Never called | +| `parseBytes()` | `packages/lib/src/services/storage-limits.ts` | Never called | +| `mapSubscriptionToStorageTier()` | `packages/lib/src/services/storage-limits.ts` | Deprecated, never called | +| `setupMemoryProtection()` | `packages/lib/src/services/memory-monitor.ts` | Never called | +| `emergencyMemoryCleanup()` | `packages/lib/src/services/memory-monitor.ts` | Never called | +| `formatMemory()` | `packages/lib/src/services/memory-monitor.ts` | Never called | +| `parseDateUTC()` | `packages/lib/src/services/date-utils.ts` | Only used in tests | + +### Device Fingerprinting (6 functions never used) + +Location: `packages/lib/src/auth/device-fingerprint-utils.ts` + +| Function | Purpose | Status | +|----------|---------|--------| +| `getClientIP()` | Extract client IP from headers | Only used internally | +| `detectPlatform()` | Detect platform from User-Agent | Only used internally | +| `calculateTrustScore()` | Calculate device trust score | Only tested | +| `isSameSubnet()` | Check if IPs on same /24 subnet | Only tested | +| `isRapidRefresh()` | Detect rapid token refresh | Only tested | +| `anonymizeIP()` | Anonymize IP for privacy | Only tested | +| `extractDeviceMetadata()` | Extract device metadata from request | Never called | +| `generateDefaultDeviceName()` | Generate device name | Never called | +| `generateServerFingerprint()` | Generate server-side fingerprint | Never called | +| `validateDeviceFingerprint()` | Validate device fingerprint | Never called | + +**Recommendation:** These appear to be part of an incomplete device security feature. Either implement the feature or remove the code. + +--- + +## 2. Duplicate Helper Implementations + +### Critical Priority + +#### 2.1 File Security Functions (2 locations) + +| Function | Location 1 | Location 2 | +|----------|------------|------------| +| `sanitizeFilename()` | `packages/lib/src/utils/file-security.ts:10-34` | `apps/processor/src/utils/security.ts:73-97` | +| `DANGEROUS_MIME_TYPES` | `packages/lib/src/utils/file-security.ts:39-45` | `apps/processor/src/utils/security.ts:102-108` | +| `isDangerousMimeType()` | `packages/lib/src/utils/file-security.ts:50-54` | `apps/processor/src/utils/security.ts:113-117` | + +**Fix:** Consolidate into `packages/lib` and import in processor app. + +#### 2.2 MCP Tool Converter (2 locations) + +| Function | Web Location | Desktop Location | +|----------|--------------|------------------| +| `validateToolName()` | `apps/web/src/lib/ai/core/mcp-tool-converter.ts:28-42` | `apps/desktop/src/main/mcp-tool-converter.ts:37-51` | +| `validateServerName()` | `:49-63` | `:58-72` | +| `createSafeToolName()` | `:72-76` | `:81-85` | +| `jsonSchemaToZod()` | `:82-165` | `:91-174` | +| `convertMCPToolSchemaToZod()` | `:170-201` | `:181-233` | +| `parseMCPToolName()` | `:242-276` | `:276-310` | +| `isMCPTool()` | `:282-284` | **MISSING** | + +**Fix:** Extract to `packages/lib` or create shared module. + +### High Priority + +#### 2.3 Byte Formatting (4 locations!) + +```typescript +// Pattern repeated 4 times: +export function formatBytes(bytes: number): string { + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return '0 B'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; +} +``` + +| Location | Line | +|----------|------| +| `apps/web/src/lib/utils/utils.ts` | 19-29 | +| `packages/lib/src/services/storage-limits.ts` | 359-364 | +| `packages/lib/src/services/subscription-utils.ts` | 96-101 | +| `packages/lib/src/client-safe.ts` | 29-34 | + +**Fix:** Keep single implementation in `packages/lib/src/client-safe.ts`, remove others. + +#### 2.4 Byte Parsing (2 locations) + +| Location | Implementation | +|----------|---------------| +| `packages/lib/src/services/storage-limits.ts:369-388` | Uses map object | +| `packages/lib/src/client-safe.ts:37-58` | Uses switch statement | + +**Fix:** Consolidate into `client-safe.ts`. + +### Medium Priority + +#### 2.5 Slugify Function (2 locations) + +Both are identical implementations: + +```typescript +export function slugify(text: string): string { + return text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, ''); +} +``` + +| Location | +|----------| +| `packages/lib/src/utils/utils.ts:1-10` | +| `apps/web/src/lib/utils/utils.ts:8-17` | + +**Fix:** Keep in `packages/lib`, re-export from web lib. + +--- + +## 3. Missing Helpers (Repeated Inline Patterns) + +### Critical Priority - Create These Helpers + +#### 3.1 `getClientIP(request: Request): string` + +**Current pattern (12 files):** +```typescript +const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] || + req.headers.get('x-real-ip') || + 'unknown'; +``` + +**Files affected:** +- `apps/web/src/app/api/auth/signup/route.ts:41-43` +- `apps/web/src/app/api/auth/login/route.ts:44-46` +- `apps/web/src/app/api/auth/mobile/signup/route.ts:45-47` +- `apps/web/src/app/api/auth/device/refresh/route.ts:38-41` +- `apps/web/src/app/api/auth/refresh/route.ts:19-21` +- `apps/web/src/app/api/auth/mobile/login/route.ts:32-34` +- `apps/web/src/app/api/auth/mobile/refresh/route.ts` +- `apps/web/src/app/api/auth/google/callback/route.ts` +- `apps/web/src/app/api/auth/google/signin/route.ts` +- `apps/web/src/app/api/auth/logout/route.ts` +- `apps/web/src/app/api/account/devices/route.ts` +- `apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts` + +**Note:** `getClientIP()` exists in `device-fingerprint-utils.ts` but is NEVER IMPORTED! + +#### 3.2 `createAuthTokenCookieHeaders(accessToken, refreshToken): Headers` + +**Current pattern (6 files):** +```typescript +const accessTokenCookie = serialize('accessToken', accessToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/', + maxAge: 15 * 60, + ...(isProduction && { domain: process.env.COOKIE_DOMAIN }) +}); + +const refreshTokenCookie = serialize('refreshToken', refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + path: '/', + maxAge: getRefreshTokenMaxAge(), + ...(isProduction && { domain: process.env.COOKIE_DOMAIN }) +}); + +const headers = new Headers(); +headers.append('Set-Cookie', accessTokenCookie); +headers.append('Set-Cookie', refreshTokenCookie); +``` + +**Files affected:** +- `apps/web/src/app/api/auth/signup/route.ts:242-258` +- `apps/web/src/app/api/auth/login/route.ts:152-168` +- `apps/web/src/app/api/auth/device/refresh/route.ts:178-194` +- `apps/web/src/app/api/auth/refresh/route.ts:140-156` +- `apps/web/src/app/api/auth/mobile/signup/route.ts` +- `apps/web/src/app/api/auth/logout/route.ts` + +#### 3.3 `handleValidationError(error, logger, defaultMessage): NextResponse` + +**Current pattern (7+ files):** +```typescript +catch (error) { + loggers.api.error('Error updating page:', error as Error); + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.issues }, { status: 400 }); + } + return NextResponse.json({ error: 'Failed to update page' }, { status: 500 }); +} +``` + +#### 3.4 `getPaginationParams(url, defaultLimit, maxLimit): { limit, offset }` + +**Current pattern (10+ files):** +```typescript +const { searchParams } = new URL(request.url); +const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); +``` + +#### 3.5 `createRateLimitResponse(rateLimitResult, message): Response` + +**Current pattern (auth routes):** +```typescript +if (!ipRateLimit.allowed) { + return Response.json( + { + error: 'Too many signup attempts from this IP address.', + retryAfter: ipRateLimit.retryAfter + }, + { + status: 429, + headers: { 'Retry-After': ipRateLimit.retryAfter?.toString() || '3600' } + } + ); +} +``` + +#### 3.6 `calculateRefreshTokenExpiration(token): Promise` + +**Current pattern (5 files):** +```typescript +const refreshPayload = await decodeToken(refreshToken); +const refreshExpiresAt = refreshPayload?.exp + ? new Date(refreshPayload.exp * 1000) + : new Date(Date.now() + getRefreshTokenMaxAge() * 1000); +``` + +--- + +## 4. Underutilized Helpers + +### High Priority + +#### 4.1 `serializeDates()` / `jsonResponse()` Not Used + +**Files with manual `.toISOString()` calls:** + +| File | Lines | Issue | +|------|-------|-------| +| `apps/web/src/app/api/account/devices/route.ts` | 46, 53-54 | Manual date serialization | +| `apps/web/src/app/api/messages/threads/route.ts` | 121-126, 203-205 | Multiple `.toISOString()` calls | +| `apps/web/src/app/api/stripe/invoices/route.ts` | 84, 86, 89 | Manual epoch to date conversion | +| `apps/web/src/app/api/stripe/upcoming-invoice/route.ts` | 98-101, 108-109 | Nested manual conversion | + +**Current pattern:** +```typescript +lastUsedAt: (device.lastUsedAt || device.createdAt).toISOString() +``` + +**Should use:** +```typescript +import { jsonResponse } from '@pagespace/lib'; +return jsonResponse(data); // Automatically serializes dates +``` + +### Medium Priority + +#### 4.2 Local `formatBytes()` Instead of Import + +**File:** `apps/web/src/app/dashboard/storage/page.tsx:103-108` + +```typescript +// Current - local implementation +const formatBytes = (bytes: number): string => { + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return '0 B'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; +}; + +// Should use +import { formatBytes } from '@pagespace/lib'; +``` + +### Low Priority + +#### 4.3 Manual Slugify in Test Files (8 locations) + +Test files manually implement slug conversion instead of using `slugify()`: + +- `apps/web/src/app/api/drives/__tests__/route.test.ts:73` +- `apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts:73, 88` +- `apps/web/src/app/api/drives/[driveId]/roles/__tests__/route.test.ts:52` +- `apps/web/src/app/api/drives/[driveId]/roles/reorder/__tests__/route.test.ts:48` +- `apps/web/src/app/api/drives/[driveId]/roles/[roleId]/__tests__/route.test.ts:54` +- `apps/web/src/app/api/drives/[driveId]/members/__tests__/route.test.ts:67` +- `apps/web/src/app/api/drives/[driveId]/members/[userId]/__tests__/route.test.ts:89` +- `apps/web/src/app/api/auth/__tests__/signup.test.ts:41` + +--- + +## 5. Recommendations Summary + +### Immediate Actions (Critical) + +1. **Consolidate file security functions** into `packages/lib` +2. **Consolidate `formatBytes()`** - keep one in `client-safe.ts` +3. **Create `getClientIP()` helper** in `packages/lib/src/auth/` (or use existing unused one) +4. **Create `createAuthTokenCookieHeaders()`** in auth-utils + +### Short-term Actions (High Priority) + +5. **Remove orphaned functions** from subscription-utils, storage-limits, memory-monitor +6. **Consolidate MCP tool converter** into packages/lib +7. **Replace manual date serialization** with `jsonResponse()` +8. **Create validation error handler** + +### Medium-term Actions + +9. **Decide on device fingerprinting feature** - implement or remove +10. **Consolidate slugify** - single source of truth +11. **Create pagination params helper** +12. **Create rate limit response helper** + +### Maintenance Actions (Low Priority) + +13. **Update tests to use `slugify()`** helper +14. **Audit for additional duplication** quarterly + +--- + +## Metrics + +| Category | Count | +|----------|-------| +| Orphaned functions | 14 | +| Duplicate implementations | 14+ functions | +| Missing helper patterns | 10 | +| Underutilized helper cases | 4 | +| **Total issues** | **42+** | + +--- + +## Appendix: Helper Function Map + +See the full exploration output for a complete map of 80+ helper/utility files across the monorepo, organized by domain: + +1. Authentication & Security (9 files) +2. Permissions & Access Control (3 files) +3. Content & Page Utilities (8 files) +4. Formatting & Display (6 files) +5. Database & Repository (9 files) +6. AI System Utilities (20+ files) +7. Logging & Monitoring (9 files) +8. WebSocket & Real-time (5 files) +9. Service Utilities (13 files) +10. Other domains (15+ files) diff --git a/packages/lib/package.json b/packages/lib/package.json index 1dd1cf5a8..07a9ef7e6 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -255,6 +255,7 @@ } }, "dependencies": { + "zod": "^3.24.4", "@iarna/toml": "^2.2.5", "@pagespace/db": "workspace:*", "@paralleldrive/cuid2": "^2.2.2", diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 79e7a5875..8ef174f58 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -47,6 +47,8 @@ export * from './sheets'; // Auth and security utilities (server-only) export * from './auth/auth-utils'; export * from './auth/device-auth-utils'; +// Device fingerprint utilities (getClientIP is commonly used across API routes) +export { getClientIP, detectPlatform, extractDeviceMetadata } from './auth/device-fingerprint-utils'; export { createServiceToken as createServiceTokenV2, verifyServiceToken as verifyServiceTokenV2, @@ -101,5 +103,8 @@ export * from './file-processing'; // Real-time and broadcasting utilities (server-only) export * from './auth/broadcast-auth'; +// MCP utilities (shared across web and desktop) +export * from './mcp'; + // Note: This index includes server-side dependencies and should NOT be imported // from client-side components. Use '@pagespace/lib/client-safe' for client-side imports. diff --git a/packages/lib/src/mcp/index.ts b/packages/lib/src/mcp/index.ts new file mode 100644 index 000000000..e46bef662 --- /dev/null +++ b/packages/lib/src/mcp/index.ts @@ -0,0 +1,7 @@ +/** + * MCP (Model Context Protocol) utilities + * Shared across web and desktop packages + */ + +export * from './mcp-types'; +export * from './mcp-tool-converter'; diff --git a/packages/lib/src/mcp/mcp-tool-converter.ts b/packages/lib/src/mcp/mcp-tool-converter.ts new file mode 100644 index 000000000..3ce46c487 --- /dev/null +++ b/packages/lib/src/mcp/mcp-tool-converter.ts @@ -0,0 +1,310 @@ +/** + * MCP Tool Converter + * Converts MCP tool schemas (JSON Schema) to Zod schemas for AI SDK + * Shared across web and desktop packages + */ + +import { z } from 'zod'; +import type { MCPTool } from './mcp-types'; + +/** + * Maximum allowed length for tool and server names + * Prevents excessively long names that could cause issues + */ +const MAX_NAME_LENGTH = 64; + +/** + * Regular expression for valid tool/server names + * Only allows alphanumeric characters, hyphens, and underscores + * This prevents injection attacks via special characters + */ +const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/; + +/** + * Validates a tool name for security and format compliance + * @param toolName - The tool name to validate + * @throws Error if the tool name is invalid + */ +export function validateToolName(toolName: string): void { + if (!toolName || toolName.length === 0) { + throw new Error('Tool name cannot be empty'); + } + + if (toolName.length > MAX_NAME_LENGTH) { + throw new Error(`Tool name exceeds maximum length of ${MAX_NAME_LENGTH} characters`); + } + + if (!VALID_NAME_REGEX.test(toolName)) { + throw new Error( + 'Tool name contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.' + ); + } +} + +/** + * Validates a server name for security and format compliance + * @param serverName - The server name to validate + * @throws Error if the server name is invalid + */ +export function validateServerName(serverName: string): void { + if (!serverName || serverName.length === 0) { + throw new Error('Server name cannot be empty'); + } + + if (serverName.length > MAX_NAME_LENGTH) { + throw new Error(`Server name exceeds maximum length of ${MAX_NAME_LENGTH} characters`); + } + + if (!VALID_NAME_REGEX.test(serverName)) { + throw new Error( + 'Server name contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.' + ); + } +} + +/** + * Creates a safe namespaced tool name after validating inputs + * @param serverName - The MCP server name + * @param toolName - The tool name + * @returns Namespaced tool name in format: mcp:servername:toolname + * @throws Error if either name is invalid + */ +export function createSafeToolName(serverName: string, toolName: string): string { + validateServerName(serverName); + validateToolName(toolName); + return `mcp:${serverName}:${toolName}`; +} + +/** + * Converts a single JSON Schema property to a Zod schema + * Handles common JSON Schema types: string, number, boolean, object, array + */ +function jsonSchemaToZod( + schema: Record, + propertyName: string, + onWarning?: (message: string) => void +): z.ZodTypeAny { + const type = schema.type as string; + const description = schema.description as string | undefined; + + let zodSchema: z.ZodTypeAny; + + switch (type) { + case 'string': + zodSchema = z.string(); + if (schema.enum && Array.isArray(schema.enum)) { + // Handle string enums + zodSchema = z.enum(schema.enum as [string, ...string[]]); + } + break; + + case 'number': + case 'integer': + zodSchema = z.number(); + if (typeof schema.minimum === 'number') { + zodSchema = (zodSchema as z.ZodNumber).min(schema.minimum); + } + if (typeof schema.maximum === 'number') { + zodSchema = (zodSchema as z.ZodNumber).max(schema.maximum); + } + if (type === 'integer') { + // Integer validation must come after min/max for proper error messages + zodSchema = zodSchema.refine((n: number) => Number.isInteger(n), { + message: 'Must be an integer', + }); + } + break; + + case 'boolean': + zodSchema = z.boolean(); + break; + + case 'object': + // Recursively convert nested object properties + const properties = schema.properties as Record> | undefined; + const required = (schema.required as string[]) || []; + + if (properties) { + const zodProperties: Record = {}; + for (const [propName, propSchema] of Object.entries(properties)) { + zodProperties[propName] = jsonSchemaToZod(propSchema, propName, onWarning); + // Make optional if not in required array + if (!required.includes(propName)) { + zodProperties[propName] = zodProperties[propName].optional(); + } + } + zodSchema = z.object(zodProperties); + } else { + // Fallback for objects without defined properties + // z.record requires explicit key type (z.string()) + zodSchema = z.record(z.string(), z.unknown()); + } + break; + + case 'array': + const items = schema.items as Record | undefined; + if (items) { + const itemSchema = jsonSchemaToZod(items, `${propertyName}Item`, onWarning); + zodSchema = z.array(itemSchema); + } else { + zodSchema = z.array(z.unknown()); + } + break; + + default: + // Fallback for unsupported types + const warnMsg = `Unsupported JSON Schema type "${type}" for property "${propertyName}", using z.unknown()`; + if (onWarning) { + onWarning(warnMsg); + } else { + console.warn(warnMsg); + } + zodSchema = z.unknown(); + } + + // Add description if available + if (description) { + zodSchema = zodSchema.describe(description); + } + + return zodSchema; +} + +/** + * Converts MCP tool input schema (JSON Schema) to Zod object schema + */ +export function convertMCPToolSchemaToZod( + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }, + onWarning?: (message: string) => void +): z.ZodObject> { + const properties = inputSchema.properties || {}; + const required = inputSchema.required || []; + + const zodProperties: Record = {}; + + for (const [propName, propSchema] of Object.entries(properties)) { + try { + zodProperties[propName] = jsonSchemaToZod(propSchema as Record, propName, onWarning); + + // Make optional if not in required array + if (!required.includes(propName)) { + zodProperties[propName] = zodProperties[propName].optional(); + } + } catch (error) { + const warnMsg = `Failed to convert property "${propName}" in MCP tool schema: ${error}`; + if (onWarning) { + onWarning(warnMsg); + } else { + console.warn(warnMsg); + } + // Skip problematic properties + continue; + } + } + + return z.object(zodProperties); +} + +/** + * Options for MCP tool conversion + */ +export interface MCPToolConversionOptions { + onWarning?: (message: string) => void; + onInfo?: (message: string) => void; +} + +/** + * Converts an array of MCP tools to AI SDK tool format (schema only, no execute) + * Returns a map of tool name to tool definition + */ +export function convertMCPToolsToAISDKSchemas( + mcpTools: MCPTool[], + options?: MCPToolConversionOptions +): Record> }> { + const toolSchemas: Record> }> = {}; + const { onWarning, onInfo } = options || {}; + + for (const mcpTool of mcpTools) { + try { + // Validate and create safe tool name + const toolName = createSafeToolName(mcpTool.serverName, mcpTool.name); + + toolSchemas[toolName] = { + description: mcpTool.description || `Tool from MCP server: ${mcpTool.serverName}`, + parameters: convertMCPToolSchemaToZod(mcpTool.inputSchema, onWarning), + }; + + if (onInfo) { + onInfo(`Converted MCP tool: ${toolName}`); + } + } catch (error) { + const warnMsg = `Skipping MCP tool "${mcpTool.serverName}.${mcpTool.name}" due to conversion error: ${error}`; + if (onWarning) { + onWarning(warnMsg); + } else { + console.warn(warnMsg); + } + } + } + + if (onInfo) { + onInfo(`Successfully converted ${Object.keys(toolSchemas).length}/${mcpTools.length} MCP tools`); + } + + return toolSchemas; +} + +/** + * Parses a namespaced MCP tool name back to server and tool name + * Supports both new format (mcp:servername:toolname) and legacy format (mcp__servername__toolname) + * + * @param namespacedName - Tool name in format: mcp:servername:toolname or mcp__servername__toolname (legacy) + * @returns Object with serverName and toolName, or null if invalid format + */ +export function parseMCPToolName(namespacedName: string): { + serverName: string; + toolName: string; +} | null { + // New format: mcp:servername:toolname + const newPrefix = 'mcp:'; + if (namespacedName.startsWith(newPrefix)) { + const parts = namespacedName.slice(newPrefix.length).split(':'); + if (parts.length < 2) { + return null; + } + + // Server name is first part, tool name is everything after first separator + const serverName = parts[0]; + const toolName = parts.slice(1).join(':'); + + return { serverName, toolName }; + } + + // Legacy format: mcp__servername__toolname (for backward compatibility) + const legacyPrefix = 'mcp__'; + if (namespacedName.startsWith(legacyPrefix)) { + const parts = namespacedName.slice(legacyPrefix.length).split('__'); + if (parts.length < 2) { + return null; + } + + const serverName = parts[0]; + const toolName = parts.slice(1).join('__'); + + return { serverName, toolName }; + } + + return null; +} + +/** + * Checks if a tool name is an MCP tool (client-side execution required) + * Supports both new format (mcp:) and legacy format (mcp__) + */ +export function isMCPTool(toolName: string): boolean { + return toolName.startsWith('mcp:') || toolName.startsWith('mcp__'); +} diff --git a/packages/lib/src/mcp/mcp-types.ts b/packages/lib/src/mcp/mcp-types.ts new file mode 100644 index 000000000..28a8df7a1 --- /dev/null +++ b/packages/lib/src/mcp/mcp-types.ts @@ -0,0 +1,90 @@ +/** + * Shared MCP type definitions + * Used across web, desktop, and server packages + */ + +/** + * MCP Tool Definition + * + * Tool Naming Convention: + * - Tool names must match /^[a-zA-Z0-9_-]+$/ + * - Server names must match /^[a-zA-Z0-9_-]+$/ + * - Maximum length: 64 characters + * - Only alphanumeric characters, hyphens, and underscores allowed + * - Prevents injection attacks via special characters + * + * Namespaced Format: mcp:servername:toolname + * Example: mcp:my-server:read-file + * Legacy Format (deprecated): mcp__servername__toolname + */ +export interface MCPTool { + /** + * Tool name (validated, alphanumeric + hyphens + underscores only, max 64 chars) + */ + name: string; + + /** + * Human-readable description of what the tool does + */ + description: string; + + /** + * JSON Schema defining the tool's input parameters + */ + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + /** + * MCP server name (validated, alphanumeric + hyphens + underscores only, max 64 chars) + */ + serverName: string; +} + +/** + * Tool Execution Result + */ +export interface ToolExecutionResult { + success: boolean; + result?: unknown; + error?: string; +} + +/** + * MCP Server Configuration + */ +export interface MCPServerConfig { + command: string; + args: string[]; + env?: Record; + autoStart?: boolean; + enabled?: boolean; + timeout?: number; +} + +/** + * MCP Configuration File Structure + */ +export interface MCPConfig { + mcpServers: Record; +} + +/** + * Server Status States + */ +export type MCPServerStatus = 'stopped' | 'starting' | 'running' | 'error' | 'crashed'; + +/** + * Extended Server Status Information + */ +export interface MCPServerStatusInfo { + status: MCPServerStatus; + error?: string; + startedAt?: Date; + crashCount: number; + lastCrashAt?: Date; + enabled: boolean; + autoStart: boolean; +} diff --git a/packages/lib/src/server.ts b/packages/lib/src/server.ts index ce8ea9e8b..0fe661092 100644 --- a/packages/lib/src/server.ts +++ b/packages/lib/src/server.ts @@ -1,6 +1,7 @@ // All exports including Node.js-only utilities export * from './auth/auth-utils'; export * from './auth/device-auth-utils'; +export { getClientIP, detectPlatform, extractDeviceMetadata } from './auth/device-fingerprint-utils'; export * from './auth/csrf-utils'; export * from './encryption'; export * from './content'; diff --git a/packages/lib/src/services/storage-limits.ts b/packages/lib/src/services/storage-limits.ts index b5934df4b..f87fb1dff 100644 --- a/packages/lib/src/services/storage-limits.ts +++ b/packages/lib/src/services/storage-limits.ts @@ -1,5 +1,6 @@ import { db, users, pages, drives, storageEvents, eq, sql, and, isNull, inArray } from '@pagespace/db'; import { getStorageConfigFromSubscription, getStorageTierFromSubscription, type SubscriptionTier } from './subscription-utils'; +import { formatBytes, parseBytes } from '../client-safe'; export interface StorageQuota { userId: string; @@ -353,39 +354,8 @@ function getWarningLevel(percent: number): 'none' | 'warning' | 'critical' { return 'none'; } -/** - * Format bytes to human-readable string - */ -export function formatBytes(bytes: number): string { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -} - -/** - * Parse human-readable size to bytes - */ -export function parseBytes(size: string): number { - // Defensive check for undefined/null input - if (!size || typeof size !== 'string') { - throw new Error(`Invalid size parameter: expected string, got ${typeof size}`); - } - - const units: Record = { - B: 1, - KB: 1024, - MB: 1024 * 1024, - GB: 1024 * 1024 * 1024, - TB: 1024 * 1024 * 1024 * 1024 - }; - - const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?B)$/i); - if (!match) throw new Error(`Invalid size format: "${size}"`); - - const [, value, unit] = match; - return Math.floor(parseFloat(value) * (units[unit.toUpperCase()] || 1)); -} +// Re-export for backward compatibility (canonical versions are in client-safe.ts) +export { formatBytes, parseBytes } from '../client-safe'; /** * @deprecated - Removed: Use subscription tier changes instead diff --git a/packages/lib/src/services/subscription-utils.ts b/packages/lib/src/services/subscription-utils.ts index 0eaa438fe..2c6a9d222 100644 --- a/packages/lib/src/services/subscription-utils.ts +++ b/packages/lib/src/services/subscription-utils.ts @@ -90,12 +90,5 @@ export function subscriptionAllows(subscriptionTier: SubscriptionTier, feature: return config.features.includes(feature); } -/** - * Format bytes to human-readable string - */ -export function formatBytes(bytes: number): string { - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 B'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`; -} \ No newline at end of file +// Re-export for backward compatibility (canonical version is in client-safe.ts) +export { formatBytes } from '../client-safe'; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c08e9efc9..73ce86d71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,6 +596,9 @@ importers: xlsx: specifier: ^0.18.5 version: 0.18.5 + zod: + specifier: ^3.24.4 + version: 3.25.76 devDependencies: '@react-email/preview-server': specifier: 4.2.12 @@ -10262,6 +10265,9 @@ packages: zod@3.24.3: resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} @@ -21050,6 +21056,8 @@ snapshots: zod@3.24.3: {} + zod@3.25.76: {} + zod@4.1.11: {} zustand@4.5.7(@types/react@19.1.13)(immer@10.1.3)(react@19.2.1):