diff --git a/.changeset/spicy-animals-fail.md b/.changeset/spicy-animals-fail.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/spicy-animals-fail.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index e047c3422..1357dab38 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -55,6 +55,7 @@ import { } from '../../billing/stripe-client.js'; import { mergeOrganizations, previewMerge } from '../../db/org-merge-db.js'; import { workos } from '../../auth/workos-client.js'; +import { DomainDataState } from '@workos-inc/node'; const logger = createLogger('addie-admin-tools'); const orgDb = new OrganizationDatabase(); @@ -871,6 +872,49 @@ The goal: Every corporate email domain should map to a known organization (membe required: [], }, }, + { + name: 'manage_organization_domains', + description: `Add, remove, or list verified domains for an organization. + +Use this tool to: +- **List** all domains associated with an organization +- **Add** a new domain to an organization (e.g., when a company has multiple email domains) +- **Remove** a domain from an organization +- **Set primary** - designate which domain is the primary one for the organization + +Domains are synced to WorkOS, which means: +1. New users signing up with that email domain are auto-associated with the organization +2. The domain is marked as verified for SSO eligibility +3. Organization enrichment uses the primary domain for company lookups + +Note: Each domain can only belong to one organization. If a domain is already claimed by another org, the add operation will fail. + +The "primary domain" is a local concept used for enrichment and display - WorkOS treats all domains equally for user association.`, + usage_hints: 'Use "list" action first to see current domains. Add/remove sync to WorkOS; set_primary is local only.', + input_schema: { + type: 'object' as const, + properties: { + action: { + type: 'string', + enum: ['list', 'add', 'remove', 'set_primary'], + description: 'The action to perform: list (show all domains), add (add a new domain), remove (delete a domain), set_primary (make a domain the primary one)', + }, + organization_id: { + type: 'string', + description: 'The WorkOS organization ID. You can get this from lookup_organization or get_organization_details.', + }, + domain: { + type: 'string', + description: 'The domain to add, remove, or set as primary (required for add/remove/set_primary actions). Example: "acme.com"', + }, + set_as_primary: { + type: 'boolean', + description: 'When adding a domain, whether to set it as the primary domain (default: false)', + }, + }, + required: ['action', 'organization_id'], + }, + }, ]; /** @@ -3176,6 +3220,292 @@ export function createAdminToolHandlers( } }); + // Manage organization domains + handlers.set('manage_organization_domains', async (input) => { + const adminCheck = requireAdminFromContext(); + if (adminCheck) return adminCheck; + + const action = input.action as string; + const organizationId = input.organization_id as string; + const domain = input.domain as string | undefined; + const setAsPrimary = input.set_as_primary as boolean | undefined; + + if (!organizationId) { + return '❌ organization_id is required. Use lookup_organization to find the org ID first.'; + } + + if (!workos) { + return '❌ WorkOS is not configured. Domain management requires WorkOS to be set up.'; + } + + const pool = getPool(); + + try { + // Verify org exists + const orgResult = await pool.query( + `SELECT name, email_domain FROM organizations WHERE workos_organization_id = $1`, + [organizationId] + ); + + if (orgResult.rows.length === 0) { + return `❌ Organization not found with ID: ${organizationId}`; + } + + const orgName = orgResult.rows[0].name; + + switch (action) { + case 'list': { + const domainsResult = await pool.query( + `SELECT domain, is_primary, verified, source, created_at + FROM organization_domains + WHERE workos_organization_id = $1 + ORDER BY is_primary DESC, created_at ASC`, + [organizationId] + ); + + if (domainsResult.rows.length === 0) { + return `## Domains for ${orgName}\n\nNo domains configured for this organization.\n\nUse this tool with action "add" to add a domain.`; + } + + let response = `## Domains for ${orgName}\n\n`; + for (const row of domainsResult.rows) { + const badges: string[] = []; + if (row.is_primary) badges.push('⭐ Primary'); + if (row.verified) badges.push('✅ Verified'); + badges.push(`Source: ${row.source}`); + + response += `**${row.domain}** ${badges.join(' | ')}\n`; + } + response += `\n_Use action "add" to add a new domain, "remove" to delete one, or "set_primary" to change the primary domain._`; + return response; + } + + case 'add': { + if (!domain) { + return '❌ domain is required for the "add" action. Example: "acme.com"'; + } + + const normalizedDomain = domain.toLowerCase().trim(); + + // Validate domain format + const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z]{2,})+$/; + if (!domainRegex.test(normalizedDomain)) { + return `❌ Invalid domain format: "${normalizedDomain}". Expected format: "example.com" or "sub.example.com"`; + } + + // Check if domain is already claimed locally + const existingResult = await pool.query( + `SELECT od.workos_organization_id, o.name as org_name + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE od.domain = $1`, + [normalizedDomain] + ); + + if (existingResult.rows.length > 0) { + const existingOrg = existingResult.rows[0]; + if (existingOrg.workos_organization_id === organizationId) { + return `ℹ️ Domain **${normalizedDomain}** is already associated with ${orgName}.`; + } + return `❌ Domain **${normalizedDomain}** is already claimed by **${existingOrg.org_name}**.\n\nIf these organizations should be merged, use the merge_organizations tool.`; + } + + // First, sync to WorkOS - this is required for user auto-association + try { + // Get existing domains from WorkOS to append the new one + const workosOrg = await workos.organizations.getOrganization(organizationId); + const existingDomains = workosOrg.domains.map(d => ({ + domain: d.domain, + state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending + })); + + // Add the new domain + await workos.organizations.updateOrganization({ + organization: organizationId, + domainData: [...existingDomains, { domain: normalizedDomain, state: DomainDataState.Verified }], + }); + } catch (workosErr) { + logger.error({ err: workosErr, domain: normalizedDomain, organizationId }, 'Failed to add domain to WorkOS'); + return `❌ Failed to add domain **${normalizedDomain}** to WorkOS. Error: ${workosErr instanceof Error ? workosErr.message : 'Unknown error'}`; + } + + // If setting as primary, clear existing primary first + if (setAsPrimary) { + await pool.query( + `UPDATE organization_domains SET is_primary = false, updated_at = NOW() + WHERE workos_organization_id = $1 AND is_primary = true`, + [organizationId] + ); + } + + // Insert/update the domain in local DB (WorkOS webhook will also do this, but let's be explicit) + await pool.query( + `INSERT INTO organization_domains (workos_organization_id, domain, is_primary, verified, source) + VALUES ($1, $2, $3, true, 'workos') + ON CONFLICT (workos_organization_id, domain) DO UPDATE SET + is_primary = EXCLUDED.is_primary, + verified = true, + source = 'workos', + updated_at = NOW()`, + [organizationId, normalizedDomain, setAsPrimary || false] + ); + + // If primary, also update the email_domain column + if (setAsPrimary) { + await pool.query( + `UPDATE organizations SET email_domain = $1, updated_at = NOW() + WHERE workos_organization_id = $2`, + [normalizedDomain, organizationId] + ); + } + + logger.info({ organizationId, domain: normalizedDomain, setAsPrimary }, 'Addie: Added domain to organization via WorkOS'); + + let response = `✅ Added domain **${normalizedDomain}** to ${orgName} and synced to WorkOS`; + if (setAsPrimary) response += ' (set as primary)'; + response += '.\n\nUsers signing up with @' + normalizedDomain + ' emails will now be auto-associated with this organization.'; + return response; + } + + case 'remove': { + if (!domain) { + return '❌ domain is required for the "remove" action.'; + } + + const normalizedDomain = domain.toLowerCase().trim(); + + // Get domain info before deletion + const domainResult = await pool.query( + `SELECT is_primary, source FROM organization_domains + WHERE workos_organization_id = $1 AND domain = $2`, + [organizationId, normalizedDomain] + ); + + if (domainResult.rows.length === 0) { + return `❌ Domain **${normalizedDomain}** not found for ${orgName}.`; + } + + const wasPrimary = domainResult.rows[0].is_primary; + + // First, remove from WorkOS + try { + const workosOrg = await workos.organizations.getOrganization(organizationId); + const remainingDomains = workosOrg.domains + .filter(d => d.domain.toLowerCase() !== normalizedDomain) + .map(d => ({ + domain: d.domain, + state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending + })); + + await workos.organizations.updateOrganization({ + organization: organizationId, + domainData: remainingDomains, + }); + } catch (workosErr) { + logger.error({ err: workosErr, domain: normalizedDomain, organizationId }, 'Failed to remove domain from WorkOS'); + return `❌ Failed to remove domain **${normalizedDomain}** from WorkOS. Error: ${workosErr instanceof Error ? workosErr.message : 'Unknown error'}`; + } + + // Delete the domain from local DB + await pool.query( + `DELETE FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, + [organizationId, normalizedDomain] + ); + + // If we deleted the primary domain, pick a new one + let newPrimary: string | null = null; + if (wasPrimary) { + const remaining = await pool.query( + `SELECT domain FROM organization_domains + WHERE workos_organization_id = $1 + ORDER BY verified DESC, created_at ASC + LIMIT 1`, + [organizationId] + ); + + newPrimary = remaining.rows.length > 0 ? remaining.rows[0].domain : null; + + if (newPrimary) { + await pool.query( + `UPDATE organization_domains SET is_primary = true, updated_at = NOW() + WHERE workos_organization_id = $1 AND domain = $2`, + [organizationId, newPrimary] + ); + } + + await pool.query( + `UPDATE organizations SET email_domain = $1, updated_at = NOW() + WHERE workos_organization_id = $2`, + [newPrimary, organizationId] + ); + } + + logger.info({ organizationId, domain: normalizedDomain, wasPrimary, newPrimary }, 'Addie: Removed domain from organization via WorkOS'); + + let response = `✅ Removed domain **${normalizedDomain}** from ${orgName} and WorkOS`; + if (wasPrimary && newPrimary) { + response += `. New primary domain: **${newPrimary}**`; + } else if (wasPrimary) { + response += '. No domains remaining.'; + } + response += '\n\nUsers signing up with @' + normalizedDomain + ' emails will no longer be auto-associated with this organization.'; + return response; + } + + case 'set_primary': { + if (!domain) { + return '❌ domain is required for the "set_primary" action.'; + } + + const normalizedDomain = domain.toLowerCase().trim(); + + // Verify domain belongs to this org + const domainResult = await pool.query( + `SELECT domain FROM organization_domains + WHERE workos_organization_id = $1 AND domain = $2`, + [organizationId, normalizedDomain] + ); + + if (domainResult.rows.length === 0) { + return `❌ Domain **${normalizedDomain}** not found for ${orgName}. Use action "add" first.`; + } + + // Clear existing primary + await pool.query( + `UPDATE organization_domains SET is_primary = false, updated_at = NOW() + WHERE workos_organization_id = $1 AND is_primary = true`, + [organizationId] + ); + + // Set new primary + await pool.query( + `UPDATE organization_domains SET is_primary = true, updated_at = NOW() + WHERE workos_organization_id = $1 AND domain = $2`, + [organizationId, normalizedDomain] + ); + + // Update organizations.email_domain + await pool.query( + `UPDATE organizations SET email_domain = $1, updated_at = NOW() + WHERE workos_organization_id = $2`, + [normalizedDomain, organizationId] + ); + + logger.info({ organizationId, domain: normalizedDomain }, 'Addie: Set primary domain for organization'); + + return `✅ Set **${normalizedDomain}** as the primary domain for ${orgName}.`; + } + + default: + return `❌ Unknown action: ${action}. Valid actions are: list, add, remove, set_primary`; + } + } catch (error) { + logger.error({ error, organizationId, action }, 'Error managing organization domains'); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return `❌ Failed to ${action} domain: ${errorMessage}`; + } + }); + // Check domain health handlers.set('check_domain_health', async (input) => { const adminCheck = requireAdminFromContext(); diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index ce8e9bfce..a9729a80d 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -869,6 +869,7 @@ export function setupDomainRoutes( ); // POST /api/admin/organizations/:orgId/domains - Add a domain to an organization + // Writes to WorkOS first, then local DB is updated via webhook (or immediately for consistency) apiRouter.post( "/organizations/:orgId/domains", requireAuth, @@ -876,13 +877,27 @@ export function setupDomainRoutes( async (req, res) => { try { const { orgId } = req.params; - const { domain, is_primary, sync_to_workos } = req.body; + const { domain, is_primary } = req.body; if (!domain) { return res.status(400).json({ error: "domain is required" }); } + if (!workos) { + return res.status(500).json({ error: "WorkOS not configured" }); + } + const normalizedDomain = domain.toLowerCase().trim(); + + // Validate domain format + const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z]{2,})+$/; + if (!domainRegex.test(normalizedDomain)) { + return res.status(400).json({ + error: "Invalid domain format", + message: `"${normalizedDomain}" is not a valid domain. Expected format: "example.com" or "sub.example.com"`, + }); + } + const pool = getPool(); // Verify org exists @@ -895,7 +910,7 @@ export function setupDomainRoutes( return res.status(404).json({ error: "Organization not found" }); } - // Check if domain is already claimed by another org + // Check if domain is already claimed by another org locally const existingResult = await pool.query( `SELECT od.workos_organization_id, o.name as org_name FROM organization_domains od @@ -921,6 +936,26 @@ export function setupDomainRoutes( }); } + // Add to WorkOS first - this is the source of truth + try { + const workosOrg = await workos.organizations.getOrganization(orgId); + const existingDomains = workosOrg.domains.map(d => ({ + domain: d.domain, + state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending + })); + + await workos.organizations.updateOrganization({ + organization: orgId, + domainData: [...existingDomains, { domain: normalizedDomain, state: DomainDataState.Verified }], + }); + } catch (workosErr) { + logger.error({ err: workosErr, domain: normalizedDomain, orgId }, "Failed to add domain to WorkOS"); + return res.status(500).json({ + error: "WorkOS error", + message: `Failed to add domain to WorkOS: ${workosErr instanceof Error ? workosErr.message : 'Unknown error'}`, + }); + } + // If setting as primary, clear existing primary first if (is_primary) { await pool.query( @@ -930,10 +965,15 @@ export function setupDomainRoutes( ); } - // Insert the domain + // Insert/update local DB immediately (webhook will also do this, but for immediate consistency) await pool.query( `INSERT INTO organization_domains (workos_organization_id, domain, is_primary, verified, source) - VALUES ($1, $2, $3, false, 'manual')`, + VALUES ($1, $2, $3, true, 'workos') + ON CONFLICT (workos_organization_id, domain) DO UPDATE SET + is_primary = EXCLUDED.is_primary, + verified = true, + source = 'workos', + updated_at = NOW()`, [orgId, normalizedDomain, is_primary || false] ); @@ -946,30 +986,13 @@ export function setupDomainRoutes( ); } - // Optionally sync to WorkOS - if (sync_to_workos && workos) { - try { - await workos.organizations.updateOrganization({ - organization: orgId, - domainData: [{ domain: normalizedDomain, state: DomainDataState.Verified }], - }); - // Mark as verified since we added it to WorkOS - await pool.query( - `UPDATE organization_domains SET verified = true, source = 'workos', updated_at = NOW() - WHERE workos_organization_id = $1 AND domain = $2`, - [orgId, normalizedDomain] - ); - } catch (workosErr) { - logger.warn({ err: workosErr, domain: normalizedDomain }, "Failed to sync domain to WorkOS"); - } - } - - logger.info({ orgId, domain: normalizedDomain, isPrimary: is_primary }, "Added domain to organization"); + logger.info({ orgId, domain: normalizedDomain, isPrimary: is_primary }, "Added domain to organization via WorkOS"); res.json({ success: true, domain: normalizedDomain, is_primary: is_primary || false, + synced_to_workos: true, }); } catch (error) { logger.error({ err: error }, "Error adding organization domain"); @@ -982,6 +1005,7 @@ export function setupDomainRoutes( ); // DELETE /api/admin/organizations/:orgId/domains/:domain - Remove a domain from an organization + // Removes from WorkOS first, then local DB is updated via webhook (or immediately for consistency) apiRouter.delete( "/organizations/:orgId/domains/:domain", requireAuth, @@ -992,6 +1016,10 @@ export function setupDomainRoutes( const normalizedDomain = domain.toLowerCase().trim(); const pool = getPool(); + if (!workos) { + return res.status(500).json({ error: "WorkOS not configured" }); + } + // Get domain info before deletion const domainResult = await pool.query( `SELECT is_primary, source FROM organization_domains @@ -1005,13 +1033,36 @@ export function setupDomainRoutes( const wasPrimary = domainResult.rows[0].is_primary; - // Delete the domain + // Remove from WorkOS first - this is the source of truth + try { + const workosOrg = await workos.organizations.getOrganization(orgId); + const remainingDomains = workosOrg.domains + .filter(d => d.domain.toLowerCase() !== normalizedDomain) + .map(d => ({ + domain: d.domain, + state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending + })); + + await workos.organizations.updateOrganization({ + organization: orgId, + domainData: remainingDomains, + }); + } catch (workosErr) { + logger.error({ err: workosErr, domain: normalizedDomain, orgId }, "Failed to remove domain from WorkOS"); + return res.status(500).json({ + error: "WorkOS error", + message: `Failed to remove domain from WorkOS: ${workosErr instanceof Error ? workosErr.message : 'Unknown error'}`, + }); + } + + // Delete from local DB await pool.query( `DELETE FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, [orgId, normalizedDomain] ); // If we deleted the primary domain, pick a new one + let newPrimary: string | null = null; if (wasPrimary) { const remaining = await pool.query( `SELECT domain FROM organization_domains @@ -1021,7 +1072,7 @@ export function setupDomainRoutes( [orgId] ); - const newPrimary = remaining.rows.length > 0 ? remaining.rows[0].domain : null; + newPrimary = remaining.rows.length > 0 ? remaining.rows[0].domain : null; if (newPrimary) { await pool.query( @@ -1038,12 +1089,13 @@ export function setupDomainRoutes( ); } - logger.info({ orgId, domain: normalizedDomain, wasPrimary }, "Removed domain from organization"); + logger.info({ orgId, domain: normalizedDomain, wasPrimary, newPrimary }, "Removed domain from organization via WorkOS"); res.json({ success: true, domain: normalizedDomain, was_primary: wasPrimary, + new_primary: newPrimary, }); } catch (error) { logger.error({ err: error }, "Error removing organization domain");