UNPKG

@hellocoop/admin-mcp

Version:

Model Context Protocol (MCP) for Hellō Admin API.

882 lines (781 loc) β€’ 32.9 kB
// MCP Tools for Hello Admin API // Focused on new Hellō developers who want to easily create first app import crypto from 'crypto'; import FormData from 'form-data'; import { validateMimeType, detectMimeType, extractBase64FromDataUrl, createMCPContent } from './utils.js'; import { sendPlausibleEvent } from './analytics.js'; import { HELLO_ADMIN, HELLO_DOMAIN } from './config.js'; /** * Generate console URL with proper query parameters * @param {string} clientId - The application client ID * @param {Object} profile - User profile containing user info * @returns {string} - Console URL with query parameters */ function generateConsoleUrl(clientId, profile) { const params = { iss: `https://issuer.${HELLO_DOMAIN}`, client_id: clientId, } const loginHint = profile.user?.id || profile.user?.email; if (loginHint) { params.login_hint = loginHint; } const url = new URL(`https://console.${HELLO_DOMAIN}`); url.search = new URLSearchParams(params).toString(); return url.toString(); } /** * Flatten application object for response * @param {Object} app - Application object with nested properties * @returns {Object} - Application object with all properties flattened */ function flattenApp(app) { if (!app) return app; const flattened = { ...app }; // Flatten web configuration if (app.web) { flattened.dev_localhost = app.web.dev?.localhost; flattened.dev_127_0_0_1 = app.web.dev?.["127.0.0.1"]; flattened.dev_wildcard = app.web.dev?.wildcard_domain; flattened.dev_redirect_uris = app.web.dev?.redirect_uris; flattened.prod_redirect_uris = app.web.prod?.redirect_uris; // Remove the nested web object delete flattened.web; } // Add standard flattened fields flattened.device_code = app.device_code; flattened.created_by = app.createdBy; flattened.secrets = app.secrets || {}; return flattened; } /** * Get tool definitions for MCP * @returns {Array} - Array of tool definitions */ export function getToolDefinitions() { sendPlausibleEvent('/tools/list'); return [ { name: 'hello_manage_app', description: 'Manage Hellō applications - create, read, update, create secrets, and upload logos', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['create', 'read', 'update', 'create_secret', 'update_logo_from_data', 'update_logo_from_url'], description: 'Action to perform: create (new app), read (get app), update (modify app), create_secret (generate secret), update_logo_from_data (set logo from base64 data), update_logo_from_url (set logo from URL)' }, team_id: { type: 'string', description: 'ID of the team that owns the application (optional for all actions - uses default team if not specified)' }, client_id: { type: 'string', description: 'ID of the OAuth client/application (optional for read - returns profile if omitted; required for: update, create_secret, update_logo_from_data, update_logo_from_url)' }, name: { type: 'string', description: 'Name of the application (optional for: create, update - if not provided for create, will be generated from user name)' }, tos_uri: { type: 'string', description: 'Terms of Service URI (optional for: create, update)' }, pp_uri: { type: 'string', description: 'Privacy Policy URI (optional for: create, update)' }, dev_localhost: { type: 'boolean', description: 'Allow localhost redirects in development environment (optional for: create, update)' }, dev_127_0_0_1: { type: 'boolean', description: 'Allow 127.0.0.1 redirects in development environment (optional for: create, update)' }, dev_wildcard: { type: 'boolean', description: 'Allow wildcard domain redirects in development environment (optional for: create, update)' }, dev_redirect_uris: { type: 'array', items: { type: 'string' }, description: 'Array of allowed redirect URIs for development environment (optional for: create, update)' }, prod_redirect_uris: { type: 'array', items: { type: 'string' }, description: 'Array of allowed redirect URIs for production environment (optional for: create, update)' }, device_code: { type: 'boolean', description: 'Whether the application supports device code flow (optional for: create, update)' }, logo_data: { type: 'string', description: 'Base64 encoded logo data (required for: update_logo_from_data)' }, logo_url: { type: 'string', description: 'URL of the logo image to fetch and set (required for: update_logo_from_url)' }, logo_content_type: { type: 'string', description: 'MIME type of the logo data, e.g. "image/png" (required for: update_logo_from_data; auto-detected for: update_logo_from_url)' }, theme: { type: 'string', enum: ['light', 'dark'], description: 'Logo theme - whether this is for light or dark mode (optional for: update_logo_from_data, update_logo_from_url, defaults to "light")' } }, required: ['action'], allOf: [ { if: { properties: { action: { const: 'create' } } }, then: { required: ['action'] } }, { if: { properties: { action: { const: 'read' } } }, then: { required: ['action'] } }, { if: { properties: { action: { const: 'update' } } }, then: { required: ['action', 'client_id'] } }, { if: { properties: { action: { const: 'create_secret' } } }, then: { required: ['action', 'client_id'] } }, { if: { properties: { action: { const: 'update_logo_from_data' } } }, then: { required: ['action', 'client_id', 'logo_data', 'logo_content_type'] } }, { if: { properties: { action: { const: 'update_logo_from_url' } } }, then: { required: ['action', 'client_id', 'logo_url'] } } ] } } ]; } /** * Get or create default team for a user * @param {Object} apiClient - Admin API client instance * @returns {Promise<string>} - Team ID */ async function getOrCreateDefaultTeam(apiClient, profile) { try { // Check if user has existing teams (using transformed structure) if (profile?.teams?.length > 0 && profile.teams[0].id) { return profile.teams[0].id; } // No teams exist, create a default one const userName = profile.user?.name || 'My'; const defaultTeamName = `${userName} Team`; const newTeam = await apiClient.callAdminAPI('POST', '/api/v1/publishers', { name: defaultTeamName }); return newTeam.profile.id; } catch (error) { throw new Error(`Failed to get or create default team: ${error.message}`); } } /** * Validate a redirect URI * @param {string} uri - The URI to validate * @param {boolean} isProduction - Whether this is for production environment * @returns {boolean} - True if valid, false otherwise */ function validateRedirectUri(uri, isProduction = false) { if (!uri || typeof uri !== 'string' || uri.trim() === '') { return false; } try { const url = new URL(uri); // For production, only reject known insecure schemes if (isProduction) { // Only reject clearly problematic schemes for security const rejectedSchemes = ['http:', 'ftp:', 'file:', 'data:', 'javascript:']; if (rejectedSchemes.includes(url.protocol)) { return false; } // Accept https and any other custom scheme that URL constructor can parse } // If URL constructor can parse it, it's probably valid enough return true; } catch (error) { // Only reject if URL constructor completely fails to parse it return false; } } /** * Merge redirect URIs, creating a superset for production to prevent deletion * @param {Array} existingUris - Current redirect URIs * @param {Array} newUris - New redirect URIs to add * @param {boolean} isProduction - Whether this is for production environment * @returns {Object} - Object with merged URIs and validation info */ function mergeRedirectUris(existingUris = [], newUris = [], isProduction = false) { const validNewUris = []; const rejectedUris = []; // Validate all new URIs and separate valid from invalid newUris.forEach(uri => { const isValid = validateRedirectUri(uri, isProduction); if (isValid) { validNewUris.push(uri); } else { rejectedUris.push(uri); console.warn(`Invalid redirect URI rejected: ${uri} (production: ${isProduction})`); } }); let finalUris; if (isProduction) { // For production, create superset - never remove existing URIs const existingSet = new Set(existingUris || []); validNewUris.forEach(uri => existingSet.add(uri)); finalUris = Array.from(existingSet); } else { // For development, allow replacement finalUris = validNewUris; } return { uris: finalUris, rejected: rejectedUris }; } /** * Get user profile with team context * @param {Object} apiClient - Admin API client instance * @returns {Promise<Object>} - Profile data with team terminology */ async function getProfileWithTeamContext(apiClient, teamId = null) { try { if (teamId) { // When team_id is provided, get specific publisher data const publisherResponse = await apiClient.callAdminAPI('GET', `/api/v1/publishers/${teamId}`); return { user: { id: publisherResponse.profile.id, name: publisherResponse.profile.name }, defaultTeam: { id: publisherResponse.profile.id, name: publisherResponse.profile.name, role: publisherResponse.role || 'admin' }, teams: [{ id: publisherResponse.profile.id, name: publisherResponse.profile.name, role: publisherResponse.role || 'admin', applications: publisherResponse.applications || [] }], applications: publisherResponse.applications || [] }; } else { // Default behavior - get profile with default team const profileResponse = await apiClient.callAdminAPI('GET', '/api/v1/profile'); return { user: profileResponse.profile, defaultTeam: profileResponse.currentPublisher?.profile ? { id: profileResponse.currentPublisher.profile.id, name: profileResponse.currentPublisher.profile.name, role: 'admin' } : null, teams: profileResponse.publishers?.map(pub => ({ id: pub.id, name: pub.name, role: pub.role, applications: [] // Publishers don't include applications in list })) || [], applications: profileResponse.currentPublisher?.applications || [] }; } } catch (error) { // Return minimal profile on error return { user: null, defaultTeam: null, teams: [], applications: [], error: `Unable to fetch profile: ${error.message}` }; } } /** * Handle the consolidated app management tool * @param {Object} args - Tool arguments * @param {Object} apiClient - Admin API client instance * @returns {Promise<Object>} - Tool execution result with profile data */ async function handleManageApp(args, apiClient) { console.log('πŸ”§ handleManageApp called with args:', JSON.stringify(args, null, 2)); const { action, client_id, team_id, name, tos_uri, pp_uri, image_uri, dev_localhost, dev_127_0_0_1, dev_wildcard, dev_redirect_uris, prod_redirect_uris, device_code, logo_data, logo_content_type, logo_url, theme } = args; console.log(`πŸ”§ Extracted action: "${action}"`); // FIRST: Validate action parameter before doing any API calls const validActions = ['create', 'read', 'update', 'create_secret', 'update_logo_from_data', 'update_logo_from_url']; if (!validActions.includes(action)) { console.log(`❌ Unknown action received: "${action}"`); console.log(` Supported actions: ${validActions.join(', ')}`); // Create a proper JSON-RPC error for invalid parameters const error = new Error(`Invalid action parameter: "${action}"`); error.code = -32602; // Invalid params error.data = { received_action: action, supported_actions: validActions, message: `The action "${action}" is not supported. Please use one of the supported actions.` }; throw error; } // Get current profile, team, and application data const profile = await getProfileWithTeamContext(apiClient, team_id); // Handle read action separately - no need to create teams if (action === 'read') { sendPlausibleEvent('/tools/call/hello_manage_app/read'); // If no client_id provided, return profile only if (!client_id) { return { profile, action_result: { action: 'read', success: true, message: 'Profile retrieved successfully' } }; } // If client_id provided, look for it in current publisher's applications first let app = profile.applications.find(app => app.id === client_id); // If not found in current publisher, try to fetch directly via Admin API if (!app) { try { app = await apiClient.callAdminAPI('GET', `/api/v1/applications/${client_id}`); // Add team context if we found the app via direct lookup if (app) { app.teamId = app.publisher; // We don't have team name from direct lookup, so leave it undefined } } catch (error) { throw new Error(`Application with client_id ${client_id} not found`); } } return { profile, application: flattenApp(app), console_url: generateConsoleUrl(client_id, profile), action_result: { action: 'read', success: true, message: 'Application retrieved successfully' } }; } // For all other actions, get team ID (use provided or create default) const resolvedTeamId = team_id || await getOrCreateDefaultTeam(apiClient, profile); console.log(`πŸ”§ Entering switch statement with action: "${action}"`); switch (action) { case 'create': { sendPlausibleEvent('/tools/call/hello_manage_app/create'); // Generate name from existing profile if not provided let appName = name; if (!appName) { const userName = profile.user?.name || 'User'; appName = `${userName}'s App`; } // Validate and prepare redirect URIs const devUriResult = mergeRedirectUris([], dev_redirect_uris || [], false); const prodUriResult = mergeRedirectUris([], prod_redirect_uris || [], true); const appData = { name: appName, tos_uri: tos_uri || null, pp_uri: pp_uri || null, image_uri: image_uri || null, web: { dev: { localhost: dev_localhost !== undefined ? dev_localhost : true, "127.0.0.1": dev_127_0_0_1 !== undefined ? dev_127_0_0_1 : true, wildcard_domain: dev_wildcard !== undefined ? dev_wildcard : false, redirect_uris: devUriResult.uris }, prod: { redirect_uris: prodUriResult.uris } }, device_code: device_code || false, createdBy: 'mcp' }; const appResult = await apiClient.callAdminAPI('POST', `/api/v1/publishers/${resolvedTeamId}/applications`, appData); // Build warning messages for rejected URIs const warnings = []; if (devUriResult.rejected.length > 0) { warnings.push(`${devUriResult.rejected.length} invalid development redirect URI(s) rejected: ${devUriResult.rejected.join(', ')}`); } if (prodUriResult.rejected.length > 0) { warnings.push(`${prodUriResult.rejected.length} invalid production redirect URI(s) rejected: ${prodUriResult.rejected.join(', ')}`); } const actionResult = { action: 'create', success: true, message: 'Application created successfully' }; if (warnings.length > 0) { actionResult.warnings = warnings; } return { profile, application: flattenApp(appResult), console_url: generateConsoleUrl(appResult.id, profile), action_result: actionResult }; } case 'update': { sendPlausibleEvent('/tools/call/hello_manage_app/update'); if (!client_id) throw new Error('Client ID is required for update action'); // Get current app data first const currentApp = await apiClient.callAdminAPI('GET', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`); // Initialize URI result objects let devUriResult = { uris: [], rejected: [] }; let prodUriResult = { uris: [], rejected: [] }; // Build web object from flattened parameters if any are provided let webUpdate = {}; if (dev_localhost !== undefined || dev_127_0_0_1 !== undefined || dev_wildcard !== undefined || dev_redirect_uris !== undefined || prod_redirect_uris !== undefined) { // Merge redirect URIs properly - superset for production, replacement for dev const currentDevUris = currentApp.web?.dev?.redirect_uris || []; const currentProdUris = currentApp.web?.prod?.redirect_uris || []; devUriResult = dev_redirect_uris !== undefined ? mergeRedirectUris([], dev_redirect_uris, false) // Dev allows replacement : { uris: currentDevUris, rejected: [] }; prodUriResult = prod_redirect_uris !== undefined ? mergeRedirectUris(currentProdUris, prod_redirect_uris, true) // Prod creates superset : { uris: currentProdUris, rejected: [] }; webUpdate = { web: { dev: { localhost: dev_localhost !== undefined ? dev_localhost : currentApp.web?.dev?.localhost || true, "127.0.0.1": dev_127_0_0_1 !== undefined ? dev_127_0_0_1 : currentApp.web?.dev?.["127.0.0.1"] || true, wildcard_domain: dev_wildcard !== undefined ? dev_wildcard : currentApp.web?.dev?.wildcard_domain || false, redirect_uris: devUriResult.uris }, prod: { redirect_uris: prodUriResult.uris } } }; } const updateData = { ...currentApp, ...(name !== undefined && { name }), ...(tos_uri !== undefined && { tos_uri }), ...(pp_uri !== undefined && { pp_uri }), ...(image_uri !== undefined && { image_uri }), ...webUpdate, ...(device_code !== undefined && { device_code }) }; const appResult = await apiClient.callAdminAPI('PUT', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`, updateData); // Build warning messages for rejected URIs const warnings = []; if (devUriResult && devUriResult.rejected.length > 0) { warnings.push(`${devUriResult.rejected.length} invalid development redirect URI(s) rejected: ${devUriResult.rejected.join(', ')}`); } if (prodUriResult && prodUriResult.rejected.length > 0) { warnings.push(`${prodUriResult.rejected.length} invalid production redirect URI(s) rejected: ${prodUriResult.rejected.join(', ')}`); } const actionResult = { action: 'update', success: true, message: 'Application updated successfully' }; if (warnings.length > 0) { actionResult.warnings = warnings; } return { profile, application: flattenApp(appResult), console_url: generateConsoleUrl(client_id, profile), action_result: actionResult }; } case 'create_secret': { sendPlausibleEvent('/tools/call/hello_manage_app/create_secret'); if (!client_id) throw new Error('Client ID is required for create_secret action'); // Generate hash and salt automatically const salt = crypto.randomUUID(); const secret = crypto.randomBytes(32).toString('hex'); const hash = crypto.createHash('sha256').update(secret + salt).digest('hex'); const secretData = { hash, salt }; const secretResult = await apiClient.callAdminAPI('POST', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}/secrets`, secretData); return { profile, client_secret: secret, // Return the raw secret to the user console_url: generateConsoleUrl(client_id, profile), action_result: { action: 'create_secret', success: true, message: 'Client secret created successfully' } }; } case 'update_logo_from_data': { console.log('πŸ”§ Starting update_logo_from_data action'); console.log(` client_id: ${client_id}`); console.log(` logo_content_type: ${logo_content_type}`); console.log(` logo_data length: ${logo_data ? logo_data.length : 'undefined'}`); console.log(` theme: ${theme}`); sendPlausibleEvent('/tools/call/hello_manage_app/update_logo_from_data'); if (!client_id) throw new Error('Client ID is required for update_logo_from_data action'); if (!logo_data || !logo_content_type) { throw new Error('logo_data and logo_content_type are required for update_logo_from_data action'); } // Validate mime type console.log('πŸ”§ Validating MIME type...'); const mimeValidation = validateMimeType(logo_content_type); if (!mimeValidation.valid) { console.log(`❌ MIME type validation failed: ${mimeValidation.error}`); throw new Error(mimeValidation.error); } console.log('βœ… MIME type validation passed'); // Upload the logo using multipart form data console.log('πŸ”§ Starting logo upload...'); const uploadResult = await uploadLogoBinary(resolvedTeamId, client_id, logo_data, logo_content_type, apiClient); console.log('βœ… Logo upload completed:', uploadResult); // Determine which logo field to update based on theme const logoTheme = theme || 'light'; console.log(`πŸ”§ Logo theme: ${logoTheme}`); // Get current application state console.log('πŸ”§ Fetching current application state...'); let currentApp; try { currentApp = await apiClient.callAdminAPI('GET', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`); console.log('βœ… Current application fetched'); } catch (error) { if (error.message.includes('Resource not found')) { const friendlyError = new Error(`Application not found: The client_id "${client_id}" does not exist or you don't have permission to access it.`); friendlyError.code = -32602; // Invalid params friendlyError.data = { error_type: 'invalid_client_id', client_id: client_id, message: `The application with client_id "${client_id}" was not found. Please check that the client_id is correct and that you have permission to access this application.` }; throw friendlyError; } throw error; // Re-throw other errors as-is } // Update the application with the new logo URL in the appropriate field const logoField = logoTheme === 'light' ? 'image_uri' : 'dark_image_uri'; console.log(`πŸ”§ Updating ${logoField} with: ${uploadResult.image_uri}`); const updateData = { ...currentApp, [logoField]: uploadResult.image_uri }; // Update the application console.log('πŸ”§ Updating application...'); const updatedApp = await apiClient.callAdminAPI('PUT', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`, updateData); console.log('βœ… Application updated successfully'); // Generate a simple filename for test expectations const extension = logo_content_type === 'image/svg+xml' ? 'svg' : logo_content_type === 'image/png' ? 'png' : logo_content_type === 'image/jpeg' ? 'jpg' : 'png'; const generatedFilename = `logo_${Date.now()}.${extension}`; console.log('πŸ”§ Preparing response...'); const response = { profile, application: flattenApp(updatedApp), console_url: generateConsoleUrl(client_id, profile), upload_result: { ...uploadResult, // Include generated filename in flattened response logo_filename: generatedFilename }, action_result: { action: 'update_logo_from_data', success: true, message: `Logo updated successfully from data for ${logoTheme} theme`, logo_url: uploadResult.image_uri, theme: logoTheme } }; console.log('βœ… update_logo_from_data completed successfully'); return response; } case 'update_logo_from_url': { sendPlausibleEvent('/tools/call/hello_manage_app/update_logo_from_url'); if (!client_id) throw new Error('Client ID is required for update_logo_from_url action'); if (!logo_url) { throw new Error('logo_url is required for update_logo_from_url action'); } // Fetch the logo from the URL const logoResponse = await fetch(logo_url); if (!logoResponse.ok) { throw new Error(`Failed to fetch logo from URL: ${logoResponse.status} ${logoResponse.statusText}`); } // Get content type from the response headers const fetchedContentType = logoResponse.headers.get('content-type'); if (!fetchedContentType || !fetchedContentType.startsWith('image/')) { throw new Error(`Invalid content type from URL: ${fetchedContentType}. Expected an image.`); } // Convert response to buffer and then to base64 const logoBuffer = await logoResponse.arrayBuffer(); const logoBase64 = Buffer.from(logoBuffer).toString('base64'); // Determine which logo field to update based on theme const logoTheme = theme || 'light'; // Upload the logo using uploadLogoBinary const uploadResult = await uploadLogoBinary( resolvedTeamId, client_id, logoBase64, fetchedContentType, apiClient ); // Get current application state const currentApp = await apiClient.callAdminAPI('GET', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`); // Update the application with the new logo URL in the appropriate field const updateData = { ...currentApp, [logoTheme === 'light' ? 'image_uri' : 'dark_image_uri']: uploadResult.image_uri }; // Update the application const updatedApp = await apiClient.callAdminAPI('PUT', `/api/v1/publishers/${resolvedTeamId}/applications/${client_id}`, updateData); // Generate a simple filename for test expectations const extension = fetchedContentType === 'image/svg+xml' ? 'svg' : fetchedContentType === 'image/png' ? 'png' : fetchedContentType === 'image/jpeg' ? 'jpg' : 'png'; const generatedFilename = `logo_${Date.now()}.${extension}`; return { profile, application: flattenApp(updatedApp), console_url: generateConsoleUrl(client_id, profile), upload_result: { ...uploadResult, logo_filename: generatedFilename }, action_result: { action: 'update_logo_from_url', success: true, message: `Logo updated successfully from URL for ${logoTheme} theme`, logo_url: uploadResult.image_uri, theme: logoTheme, fetched_content_type: fetchedContentType } }; } default: { // This should never be reached since we validate actions upfront throw new Error(`Unexpected action in switch: ${action}`); } } } /** * Handle tool execution * @param {string} toolName - Name of the tool to execute * @param {Object} args - Tool arguments * @param {Object} apiClient - Admin API client instance * @param {Object} authManager - Authentication manager instance * @returns {Promise<Object>} - Tool execution result */ export async function handleToolCall(toolName, args, apiClient, authManager) { let result; switch (toolName) { case 'hello_manage_app': { result = await handleManageApp(args, apiClient); break; } default: throw new Error(`Unknown tool: ${toolName}`); } // Format all tool responses consistently for MCP return createMCPContent(result); } /** * Upload logo binary data to the admin API * @param {string} publisherId - The publisher/team ID * @param {string} applicationId - The application ID * @param {string} logoFile - Base64 encoded logo file * @param {string} logoFilename - Generated filename for the logo * @param {string} logoContentType - MIME type of the logo * @param {Object} apiClient - Admin API client instance * @returns {Promise<Object>} - Upload result from admin API */ async function uploadLogoBinary(publisherId, applicationId, logoFile, logoContentType, apiClient) { // Convert base64 to buffer const buffer = Buffer.from(logoFile, 'base64'); // Create FormData - use the imported module const formData = new FormData(); // Add the file with placeholder filename and content type formData.append('file', buffer, { filename: 'placeholder', contentType: logoContentType }); // Make the API call with form data using a custom approach const path = `/api/v1/publishers/${publisherId}/applications/${applicationId}/logo`; try { // Import HELLO_ADMIN config and get access token properly const accessToken = apiClient.authManager.getAccessToken(); // Convert form-data to a format that works better with fetch return new Promise((resolve, reject) => { // Get the form data as buffer formData.getLength((err, length) => { if (err) { reject(new Error(`Failed to get form data length: ${err.message}`)); return; } const chunks = []; formData.on('data', chunk => { // Ensure chunk is a Buffer chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); formData.on('end', async () => { try { const buffer = Buffer.concat(chunks); const response = await fetch(`${HELLO_ADMIN}${path}`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': formData.getHeaders()['content-type'], 'Content-Length': length.toString() }, body: buffer }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); } resolve(await response.json()); } catch (error) { reject(new Error(`Logo upload failed: ${error.message}`)); } }); formData.on('error', (error) => { reject(new Error(`Form data error: ${error.message}`)); }); // Actually start reading the form data to trigger the events formData.resume(); }); }); } catch (error) { throw new Error(`Logo upload failed: ${error.message}`); } }