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'`,
+ );
+ });
});
});