From b6525c0a6c8e59ed606c07502d51bbb5a47ed866 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 25 Apr 2026 19:55:24 -0400 Subject: [PATCH] feat(membership): auto-provision verified-domain users + promote-by-email Closes the gap surfaced by the Triton Digital escalation: an org owner tried to promote a colleague to admin, but the system 404'd because the colleague wasn't yet in the org's WorkOS membership cache. Server changes: - autoLinkByVerifiedDomain runs every authenticated request and on user.created webhook; helper short-circuits when the user is already in the candidate org's local cache. Always provisions as 'member'; auto-promotion to owner for ownerless orgs is delegated to the existing race-safe upsertOrganizationMembership SQL. - New auto_provision_verified_domain column on organizations (default true). Org admins/owners flip it via PATCH /api/organizations/:orgId/settings. - New POST /api/organizations/:orgId/members/by-email walks the four state machine (invite / create / update / no-op). Authz: org admin/ owner can add members; only org owner or AAO super-admin can change an existing role or assign 'owner'. Invite path always uses roleSlug='member' (matches the routes/invites.ts downgrade discipline); admins promote after accept via the same endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/auto-provision-verified-domain.md | 18 + server/src/db/membership-db.ts | 45 ++- .../433_auto_provision_verified_domain.sql | 12 + server/src/db/organization-db.ts | 2 + server/src/http.ts | 17 +- server/src/routes/member-profiles.ts | 46 ++- server/src/routes/organizations.ts | 345 +++++++++++++++++- server/src/routes/workos-webhooks.ts | 18 + .../integration/membership-webhook.test.ts | 66 +++- 9 files changed, 512 insertions(+), 57 deletions(-) create mode 100644 .changeset/auto-provision-verified-domain.md create mode 100644 server/src/db/migrations/433_auto_provision_verified_domain.sql diff --git a/.changeset/auto-provision-verified-domain.md b/.changeset/auto-provision-verified-domain.md new file mode 100644 index 0000000000..2e45f9fb33 --- /dev/null +++ b/.changeset/auto-provision-verified-domain.md @@ -0,0 +1,18 @@ +--- +--- + +Auto-provision users into verified-domain orgs more aggressively, give org admins a self-service way to manage members by email, and let them opt out per org. + +Closes the gap surfaced by the Triton Digital escalation: an org owner tried to promote someone with a verified-domain email to admin, but the system 404'd because the user wasn't yet a member of the org in WorkOS. + +## Server changes + +- `autoLinkByVerifiedDomain` runs on every authenticated request. The helper short-circuits internally when the user already has a row in the candidate org's local membership cache, so the cost stays close to one indexed query. Always provisions as `member`; the existing race-safe `upsertOrganizationMembership` SQL handles auto-promotion to `owner` for ownerless orgs (atomically, against the live table — no cache-skew risk). +- New `user.created` webhook step provisions users with verified emails into their employer's verified-domain org proactively, instead of lazily on first API hit. +- New `auto_provision_verified_domain` column on `organizations` (default `true`) — org owners and admins can flip it via `PATCH /api/organizations/:orgId/settings` to require explicit invites only. +- New `POST /api/organizations/:orgId/members/by-email` endpoint walks the four-state machine for callers (invite / create membership / update role / no-op). Authz mirrors the existing patterns: org admin/owner OR AAO super-admin can add members; only org owner OR AAO super-admin can change an existing member's role; only owner or AAO super-admin can assign the owner role. +- The invite path always invites as `member` regardless of the requested role (matching the bearer-credential downgrade discipline used in `routes/invites.ts`); admins promote after acceptance via the same endpoint. + +## Migration + +`433_auto_provision_verified_domain.sql` adds the opt-out flag with default `TRUE`. diff --git a/server/src/db/membership-db.ts b/server/src/db/membership-db.ts index 2884b0ca56..c6fbfb73f6 100644 --- a/server/src/db/membership-db.ts +++ b/server/src/db/membership-db.ts @@ -207,13 +207,16 @@ export interface DomainLinkResult { } /** - * When a user has no WorkOS organization memberships, check whether their - * email domain matches a verified domain on an organization with an active - * subscription. If so, create the WorkOS membership automatically. + * Check whether a user's email domain matches a verified domain on an + * organization with an active subscription. If so, create a WorkOS membership + * for them. * - * This closes the gap where a subscription is purchased for an org but the - * user was never added as a member in WorkOS (e.g. webhook failure, manual - * provisioning that skipped the membership step). + * Idempotent: short-circuits when the user is already in the candidate org's + * local membership cache, and treats `organization_membership_already_exists` + * from WorkOS as success. Safe to call on every authenticated request. + * + * Honors the per-org `auto_provision_verified_domain` opt-out: orgs that + * prefer explicit invites only set this to false. */ export async function autoLinkByVerifiedDomain( workos: WorkOS, @@ -224,11 +227,10 @@ export async function autoLinkByVerifiedDomain( const emailDomain = email.split('@')[1]?.toLowerCase(); if (!emailDomain) return null; - // Find an org with a verified domain matching the user's email and an active subscription const result = await pool.query<{ workos_organization_id: string; org_name: string; - has_admin: boolean; + user_already_member: boolean; }>(` SELECT od.workos_organization_id, @@ -236,31 +238,42 @@ export async function autoLinkByVerifiedDomain( EXISTS ( SELECT 1 FROM organization_memberships om WHERE om.workos_organization_id = od.workos_organization_id - AND om.role IN ('admin', 'owner') - ) AS has_admin + AND om.workos_user_id = $2 + ) AS user_already_member FROM organization_domains od JOIN organizations o ON o.workos_organization_id = od.workos_organization_id WHERE LOWER(od.domain) = $1 AND od.verified = true AND o.subscription_status = 'active' AND o.subscription_canceled_at IS NULL + AND COALESCE(o.auto_provision_verified_domain, true) = true LIMIT 1 - `, [emailDomain]); + `, [emailDomain, userId]); if (result.rows.length === 0) return null; - const { workos_organization_id: orgId, org_name: orgName, has_admin: hasAdmin } = result.rows[0]; - const role = hasAdmin ? 'member' : 'owner'; + const { + workos_organization_id: orgId, + org_name: orgName, + user_already_member: userAlreadyMember, + } = result.rows[0]; + + if (userAlreadyMember) return null; + // Always create as member. Auto-promotion to owner for ownerless orgs is + // handled atomically by upsertOrganizationMembership when the + // organization_membership.created webhook fires — that path uses a NOT EXISTS + // subquery against the live membership table, which is race-safe and not + // vulnerable to the local-cache skew that a `has_admin` lookup here would be. try { await workos.userManagement.createOrganizationMembership({ userId, organizationId: orgId, - roleSlug: role, + roleSlug: 'member', }); - logger.info({ userId, email, orgId, orgName, role }, 'Auto-linked user to organization via verified domain'); - return { organizationId: orgId, organizationName: orgName, role }; + logger.info({ userId, email, orgId, orgName }, 'Auto-linked user to organization via verified domain'); + return { organizationId: orgId, organizationName: orgName, role: 'member' }; } catch (err: any) { if (err?.code === 'organization_membership_already_exists') { // Membership exists but wasn't returned by list — return as success diff --git a/server/src/db/migrations/433_auto_provision_verified_domain.sql b/server/src/db/migrations/433_auto_provision_verified_domain.sql new file mode 100644 index 0000000000..cf753e6d66 --- /dev/null +++ b/server/src/db/migrations/433_auto_provision_verified_domain.sql @@ -0,0 +1,12 @@ +-- Auto-provision verified-domain users into orgs. +-- +-- When a user signs in with an email whose domain is verified on an +-- organization with an active subscription, autoLinkByVerifiedDomain creates +-- a WorkOS membership for them. This flag lets an org owner opt out — useful +-- for orgs that prefer explicit invites only (e.g. regulated industries). + +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS auto_provision_verified_domain BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN organizations.auto_provision_verified_domain IS + 'When true (default), users whose email domain is verified on this org are auto-added as members on first authenticated request or user.created webhook. When false, only explicit invites grant membership.'; diff --git a/server/src/db/organization-db.ts b/server/src/db/organization-db.ts index 6077a2253d..f6e4d5d1ca 100644 --- a/server/src/db/organization-db.ts +++ b/server/src/db/organization-db.ts @@ -142,6 +142,7 @@ export interface Organization { stripe_coupon_id: string | null; stripe_promotion_code: string | null; billing_address: BillingAddress | null; + auto_provision_verified_domain: boolean; created_at: Date; updated_at: Date; } @@ -859,6 +860,7 @@ export class OrganizationDatabase { stripe_coupon_id: 'stripe_coupon_id', stripe_promotion_code: 'stripe_promotion_code', billing_address: 'billing_address', + auto_provision_verified_domain: 'auto_provision_verified_domain', }; const setClauses: string[] = []; diff --git a/server/src/http.ts b/server/src/http.ts index b5b1af7c0f..41a15b9157 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -6685,15 +6685,14 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}< statuses: ['active'], }); - // Auto-link: if no memberships, check for verified domain match - if (memberships.data.length === 0) { - const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); - if (linked) { - memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - statuses: ['active'], - }); - } + // Auto-link any verified-domain orgs the user isn't yet in. + // Helper short-circuits when the user is already a cached member. + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + statuses: ['active'], + }); } // Map memberships to organization details with roles diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts index 63a5588095..c1c6b9e1a9 100644 --- a/server/src/routes/member-profiles.ts +++ b/server/src/routes/member-profiles.ts @@ -118,15 +118,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro userId: user.id, }); - // Auto-link: if no memberships, check for verified domain match - if (memberships.data.length === 0) { - const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); - if (linked) { - // Re-fetch memberships after auto-link - memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - } + // Auto-link any verified-domain orgs the user isn't yet in. + // Helper short-circuits when the user is already a cached member. + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); } if (memberships.data.length === 0) { @@ -250,14 +248,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro userId: user.id, }); - // Auto-link: if no memberships, check for verified domain match - if (memberships.data.length === 0) { - const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); - if (linked) { - memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - } + // Auto-link any verified-domain orgs the user isn't yet in. + // Helper short-circuits when the user is already a cached member. + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); } if (memberships.data.length === 0) { @@ -465,14 +462,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro userId: user.id, }); - // Auto-link: if no memberships, check for verified domain match - if (memberships.data.length === 0) { - const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); - if (linked) { - memberships = await workos!.userManagement.listOrganizationMemberships({ - userId: user.id, - }); - } + // Auto-link any verified-domain orgs the user isn't yet in. + // Helper short-circuits when the user is already a cached member. + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); } if (memberships.data.length === 0) { diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts index a215ca9b55..509d727555 100644 --- a/server/src/routes/organizations.ts +++ b/server/src/routes/organizations.ts @@ -25,6 +25,8 @@ import * as referralDb from "../db/referral-codes-db.js"; import { SlackDatabase } from "../db/slack-db.js"; import { getCompanyDomain } from "../utils/email-domain.js"; import { resolveUserRole } from "../utils/resolve-user-role.js"; +import { isValidWorkOSMembershipId } from "../utils/workos-validation.js"; +import { isWebUserAAOAdmin } from "../addie/mcp/admin-tools.js"; import { createStripeCustomer, createCustomerPortalSession, @@ -1568,12 +1570,12 @@ export function createOrganizationsRouter(): Router { } }); - // PATCH /api/organizations/:orgId/settings - Update organization settings (company_type, revenue_tier) + // PATCH /api/organizations/:orgId/settings - Update organization settings (company_type, revenue_tier, auto_provision_verified_domain) router.patch('/:orgId/settings', requireAuth, async (req, res) => { try { const user = req.user!; const { orgId } = req.params; - const { company_type, revenue_tier } = req.body; + const { company_type, revenue_tier, auto_provision_verified_domain } = req.body; // Verify user is member of this organization with owner or admin role const memberships = await workos!.userManagement.listOrganizationMemberships({ @@ -1629,10 +1631,19 @@ export function createOrganizationsRouter(): Router { }); } + // Validate auto_provision_verified_domain if provided + if (auto_provision_verified_domain !== undefined && typeof auto_provision_verified_domain !== 'boolean') { + return res.status(400).json({ + error: 'Invalid auto_provision_verified_domain', + message: 'auto_provision_verified_domain must be a boolean', + }); + } + // Build updates object with properly typed values const updates: { company_type?: CompanyType | null; revenue_tier?: RevenueTier | null; + auto_provision_verified_domain?: boolean; } = {}; if (company_type !== undefined) { updates.company_type = company_type as CompanyType | null; @@ -1640,11 +1651,14 @@ export function createOrganizationsRouter(): Router { if (revenue_tier !== undefined) { updates.revenue_tier = revenue_tier as RevenueTier | null; } + if (auto_provision_verified_domain !== undefined) { + updates.auto_provision_verified_domain = auto_provision_verified_domain; + } if (Object.keys(updates).length === 0) { return res.status(400).json({ error: 'No updates provided', - message: 'Provide company_type or revenue_tier to update', + message: 'Provide company_type, revenue_tier, or auto_provision_verified_domain to update', }); } @@ -1667,6 +1681,9 @@ export function createOrganizationsRouter(): Router { success: true, company_type: company_type !== undefined ? company_type : org.company_type, revenue_tier: revenue_tier !== undefined ? revenue_tier : org.revenue_tier, + auto_provision_verified_domain: auto_provision_verified_domain !== undefined + ? auto_provision_verified_domain + : org.auto_provision_verified_domain, }); } catch (error) { logger.error({ err: error }, 'Update organization settings error'); @@ -2587,6 +2604,328 @@ export function createOrganizationsRouter(): Router { } }); + /** + * POST /api/organizations/:orgId/members/by-email + * + * Add or promote a member by email. Walks the four-state machine so callers + * don't need to know whether the user has a WorkOS account, whether they're + * already a member, or how WorkOS membership IDs work: + * - WorkOS user not found -> sendInvitation (always as member; promote after accept) + * - User found, no membership in org -> createOrganizationMembership with target role + * - User found, already in this role -> no_change + * - User found, different role -> updateOrganizationMembership + * + * Authz mirrors the existing patterns: + * - Adding a new member: org admin/owner OR AAO super-admin + * - Updating an existing member's role: org owner OR AAO super-admin + * - Org admins capped at 'admin'/'member' roles. Only owners and AAO + * super-admins can assign 'owner'. + */ + router.post('/:orgId/members/by-email', requireAuth, async (req, res) => { + const user = req.user!; + const { orgId } = req.params; + const body = (req.body ?? {}) as Record; + const email = body.email; + const requestedRole = body.role ?? 'member'; + + if (typeof email !== 'string') { + return res.status(400).json({ error: 'Missing required field', message: 'email is required' }); + } + + const emailValidation = validateEmail(email); + if (!emailValidation.valid) { + return res.status(400).json({ error: 'Invalid email', message: emailValidation.error }); + } + + if (typeof requestedRole !== 'string' || !VALID_ORGANIZATION_ROLES.includes(requestedRole as any)) { + return res.status(400).json({ + error: 'Invalid role', + message: `Role must be one of: ${VALID_ORGANIZATION_ROLES.join(', ')}`, + }); + } + const role = requestedRole as 'owner' | 'admin' | 'member'; + + const normalizedEmail = email.trim().toLowerCase(); + + try { + const localOrg = await orgDb.getOrganization(orgId); + if (!localOrg) { + return res.status(404).json({ error: 'Organization not found' }); + } + if (localOrg.is_personal) { + return res.status(400).json({ + error: 'Personal workspace', + message: 'Personal workspaces cannot have team members. Convert to a team workspace first.', + }); + } + + // Resolve caller authority: org role + AAO super-admin override + const callerMemberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + organizationId: orgId, + }); + const callerOrgRole = resolveUserRole(callerMemberships.data); + const isAAOAdmin = await isWebUserAAOAdmin(user.id); + + const isOrgAdminOrOwner = callerOrgRole === 'admin' || callerOrgRole === 'owner'; + const isOrgOwner = callerOrgRole === 'owner'; + + if (!isAAOAdmin && callerMemberships.data.length === 0) { + return res.status(403).json({ + error: 'Access denied', + message: 'You are not a member of this organization', + }); + } + if (!isAAOAdmin && !isOrgAdminOrOwner) { + return res.status(403).json({ + error: 'Insufficient permissions', + message: 'Only admins and owners can manage members', + }); + } + // Org admins (non-owner, non-AAO) cannot assign 'owner' + if (role === 'owner' && !isAAOAdmin && !isOrgOwner) { + return res.status(403).json({ + error: 'Insufficient permissions', + message: 'Only owners can assign the owner role', + }); + } + + const userLookup = await workos!.userManagement.listUsers({ email: normalizedEmail }); + const workosUser = userLookup.data.find((u) => u.email.toLowerCase() === normalizedEmail); + + // Path 1: WorkOS user does not exist yet — invite as member only. + // + // The WorkOS-hosted invite-accept page would honor whatever roleSlug we pass, + // but invite tokens are bearer credentials (forwarded mail, leaked links). + // We always invite as 'member' and require an explicit promote step after + // acceptance — same discipline as routes/invites.ts uses on the AAO-internal + // accept flow. + if (!workosUser) { + const seatCheck = await canAddSeat(orgId, 'community_only'); + if (!seatCheck.allowed) { + return res.status(403).json({ error: 'Seat limit reached', message: seatCheck.reason }); + } + + const invitation = await workos!.userManagement.sendInvitation({ + email: normalizedEmail, + organizationId: orgId, + inviterUserId: user.id, + roleSlug: 'member', + }); + + await orgDb.recordAuditLog({ + workos_organization_id: orgId, + workos_user_id: user.id, + action: 'member_invited', + resource_type: 'invitation', + resource_id: invitation.id, + details: { + email: normalizedEmail, + requested_role: role, + invited_role: 'member', + inviter_email: user.email, + via: 'by_email', + }, + }); + + logger.info( + { orgId, email: normalizedEmail, requestedRole: role, inviterId: user.id }, + 'Invited member by email (no WorkOS account yet)', + ); + + const promoteHint = role !== 'member' + ? ` After they accept, call this endpoint again to promote them to ${role}.` + : ''; + return res.status(201).json({ + success: true, + action: 'invited', + message: `Invitation sent to ${normalizedEmail} as member.${promoteHint}`, + invited_role: 'member', + requested_role: role, + invitation: { + id: invitation.id, + email: invitation.email, + state: invitation.state, + }, + }); + } + + const targetUserId = workosUser.id; + const pool = getPool(); + + const existingRow = await pool.query<{ + workos_membership_id: string | null; + role: string | null; + }>( + `SELECT workos_membership_id, role + FROM organization_memberships + WHERE workos_organization_id = $1 AND workos_user_id = $2`, + [orgId, targetUserId], + ); + + // Path 2: user exists but is not yet a member — create membership + if (existingRow.rows.length === 0) { + const seatCheck = await canAddSeat(orgId, 'community_only'); + if (!seatCheck.allowed) { + return res.status(403).json({ error: 'Seat limit reached', message: seatCheck.reason }); + } + + let membership; + try { + membership = await workos!.userManagement.createOrganizationMembership({ + userId: targetUserId, + organizationId: orgId, + roleSlug: role, + }); + } catch (createErr) { + const code = (createErr as { code?: string }).code; + if (code === 'organization_membership_already_exists') { + return res.status(409).json({ + error: 'Membership already exists', + message: 'This user was just added. Try again to update their role.', + }); + } + throw createErr; + } + + await orgDb.recordAuditLog({ + workos_organization_id: orgId, + workos_user_id: user.id, + action: 'member_added', + resource_type: 'membership', + resource_id: membership.id, + details: { + target_user_id: targetUserId, + target_email: normalizedEmail, + role, + actor_email: user.email, + via: 'by_email', + }, + }); + + logger.info( + { orgId, targetUserId, email: normalizedEmail, role, actorId: user.id }, + 'Added member by email', + ); + + return res.status(201).json({ + success: true, + action: 'membership_created', + message: `Added ${normalizedEmail} to the organization as ${role}.`, + user_id: targetUserId, + role, + }); + } + + // Path 3: user is already a member — update role if it differs. + // Treat NULL local role the same as 'member' for comparison, but log the + // raw value in the audit row so a NULL doesn't get silently rewritten as + // 'member' in the trail. + const rawCurrentRole = existingRow.rows[0].role; + const effectiveCurrentRole = rawCurrentRole || 'member'; + if (effectiveCurrentRole === role) { + return res.json({ + success: true, + action: 'no_change', + message: `${normalizedEmail} is already a ${role}.`, + user_id: targetUserId, + role, + }); + } + + // Role-update authorization: only owner or AAO super-admin (matches the + // existing PATCH /:orgId/members/:membershipId endpoint policy). + if (!isAAOAdmin && !isOrgOwner) { + return res.status(403).json({ + error: 'Insufficient permissions', + message: "Only owners can change existing members' roles", + }); + } + + // Resolve membership ID (backfill from WorkOS if local cache is missing it) + let membershipId = existingRow.rows[0].workos_membership_id; + if (!membershipId) { + try { + const memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: targetUserId, + organizationId: orgId, + }); + const match = memberships.data.find( + (m) => m.userId === targetUserId && m.organizationId === orgId, + ); + if (!match || !isValidWorkOSMembershipId(match.id)) { + return res.status(400).json({ + error: 'Cannot update role: membership not found in WorkOS', + }); + } + membershipId = match.id; + await pool.query( + `UPDATE organization_memberships SET workos_membership_id = $1 + WHERE workos_organization_id = $2 AND workos_user_id = $3`, + [membershipId, orgId, targetUserId], + ); + } catch (lookupErr) { + logger.error( + { err: lookupErr, orgId, targetUserId, email: normalizedEmail }, + 'Failed to look up membership from WorkOS for backfill', + ); + return res.status(400).json({ + error: 'Cannot update role: unable to resolve WorkOS membership', + }); + } + } + + await workos!.userManagement.updateOrganizationMembership(membershipId, { roleSlug: role }); + + await pool.query( + `UPDATE organization_memberships + SET role = $1, updated_at = NOW() + WHERE workos_organization_id = $2 AND workos_user_id = $3`, + [role, orgId, targetUserId], + ); + + await orgDb.recordAuditLog({ + workos_organization_id: orgId, + workos_user_id: user.id, + action: 'member_role_changed', + resource_type: 'membership', + resource_id: membershipId, + details: { + target_user_id: targetUserId, + target_email: normalizedEmail, + old_role: rawCurrentRole, + new_role: role, + actor_email: user.email, + via: 'by_email', + }, + }); + + logger.info( + { orgId, targetUserId, email: normalizedEmail, oldRole: rawCurrentRole, newRole: role, actorId: user.id }, + 'Updated member role by email', + ); + + return res.json({ + success: true, + action: 'role_updated', + message: `Updated ${normalizedEmail} to ${role}.`, + user_id: targetUserId, + role, + previous_role: rawCurrentRole, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + { err: error, errorMessage, orgId, email: normalizedEmail, role }, + 'Error in members/by-email', + ); + return res.status(500).json({ + error: 'Internal server error', + message: 'Unable to add or promote member. Please try again or contact support.', + }); + } + }); + // PATCH /api/organizations/:orgId/members/:membershipId - Update member role and/or seat type router.patch('/:orgId/members/:membershipId', requireAuth, async (req, res) => { try { diff --git a/server/src/routes/workos-webhooks.ts b/server/src/routes/workos-webhooks.ts index 13d8d6506a..630525e93d 100644 --- a/server/src/routes/workos-webhooks.ts +++ b/server/src/routes/workos-webhooks.ts @@ -36,6 +36,7 @@ import { consumeInvitationSeatType, findSuccessorForPromotion, setMembershipRole, + autoLinkByVerifiedDomain, } from '../db/membership-db.js'; const logger = createLogger('workos-webhooks'); @@ -801,6 +802,23 @@ export function createWorkOSWebhooksRouter(): Router { 'Auto-linked new website user to Slack account' ); } + // Auto-provision into a verified-domain org if one matches. + // Verified email is the trust gate: skip when WorkOS hasn't confirmed it yet + // (the /api/me/* paths will retry after the user signs in and the email verifies). + if (user.email_verified) { + try { + const linked = await autoLinkByVerifiedDomain(getWorkos(), user.id, user.email); + if (linked) { + logger.info( + { userId: user.id, email: user.email, orgId: linked.organizationId, role: linked.role }, + 'Auto-provisioned new user into verified-domain organization' + ); + } + } catch (linkErr) { + logger.warn({ err: linkErr, userId: user.id, email: user.email }, + 'Failed to auto-provision new user into verified-domain organization'); + } + } // Fire-and-forget prospect triage + brand research for business emails. if (user.email) { const domain = user.email.split('@')[1]; diff --git a/server/tests/integration/membership-webhook.test.ts b/server/tests/integration/membership-webhook.test.ts index bbc505a7f4..1e8982fed2 100644 --- a/server/tests/integration/membership-webhook.test.ts +++ b/server/tests/integration/membership-webhook.test.ts @@ -415,7 +415,7 @@ describe('Membership webhook DB operations', () => { ); } - it('creates membership when email domain matches verified domain with active subscription', async () => { + it('always creates membership as member; upsert path handles auto-promotion atomically', async () => { await seedOrgWithVerifiedDomain('autolink.com'); const workos = makeWorkOSMock(); @@ -424,16 +424,16 @@ describe('Membership webhook DB operations', () => { expect(result).not.toBeNull(); expect(result!.organizationId).toBe(TEST_AUTOLINK_ORG_ID); expect(result!.organizationName).toBe('AutoLink Corp'); + expect(result!.role).toBe('member'); expect(workos.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ userId: AUTOLINK_USER, organizationId: TEST_AUTOLINK_ORG_ID, - roleSlug: 'owner', // no existing admin/owner + roleSlug: 'member', }); }); - it('assigns member role when org already has an admin', async () => { + it('still creates as member when org already has an admin', async () => { await seedOrgWithVerifiedDomain('autolink.com'); - // Add an existing owner await pool.query( `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, seat_type, created_at, updated_at, synced_at) VALUES ('user_existing_owner', $1, 'boss@autolink.com', 'owner', 'contributor', NOW(), NOW(), NOW())`, @@ -513,5 +513,63 @@ describe('Membership webhook DB operations', () => { const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'nodomain'); expect(result).toBeNull(); }); + + it('returns null when org has auto_provision_verified_domain disabled', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + await pool.query( + `UPDATE organizations SET auto_provision_verified_domain = false + WHERE workos_organization_id = $1`, + [TEST_AUTOLINK_ORG_ID], + ); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + + expect(result).toBeNull(); + expect(workos.userManagement.createOrganizationMembership).not.toHaveBeenCalled(); + }); + + it('short-circuits when user already has a cached membership in the candidate org', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, seat_type, created_at, updated_at, synced_at) + VALUES ($1, $2, 'matt@autolink.com', 'member', 'community_only', NOW(), NOW(), NOW())`, + [AUTOLINK_USER, TEST_AUTOLINK_ORG_ID], + ); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + + expect(result).toBeNull(); + expect(workos.userManagement.createOrganizationMembership).not.toHaveBeenCalled(); + }); + + it('still creates membership when user has memberships in OTHER orgs (the Triton case)', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + // Seed an unrelated personal org membership for the user. + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, seat_type, created_at, updated_at, synced_at) + VALUES ($1, 'org_personal_autolink_user', 'matt@autolink.com', 'owner', 'community_only', NOW(), NOW(), NOW()) + ON CONFLICT (workos_user_id, workos_organization_id) DO NOTHING`, + [AUTOLINK_USER], + ); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + + expect(result).not.toBeNull(); + expect(result!.organizationId).toBe(TEST_AUTOLINK_ORG_ID); + expect(workos.userManagement.createOrganizationMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: AUTOLINK_USER, + organizationId: TEST_AUTOLINK_ORG_ID, + }), + ); + + // Cleanup the personal-org seed + await pool.query( + `DELETE FROM organization_memberships WHERE workos_organization_id = 'org_personal_autolink_user'`, + ); + }); }); });