diff --git a/.changeset/4159-stage2-drop-primary-brand-domain.md b/.changeset/4159-stage2-drop-primary-brand-domain.md new file mode 100644 index 0000000000..aa329b3f4f --- /dev/null +++ b/.changeset/4159-stage2-drop-primary-brand-domain.md @@ -0,0 +1,22 @@ +--- +--- + +feat(server): Stage 2 of #4159 — drop `member_profiles.primary_brand_domain` + +After Stage 1 routed every read through `getBrandPrimaryDomain` (resolver-first, column-fallback), Stage 2 cuts the column entirely. `organization_domains.is_primary=true` is now the single source of truth for both org-membership inference and brand identity — one row, one write, no more drift. + +Removed: + +- the `member_profiles.primary_brand_domain` column (migration 472); +- the resolver fallback to `member_profiles` (`getBrandPrimaryDomain` now reads only `organization_domains.is_primary`); +- the WorkOS-webhook auto-populate that wrote `member_profiles.primary_brand_domain` on first verified domain — the auto-promote-to-`is_primary` on `organization_domains` covers the same ground in one row; +- `PUT /api/me/organization/domains/:domain/primary` and the brand-identity service's dual-write to `member_profiles`; +- the `/api/me/agents` POST backfill that set `primary_brand_domain` from agent hostnames; +- the bootstrap endpoint's acceptance of `primary_brand_domain` in the request body (silently ignored — derived from `organization_domains.is_primary`); +- the `Stage0` data-cleanup and backfill scripts (one-shot work complete). + +Added: + +- a 400 `domain_not_workos_verified` response on `PUT /api/me/organization/domains/:domain/primary` for non-WorkOS sources. With `is_primary` now driving brand identity too, an admin-imported "verified" row shouldn't be promotable via member self-service. + +The API response field `primary_brand_domain` on `GET /api/me/member-profile` is preserved for client compatibility, but its value is now derived from the resolver rather than stored on the profile. diff --git a/server/src/addie/jobs/announcement-trigger.ts b/server/src/addie/jobs/announcement-trigger.ts index 3468d543ea..89cc56f596 100644 --- a/server/src/addie/jobs/announcement-trigger.ts +++ b/server/src/addie/jobs/announcement-trigger.ts @@ -68,7 +68,7 @@ export interface AnnounceCandidate { /** * Orgs eligible for a draft. Base filter (always applied): * - `member_profiles.is_public = true` right now - * - A brand.json manifest exists for their primary_brand_domain + * - A brand.json manifest exists for their primary brand domain * - `member_profiles.metadata->>'no_announcement'` is not 'true' * - No prior `announcement_draft_posted` or `announcement_skipped` activity * diff --git a/server/src/addie/mcp/brand-property-tools.ts b/server/src/addie/mcp/brand-property-tools.ts index 229044d7ee..0782a4d53d 100644 --- a/server/src/addie/mcp/brand-property-tools.ts +++ b/server/src/addie/mcp/brand-property-tools.ts @@ -15,7 +15,7 @@ * * Both tools enforce the same ownership check as the HTTP route via * getBrandForEdit — the calling user's primary org must own the brand - * domain (organization_domains or member_profiles.primary_brand_domain). + * domain (verified row on organization_domains). */ import type { AddieTool } from '../types.js'; diff --git a/server/src/db/member-db.ts b/server/src/db/member-db.ts index 1bee5d5bd4..31ba4b4670 100644 --- a/server/src/db/member-db.ts +++ b/server/src/db/member-db.ts @@ -62,12 +62,11 @@ export class MemberDatabase { const result = await query( `INSERT INTO member_profiles ( workos_organization_id, display_name, slug, tagline, description, - primary_brand_domain, contact_email, contact_website, contact_phone, linkedin_url, twitter_url, offerings, agents, publishers, data_providers, headquarters, markets, metadata, tags, is_public, show_in_carousel - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING *`, [ input.workos_organization_id, @@ -75,7 +74,6 @@ export class MemberDatabase { input.slug, input.tagline || null, input.description || null, - input.primary_brand_domain || null, input.contact_email || null, input.contact_website || null, input.contact_phone || null, @@ -122,11 +120,20 @@ export class MemberDatabase { } /** - * Get profile by primary brand domain + * Get profile by primary brand domain. Brand-primary lives on + * `organization_domains.is_primary=true` post-Stage-2 (#4159), so this + * joins through the org_id rather than reading a column directly off + * member_profiles. */ async getProfileByDomain(domain: string): Promise { const result = await query( - 'SELECT * FROM member_profiles WHERE primary_brand_domain = $1', + `SELECT mp.* + FROM member_profiles mp + JOIN organization_domains od + ON od.workos_organization_id = mp.workos_organization_id + AND od.is_primary = true + WHERE LOWER(od.domain) = LOWER($1) + LIMIT 1`, [domain] ); @@ -157,7 +164,6 @@ export class MemberDatabase { display_name: 'display_name', tagline: 'tagline', description: 'description', - primary_brand_domain: 'primary_brand_domain', contact_email: 'contact_email', contact_website: 'contact_website', contact_phone: 'contact_phone', diff --git a/server/src/db/migrations/472_drop_member_profiles_primary_brand_domain.sql b/server/src/db/migrations/472_drop_member_profiles_primary_brand_domain.sql new file mode 100644 index 0000000000..6a253081ca --- /dev/null +++ b/server/src/db/migrations/472_drop_member_profiles_primary_brand_domain.sql @@ -0,0 +1,14 @@ +-- Drop `member_profiles.primary_brand_domain` (Stage 2 of #4159). +-- +-- The column was the brand-identity primary; Stage 0 backfilled the same +-- value into `organization_domains.is_primary=true` rows so the resolver +-- could read from one place. Stage 1 migrated every reader through +-- `getBrandPrimaryDomain` (resolver). Stage 2 (this migration + the code +-- changes that ship with it) removes the column entirely. +-- +-- Spec: specs/domain-column-rationalization.md. +-- Issue: #4159. +-- Backfill that produced this state: scripts/backfill-primary-brand-domain.ts +-- and scripts/stage0-domain-cleanup.ts (deleted in this same PR). + +ALTER TABLE member_profiles DROP COLUMN IF EXISTS primary_brand_domain; diff --git a/server/src/db/organization-db.ts b/server/src/db/organization-db.ts index 39c219c040..8bb2bd7b56 100644 --- a/server/src/db/organization-db.ts +++ b/server/src/db/organization-db.ts @@ -1007,7 +1007,14 @@ export class OrganizationDatabase { FROM organizations o LEFT JOIN member_profiles mp ON mp.workos_organization_id = o.workos_organization_id LEFT JOIN LATERAL ( - SELECT brand_manifest AS brand_json FROM brands WHERE domain = mp.primary_brand_domain LIMIT 1 + SELECT od.domain + FROM organization_domains od + WHERE od.workos_organization_id = o.workos_organization_id + AND od.is_primary = true + LIMIT 1 + ) pd ON true + LEFT JOIN LATERAL ( + SELECT brand_manifest AS brand_json FROM brands WHERE domain = pd.domain LIMIT 1 ) hb ON true WHERE ${conditions.join(' AND ')} ORDER BY o.name ASC diff --git a/server/src/routes/brand-feeds.ts b/server/src/routes/brand-feeds.ts index bc46258ed3..dabcad45c3 100644 --- a/server/src/routes/brand-feeds.ts +++ b/server/src/routes/brand-feeds.ts @@ -20,8 +20,6 @@ import { VALID_PROPERTY_TYPES, type Relationship, } from '../services/brand-property-parse.js'; -import { getBrandPrimaryDomain } from '../services/brand-domain-resolver.js'; - const MAX_COLLECTIONS = 200; const VALID_COLLECTION_KINDS = ['series', 'publication', 'event_series', 'rotation']; @@ -42,26 +40,18 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) { // the orphan state) before allowing further edits. if (brand.manifest_orphaned) return { error: 'This brand is awaiting adoption — claim it through the brand identity flow first', status: 409 }; - // Verify the user's org owns this brand (via primary_brand_domain or organization_domains) + // Verify the user's org owns this brand. Only verified org_domains rows + // grant edit authority — unverified rows are pending DNS challenges. const orgId = await resolvePrimaryOrganization(userId); if (!orgId) { return { error: 'No organization associated with your account', status: 403 }; } - // TODO(#4159): the orgDomains walk doesn't filter on verified=true; same - // for the resolver's fallback. Pre-existing trust gap — an unverified - // org_domains row or stale member_profiles.primary_brand_domain grants - // brand-feed write authority to the org owner. Stage 2 should add the - // verified gate when the column drops. const orgDomains = await query<{ domain: string }>( - 'SELECT domain FROM organization_domains WHERE workos_organization_id = $1', + 'SELECT domain FROM organization_domains WHERE workos_organization_id = $1 AND verified = true', [orgId] ); - const brandPrimary = await getBrandPrimaryDomain(orgId); - const ownedDomains = new Set([ - ...orgDomains.rows.map(r => r.domain.toLowerCase()), - ...(brandPrimary ? [brandPrimary.toLowerCase()] : []), - ]); + const ownedDomains = new Set(orgDomains.rows.map(r => r.domain.toLowerCase())); if (!ownedDomains.has(domain.toLowerCase())) { return { error: 'You do not own this brand domain', status: 403 }; } diff --git a/server/src/routes/me-organization-domains.ts b/server/src/routes/me-organization-domains.ts index ad9b79be0f..e0eb49d14a 100644 --- a/server/src/routes/me-organization-domains.ts +++ b/server/src/routes/me-organization-domains.ts @@ -2,11 +2,10 @@ * Member-facing self-service for the org's linked domains. * * Mirrors the admin Set-Primary affordance from `admin-account-detail.html` - * but scoped to the caller's own organization. The PUT path writes BOTH - * `organization_domains.is_primary` (org-membership inference primary) AND - * `member_profiles.primary_brand_domain` (brand-identity primary) when the - * domain is claimable, so members don't have to think about the two-primary - * distinction documented in the four-domain-columns audit. + * but scoped to the caller's own organization. The PUT path flips + * `organization_domains.is_primary` — after the Stage 2 column drop, that + * row drives both org-membership-inference and brand-identity, so a single + * write sets the primary unambiguously. * * MVP scope: list + set-primary. Add (POST → WorkOS verification challenge) * and remove (DELETE) deferred to a follow-up — the WorkOS-side wiring is @@ -86,11 +85,11 @@ export function createMeOrganizationDomainsRouter( [orgId], ); - const profileRow = await pool.query<{ primary_brand_domain: string | null }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [orgId], - ); - const brandPrimary = profileRow.rows[0]?.primary_brand_domain ?? null; + // After the Stage 2 column drop, `is_primary` on organization_domains + // is the canonical brand-primary too — `is_brand_primary` mirrors it. + // Kept as a separate field on the response for API stability so any + // existing clients that read it don't break. + const brandPrimary = result.rows.find((r) => r.is_primary)?.domain ?? null; const domains = result.rows.map((row) => { let claimable = false; @@ -105,7 +104,7 @@ export function createMeOrganizationDomainsRouter( is_primary: row.is_primary, verified: row.verified, source: row.source, - is_brand_primary: brandPrimary === row.domain, + is_brand_primary: row.is_primary, claimable, }; }); @@ -118,9 +117,8 @@ export function createMeOrganizationDomainsRouter( }); // PUT /api/me/organization/domains/:domain/primary — set primary domain. - // Writes BOTH organization_domains.is_primary AND member_profiles.primary_brand_domain - // (when the domain is claimable) in a single transaction so members can't end up - // with the two-primary fields out of sync. + // Single source of truth: organization_domains.is_primary. After the Stage 2 + // column drop, brand-identity and org-membership-inference share this row. router.put('/:domain/primary', requireAuth, async (req, res) => { try { const orgId = await resolveTargetOrgId(req, res); @@ -166,13 +164,13 @@ export function createMeOrganizationDomainsRouter( [orgId], ); - // Verify the domain belongs to this org and is verified. We refuse to - // promote a pending/unverified row — letting an attacker set - // `pending` rows as primary would let them claim a domain via SSO - // before WorkOS confirms control. Also restrict to `source = 'workos'` - // for the brand-identity dual-write below; admin-imported / manual - // "verified" rows aren't actually DNS-proof-of-control claims (same - // trust boundary the backfill script enforces). + // Verify the domain belongs to this org and is a WorkOS-verified row. + // After Stage 2 of #4159, is_primary drives BOTH org-membership + // inference AND brand identity, so we hold the bar at WorkOS DNS + // proof: pending rows are pre-verification (would grant SSO claim + // before WorkOS confirms control) and admin-imported / manual rows + // aren't DNS-proof-of-control claims (would let an admin-imported + // verified=true row escalate to brand identity via member self-service). const domainRow = await client.query<{ verified: boolean; source: string }>( `SELECT verified, source FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, @@ -189,7 +187,13 @@ export function createMeOrganizationDomainsRouter( message: 'The domain must be verified before it can be set as primary', }); } - const sourceIsWorkos = domainRow.rows[0].source === 'workos'; + if (domainRow.rows[0].source !== 'workos') { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: 'domain_not_workos_verified', + message: 'Only domains verified through WorkOS DNS challenge can be set as primary', + }); + } // Clear existing primary, set new primary, and update the // denormalized organizations.email_domain in one transaction. @@ -209,37 +213,12 @@ export function createMeOrganizationDomainsRouter( [normalizedDomain, orgId], ); - // Coherent dual-write: also update member_profiles.primary_brand_domain - // when the domain is claimable AND came from a WorkOS proof-of-control. - // This is the bit the admin set-primary path doesn't do — and the cause - // of the Media.net escalation #321. Members shouldn't have to think - // about the two-primary distinction. - // - // Source restriction: `source='manual'` / `'import'` rows can be - // flagged verified by admin tooling without actual DNS proof, so we - // refuse to let them seed brand identity. Same trust boundary the - // backfill-primary-brand-domain script applies. - let brandPrimaryUpdated = false; - let claimable = false; - try { - assertClaimableBrandDomain(normalizedDomain); - claimable = true; - } catch { - claimable = false; - } - if (claimable && sourceIsWorkos) { - const updated = await client.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2`, - [normalizedDomain, orgId], - ); - brandPrimaryUpdated = (updated.rowCount ?? 0) > 0; - } - await client.query('COMMIT'); - if (brandPrimaryUpdated) invalidateMemberContextCache(); + // Brand-identity primary now mirrors org-membership-inference primary + // via the same row, so any member-context cache that depended on + // brand-primary still needs invalidation when the row flips. + invalidateMemberContextCache(); logger.info( { @@ -247,7 +226,6 @@ export function createMeOrganizationDomainsRouter( domain: normalizedDomain, actor: req.user!.id, via_dev_bypass: membership.via_dev_bypass, - brand_primary_updated: brandPrimaryUpdated, }, 'Set primary domain via member self-service', ); @@ -255,7 +233,6 @@ export function createMeOrganizationDomainsRouter( return res.json({ success: true, primary_domain: normalizedDomain, - brand_primary_updated: brandPrimaryUpdated, }); } catch (err) { await client.query('ROLLBACK').catch((rbErr) => { diff --git a/server/src/routes/member-agents.ts b/server/src/routes/member-agents.ts index 7e2ba11a62..61f051c37b 100644 --- a/server/src/routes/member-agents.ts +++ b/server/src/routes/member-agents.ts @@ -64,44 +64,6 @@ export interface MemberAgentsRouterConfig { invalidateMemberContextCache: () => void; } -/** - * Extract the brand domain from an agent URL. Strips protocol, path, query, - * and a leading `www.` so the value matches how `extractDomain` in - * registry-api normalizes lookup queries. Returns null if the URL is - * unparseable. Used to backfill `member_profiles.primary_brand_domain` when - * an agent is registered against a profile that has no brand domain set — - * without this, `/api/registry/operator?domain=…` exact-match lookup misses - * the profile entirely (it keys off `primary_brand_domain`, not the agents - * JSONB), leaving the agent invisible to the public registry. - */ -function brandDomainFromAgentUrl(url: string): string | null { - try { - const h = new URL(url).hostname.toLowerCase(); - if (!h) return null; - return h.startsWith('www.') ? h.slice(4) : h; - } catch { - return null; - } -} - -/** - * Returns the unanimous brand-domain across all agent URLs in the array, or - * null if agents disagree (multi-domain rollup) or none have a parseable URL. - * "Unanimous" is the bar for auto-populating `primary_brand_domain` because - * a profile carries one canonical brand — silently picking one of N - * conflicting hostnames could mis-key registry lookups. - */ -function unanimousBrandDomain(agents: AgentConfig[]): string | null { - const hosts = new Set(); - for (const a of agents) { - if (!a || typeof a.url !== 'string') continue; - const h = brandDomainFromAgentUrl(a.url); - if (h) hosts.add(h); - } - if (hosts.size !== 1) return null; - return hosts.values().next().value ?? null; -} - /** * Decoded shape of `member_profiles.agents` JSONB. The column is JSONB but * pg sometimes hands it back as a string depending on driver settings. @@ -298,7 +260,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout try { await client.query('BEGIN'); const row = await client.query( - `SELECT id, agents, primary_brand_domain + `SELECT id, agents FROM member_profiles WHERE workos_organization_id = $1 FOR UPDATE`, @@ -316,7 +278,6 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout } const profileId = row.rows[0].id as string; - const currentBrandDomain = row.rows[0].primary_brand_domain as string | null; const existing = parseAgents(row.rows[0].agents); const result = await mutate(existing); if (result.kind === 'reject') { @@ -330,33 +291,17 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout const typed = (await resolveAgentTypes(gated)) as AgentConfig[]; await logResolvedTypeChanges(gated, typed, orgId); - // Backfill `primary_brand_domain` from the agents' URL hostnames when - // it's currently null AND every agent agrees on the same hostname. - // This keeps the public registry lookup - // (`/api/registry/operator?domain=…`, which keys off - // `primary_brand_domain`) discoverable for profiles that registered an - // agent before setting a brand domain. Conflicts (multiple distinct - // hostnames) are deliberately skipped — picking one would mis-key - // discovery. - let newBrandDomain: string | null = null; - if (!currentBrandDomain) { - newBrandDomain = unanimousBrandDomain(typed); - } - if (newBrandDomain) { - await client.query( - `UPDATE member_profiles - SET agents = $1::jsonb, primary_brand_domain = $2, updated_at = NOW() - WHERE id = $3`, - [JSON.stringify(typed), newBrandDomain, profileId], - ); - } else { - await client.query( - `UPDATE member_profiles - SET agents = $1::jsonb, updated_at = NOW() - WHERE id = $2`, - [JSON.stringify(typed), profileId], - ); - } + // Stage 2 of #4159 dropped the primary_brand_domain column; this + // path no longer auto-backfills brand-primary from agent URL + // hostnames. The canonical brand-primary lives on + // organization_domains.is_primary, set via the Linked Domains UI + // (PR #4179) or the WorkOS verify-domain auto-promote. + await client.query( + `UPDATE member_profiles + SET agents = $1::jsonb, updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(typed), profileId], + ); // Ensure every registered agent has an `agent_registry_metadata` row // so the compliance heartbeat picks it up. Pre-fix, the heartbeat's diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts index ef8e6fecc3..8c403ea0f3 100644 --- a/server/src/routes/member-profiles.ts +++ b/server/src/routes/member-profiles.ts @@ -236,6 +236,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro profile: any, org: any, corporateDomain: string, + brandPrimaryDomain: string | null, ): Record { const created = profile?.created_at instanceof Date ? profile.created_at.toISOString() @@ -246,9 +247,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro company_type: org?.company_type ?? null, ...(org?.revenue_tier ? { revenue_tier: org.revenue_tier } : {}), corporate_domain: corporateDomain, - ...(profile.primary_brand_domain - ? { primary_brand_domain: profile.primary_brand_domain } - : {}), + // After Stage 2 of #4159, brand-primary lives on + // organization_domains.is_primary and is resolved via getBrandPrimaryDomain. + // Caller passes the resolved value through so this function stays pure. + ...(brandPrimaryDomain ? { primary_brand_domain: brandPrimaryDomain } : {}), ...(org?.membership_tier ? { membership_tier: org.membership_tier } : {}), created_at: created, agents: Array.isArray(profile.agents) ? profile.agents : [], @@ -284,10 +286,15 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro company_type, revenue_tier, corporate_domain, - primary_brand_domain, marketing_opt_in, membership_tier, } = req.body as Record; + // After Stage 2 of #4159, brand-primary lives on + // organization_domains.is_primary, not on the member profile. Members + // set it via the Linked Domains UI (PR #4179) or it auto-promotes + // from a verified WorkOS email domain. The bootstrap endpoint no + // longer accepts primary_brand_domain — silently ignored if old + // clients still pass it. const trimmedName = typeof organization_name === 'string' ? organization_name.trim() : ''; if (!trimmedName || trimmedName.length > 200) { @@ -337,24 +344,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro }); } } - let normalizedBrandDomain: string | null = null; - if (primary_brand_domain !== undefined && primary_brand_domain !== null && primary_brand_domain !== '') { - if (typeof primary_brand_domain !== 'string') { - return res.status(400).json({ - error: 'Invalid primary_brand_domain', - message: 'primary_brand_domain must be a string', - }); - } - const candidate = primary_brand_domain.toLowerCase().trim(); - if (!candidate || candidate.length > 253 || !DOMAIN_RE.test(candidate)) { - return res.status(400).json({ - error: 'Invalid primary_brand_domain', - message: 'primary_brand_domain must be a valid domain like "acme.com"', - }); - } - normalizedBrandDomain = candidate; - } - // Email-domain match. getCompanyDomain returns null for personal-email // domains (gmail.com, yahoo.com, etc.) — those cannot bootstrap a // corporate profile. Mismatch between the verified email domain and @@ -446,9 +435,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro if (existingProfile) { const existingOrg = await orgDb.getOrganization(targetOrgId); const primaryDomain = await resolvePrimaryDomain(targetOrgId, corporateDomain); + const brandPrimaryDomain = await getBrandPrimaryDomain(targetOrgId); logger.info({ userId: user.id, orgId: targetOrgId, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile (bootstrap) idempotent hit'); return res.status(200).json({ - profile: toSpecMemberProfile(existingProfile, existingOrg, primaryDomain), + profile: toSpecMemberProfile(existingProfile, existingOrg, primaryDomain, brandPrimaryDomain), warnings: [{ code: 'profile_already_exists', message: 'Member profile already exists for this organization; no fields were mutated.', @@ -542,7 +532,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro workos_organization_id: targetOrgId, display_name: trimmedName, slug, - ...(normalizedBrandDomain ? { primary_brand_domain: normalizedBrandDomain } : {}), // Default privacy posture for a bootstrapped profile is private — // the caller can flip is_public via PUT /api/me/member-profile/visibility // once they have an active subscription. The legacy POST path applies @@ -661,8 +650,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro }); } + const newBrandPrimaryDomain = await getBrandPrimaryDomain(targetOrgId); return res.status(201).json({ - profile: toSpecMemberProfile(profile, refreshedOrg, primaryDomain), + profile: toSpecMemberProfile(profile, refreshedOrg, primaryDomain, newBrandPrimaryDomain), ...(warnings.length ? { warnings } : {}), }); } catch (error) { @@ -798,7 +788,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro slug, tagline, description, - primary_brand_domain, contact_email, contact_website, contact_phone, @@ -812,6 +801,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro is_public, show_in_carousel, } = req.body; + // After Stage 2 of #4159, primary_brand_domain is no longer a field + // on member_profiles. Old clients passing it in this POST body have + // their value silently dropped — brand-primary now lives on + // organization_domains.is_primary. // Validate required fields if (!display_name || !slug) { @@ -969,7 +962,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro slug, tagline, description, - primary_brand_domain: primary_brand_domain || null, contact_email, contact_website, contact_phone, diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index be55ec9e7a..f44c842157 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -7030,13 +7030,9 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { } } - // Link the member profile to this brand domain using authenticated user's org - const memberDb = new MemberDatabase(); - if (orgId) { - await memberDb.updateProfileByOrgId(orgId, { - primary_brand_domain: domain, - }); - } + // Brand→org attribution lives on `brands.workos_organization_id` + // (set above on create/update). Stage 3 of #4159 owns the canonical + // setPrimaryDomain writer for `organization_domains.is_primary`. const hostedBrandJsonUrl = aaoHostedBrandJsonUrl(domain); const pointerSnippet = JSON.stringify( diff --git a/server/src/routes/workos-webhooks.ts b/server/src/routes/workos-webhooks.ts index c58d8ba359..3bfa7d981e 100644 --- a/server/src/routes/workos-webhooks.ts +++ b/server/src/routes/workos-webhooks.ts @@ -787,31 +787,12 @@ export async function upsertOrganizationDomain(domainData: OrganizationDomainEve logger.error({ err, orgId: domainData.organization_id, domain: normalizedDomain }, 'Failed to sync verified domain to brand registry'); } - // Auto-populate `member_profiles.primary_brand_domain` when a member - // verifies a claimable domain via WorkOS and they haven't already - // staked a brand identity. Without this, members who verified a - // domain via SSO still hit the publish-agent gate that requires a - // primary brand domain — surprising for the common case where their - // email domain *is* their brand. Only writes when NULL so an - // intentional brand-claim (request_brand_domain_challenge / verify) - // pointing at a different domain is never clobbered. - try { - const updated = await pool.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2 - AND primary_brand_domain IS NULL`, - [normalizedDomain, domainData.organization_id] - ); - if (updated.rowCount && updated.rowCount > 0) { - logger.info({ - orgId: domainData.organization_id, - domain: normalizedDomain, - }, 'Auto-set primary_brand_domain on member_profile from verified WorkOS domain'); - } - } catch (err) { - logger.error({ err, orgId: domainData.organization_id, domain: normalizedDomain }, 'Failed to auto-set primary_brand_domain on member_profile'); - } + // After Stage 2 of #4159, brand-identity primary lives on the same + // organization_domains.is_primary row as org-membership-inference. + // The auto-promote-to-is_primary above (when no other primary exists) + // covers the auto-populate case — a member verifying their first + // domain via WorkOS now seeds both facets in one write. Stage 1's + // separate primary_brand_domain auto-populate is no longer needed. } } } catch (error) { diff --git a/server/src/schemas/member-agents-openapi.ts b/server/src/schemas/member-agents-openapi.ts index da4dc64844..d3669f5678 100644 --- a/server/src/schemas/member-agents-openapi.ts +++ b/server/src/schemas/member-agents-openapi.ts @@ -178,7 +178,7 @@ registry.registerPath({ "- If the caller's org has no member profile, the server auto-creates a private profile (display name = organization name, `is_public: false`) and the response includes `profile_auto_created: true`.", "Both auto-bootstraps are best-effort fallbacks. To customize org name / company_type / revenue_tier, or to control profile slug / brand identity / tagline, call `POST /api/organizations` and `POST /api/me/member-profile` explicitly before registering the agent. Tier transitions never happen via this path — go through the billing flow.", "`type` is required and declared by the caller — the server does not infer it. Server-side smuggle protection still cross-checks the declared type against the agent's capability snapshot when one exists; if the snapshot contradicts the declaration without classifying it, the stored value is `unknown` and the dashboard surfaces the conflict for the owner to resolve.", - '`visibility: "public"` requires Professional tier or higher and a `primary_brand_domain` set on the profile. Non-API-tier callers who request `public` will have the entry stored as `members_only` instead, and the response will include a `visibility_downgraded` warning describing the coercion.', + '`visibility: "public"` requires Professional tier or higher and a verified primary domain on the organization (set via the Linked Domains UI). Non-API-tier callers who request `public` will have the entry stored as `members_only` instead, and the response will include a `visibility_downgraded` warning describing the coercion.', ].join('\n\n'), tags: ['Member Agents'], security: [{ bearerAuth: [] }, { oauth2: [] }], diff --git a/server/src/scripts/backfill-primary-brand-domain.ts b/server/src/scripts/backfill-primary-brand-domain.ts deleted file mode 100644 index 537eb8dea9..0000000000 --- a/server/src/scripts/backfill-primary-brand-domain.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Backfill `member_profiles.primary_brand_domain` from verified WorkOS domains. - * - * For each member_profile where `primary_brand_domain IS NULL`, find the org's - * verified, claimable WorkOS-sourced domain and set it. Skips ambiguous cases - * (multiple verified domains) so an admin can resolve those manually — the - * webhook auto-populate (workos-webhooks.ts) handles the single-domain case - * going forward; this script catches profiles created before that change - * landed. - * - * Usage (dev): - * npx tsx server/src/scripts/backfill-primary-brand-domain.ts # dry-run - * npx tsx server/src/scripts/backfill-primary-brand-domain.ts --apply # write - * - * Usage (prod, via fly ssh): - * fly ssh console -a adcp-docs -C 'node /app/dist/scripts/backfill-primary-brand-domain.js' # dry-run - * fly ssh console -a adcp-docs -C 'node /app/dist/scripts/backfill-primary-brand-domain.js --apply' # write - * - * Prerequisites: DATABASE_URL set. - */ - -import { initializeDatabase, getPool, closeDatabase } from '../db/client.js'; -import { getDatabaseConfig } from '../config.js'; -import { - assertClaimableBrandDomain, - canonicalizeBrandDomain, -} from '../services/identifier-normalization.js'; - -// Default to dry-run; require explicit `--apply` to write. Cheap insurance -// against an operator running the script while wired to the wrong DATABASE_URL. -const apply = process.argv.includes('--apply'); -const dryRun = !apply; - -interface Candidate { - org_id: string; - domains: string[]; -} - -async function main(): Promise { - const dbConfig = getDatabaseConfig(); - if (!dbConfig) { - console.error('DATABASE_URL is required'); - process.exit(1); - } - initializeDatabase(dbConfig); - const pool = getPool(); - - const result = await pool.query<{ workos_organization_id: string; domain: string }>( - `SELECT mp.workos_organization_id, od.domain - FROM member_profiles mp - JOIN organization_domains od ON od.workos_organization_id = mp.workos_organization_id - WHERE mp.primary_brand_domain IS NULL - AND od.verified = true - AND od.source = 'workos' - ORDER BY mp.workos_organization_id, od.created_at ASC`, - ); - - const byOrg = new Map(); - for (const row of result.rows) { - const arr = byOrg.get(row.workos_organization_id) ?? []; - arr.push(row.domain); - byOrg.set(row.workos_organization_id, arr); - } - - let scanned = 0; - let setCount = 0; - let skippedNonClaimable = 0; - let skippedAmbiguous = 0; - const sets: Array<{ org: string; domain: string }> = []; - const ambiguous: Candidate[] = []; - - for (const [orgId, domains] of byOrg) { - scanned += 1; - const claimable = domains.filter((d) => { - try { - assertClaimableBrandDomain(canonicalizeBrandDomain(d)); - return true; - } catch { - return false; - } - }); - - if (claimable.length === 0) { - skippedNonClaimable += 1; - continue; - } - if (claimable.length > 1) { - skippedAmbiguous += 1; - ambiguous.push({ org_id: orgId, domains: claimable }); - continue; - } - - const domain = claimable[0]; - sets.push({ org: orgId, domain }); - - if (!dryRun) { - await pool.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2 - AND primary_brand_domain IS NULL`, - [domain, orgId] - ); - } - setCount += 1; - } - - console.log(`Mode: ${dryRun ? 'DRY-RUN (use --apply to write)' : 'APPLY (writing changes)'}`); - console.log(`Scanned: ${scanned} profiles with NULL primary_brand_domain and ≥1 verified WorkOS domain`); - console.log(`Set: ${setCount}${dryRun ? ' (would set)' : ''}`); - console.log(`Skipped (non-claimable): ${skippedNonClaimable}`); - console.log(`Skipped (ambiguous, ≥2 claimable): ${skippedAmbiguous}`); - if (ambiguous.length > 0) { - console.log('\nAmbiguous orgs needing manual resolution:'); - for (const a of ambiguous) console.log(` ${a.org_id}: ${a.domains.join(', ')}`); - } -} - -main() - .then(() => closeDatabase()) - .then(() => process.exit(0)) - .catch(async (err) => { - console.error(err); - await closeDatabase().catch(() => {}); - process.exit(1); - }); diff --git a/server/src/scripts/stage0-domain-cleanup.ts b/server/src/scripts/stage0-domain-cleanup.ts deleted file mode 100644 index 743819a5bc..0000000000 --- a/server/src/scripts/stage0-domain-cleanup.ts +++ /dev/null @@ -1,617 +0,0 @@ -/** - * Stage 0 of the domain-column rationalization (issue #4159, spec at - * specs/domain-column-rationalization.md). - * - * Three idempotent phases that prepare the fleet for Option B (collapse - * member_profiles.primary_brand_domain into organization_domains.is_primary). - * - * Phases: - * - canonicalize-www Strip `www.` from member_profiles.primary_brand_domain - * values where the apex equivalent already exists in - * organization_domains for the same org. - * - per-case-fixes Hand-tuned fixes for the 6 non-trivial divergence - * cases. Each guards on expected before-state. - * - insert-missing-rows For profiles where primary_brand_domain is set but - * no matching organization_domains row exists, - * insert as `source='manual', verified=true, - * is_primary=true`. Trust model: brand-claim DNS - * verification, not WorkOS DNS. Cross-org collisions - * are surfaced (exit code 2), not stomped. - * - * All phases are independently runnable. All default to dry-run; pass - * `--apply` to write. - * - * Usage: - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=canonicalize-www - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=canonicalize-www --apply - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=per-case-fixes - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=per-case-fixes --apply - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=insert-missing-rows - * node /app/dist/scripts/stage0-domain-cleanup.js --phase=insert-missing-rows --apply - * - * Exit codes: 0 success; 1 unrecoverable error; 2 a guard rejected (per-case - * before-state drift, or insert-missing-rows cross-org collision). - */ - -import { initializeDatabase, getPool, closeDatabase } from '../db/client.js'; -import { getDatabaseConfig } from '../config.js'; -import { - assertClaimableBrandDomain, - canonicalizeBrandDomain, -} from '../services/identifier-normalization.js'; -import type { Pool } from 'pg'; - -const apply = process.argv.includes('--apply'); -const dryRun = !apply; -const allowExternalProof = process.argv.includes('--allow-external-proof'); -const phaseArg = process.argv.find((a) => a.startsWith('--phase='))?.split('=')[1]; -const onlyArg = process.argv.find((a) => a.startsWith('--only='))?.split('=')[1]; -const onlyFilter: Set | null = onlyArg ? new Set(onlyArg.split(',').map((s) => s.trim())) : null; - -interface PerCaseFix { - org_id: string; - org_name: string; - description: string; - // before-state guards — script aborts if any fail - expected_brand_primary_before: string | null; - // Asserts the named organization_domains rows exist with the named flag - // values BEFORE the writes run. Catches partial-application drift that the - // single-field brand_primary guard would miss. - expected_organization_domains_before?: Array<{ domain: string; is_primary: boolean; verified: boolean }>; - // writes - brand_primary_after: string; - organization_domains_writes: Array< - | { op: 'insert'; domain: string; verified: boolean; is_primary: boolean; source: string } - | { op: 'update_primary'; domain: string; is_primary: boolean } - | { op: 'update_verified'; domain: string; verified: boolean } - | { op: 'delete'; domain: string } - >; - // Set when applying this fix asserts a brand-identity claim that requires - // out-of-band human verification (e.g., DBA paperwork). The script refuses - // to write unless --allow-external-proof is also passed. - requires_external_proof?: { reason: string }; -} - -const PER_CASE_FIXES: PerCaseFix[] = [ - { - org_id: 'org_01KCJ4M0Q6WAR5QQD8SS1KQXW8', - org_name: 'DanAds', - description: 'International TLD: keep .se as verified non-primary, promote .com to primary', - expected_brand_primary_before: 'danads.com', - expected_organization_domains_before: [ - { domain: 'danads.se', is_primary: true, verified: true }, - ], - brand_primary_after: 'danads.com', - organization_domains_writes: [ - { op: 'insert', domain: 'danads.com', verified: true, is_primary: true, source: 'manual' }, - { op: 'update_primary', domain: 'danads.se', is_primary: false }, - ], - }, - { - org_id: 'org_01KGF19Y4MXWMP82XA2FG70VMX', - org_name: 'iPROM', - description: 'International TLD: keep .si as verified non-primary, promote .eu to primary', - expected_brand_primary_before: 'iprom.eu', - expected_organization_domains_before: [ - { domain: 'iprom.si', is_primary: true, verified: true }, - ], - brand_primary_after: 'iprom.eu', - organization_domains_writes: [ - { op: 'insert', domain: 'iprom.eu', verified: true, is_primary: true, source: 'manual' }, - { op: 'update_primary', domain: 'iprom.si', is_primary: false }, - ], - }, - { - org_id: 'org_01KCBDJ1BN5HWR3J73HTCPS3TY', - org_name: 'Transfon', - description: 'BiddingStack is a product brand under Transfon — reset member-profile to transfon.com; BiddingStack stays as a separate brands-table row', - expected_brand_primary_before: 'biddingstack.com', - brand_primary_after: 'transfon.com', - organization_domains_writes: [], // transfon.com is already primary; no org_domains change - }, - { - org_id: 'org_01KEVY532HYA8HXBRBSDJSTAJQ', - org_name: 'Mission Media / Winstar', - description: 'DBA case: insert winstarinteractive.com as primary, demote wims.com to non-primary verified', - expected_brand_primary_before: 'winstarinteractive.com', - expected_organization_domains_before: [ - { domain: 'wims.com', is_primary: true, verified: true }, - ], - brand_primary_after: 'winstarinteractive.com', - organization_domains_writes: [ - { op: 'insert', domain: 'winstarinteractive.com', verified: true, is_primary: true, source: 'manual' }, - { op: 'update_primary', domain: 'wims.com', is_primary: false }, - ], - // The org name is literally "Mission Media Services Inc DBA Winstar - // Interactive" — wims.com is the legal entity, winstarinteractive.com is - // the public DBA. But asserting `winstarinteractive.com` as a verified - // organization_domains row makes any @winstarinteractive.com signup - // auto-link to this tenant via findPayingOrgForDomain. Cross-org-identity - // boundary; the script refuses to apply without explicit operator - // confirmation that the DBA is documented (e.g., AAO membership form, - // incorporation cert, founder attestation). - requires_external_proof: { - reason: 'Inserting winstarinteractive.com as a verified organization_domains row enables @winstarinteractive.com auto-link. Confirm the DBA is documented before --apply (re-run with --allow-external-proof).', - }, - }, - { - org_id: 'org_01KC80TYK2QPPWQ7A8SGGGNHE7', - org_name: 'Triton Digital', - description: 'Data corruption from prior incident: verify tritondigital.com, set as primary, demote agilecompanion.com, drop www. duplicate', - expected_brand_primary_before: 'tritondigital.com', - expected_organization_domains_before: [ - { domain: 'agilecompanion.com', is_primary: true, verified: true }, - { domain: 'tritondigital.com', is_primary: false, verified: false }, - { domain: 'www.tritondigital.com', is_primary: false, verified: true }, - ], - brand_primary_after: 'tritondigital.com', - organization_domains_writes: [ - { op: 'update_verified', domain: 'tritondigital.com', verified: true }, - { op: 'update_primary', domain: 'tritondigital.com', is_primary: true }, - { op: 'update_primary', domain: 'agilecompanion.com', is_primary: false }, - { op: 'delete', domain: 'www.tritondigital.com' }, - ], - }, - { - org_id: 'org_01KEWQT7DA1BXZXQGX1298CPZX', - org_name: 'Mangrove Digital', - description: 'Bug: linkedin.com was set as their brand. Reset to their actual domain', - expected_brand_primary_before: 'linkedin.com', - brand_primary_after: 'mangrovedigital.com.au', - organization_domains_writes: [], // mangrovedigital.com.au is already primary; no org_domains change - }, - { - org_id: 'org_01KQ0J250HS95TX5SCG8835AW1', - org_name: 'Mogl', - description: 'Bug: HubSpot CDN URL stored as brand. Reset to their corporate domain (mogl.com is already the membership-primary)', - expected_brand_primary_before: '243380875.fs1.hubspotusercontent-na2.net', - expected_organization_domains_before: [ - { domain: 'mogl.com', is_primary: true, verified: true }, - ], - brand_primary_after: 'mogl.com', - organization_domains_writes: [], // mogl.com is already primary; no org_domains change - }, - { - org_id: 'org_01KJED4HHAKZS5AMQA8N64X3S0', - org_name: 'No Fluff Advisory', - description: 'Bug: www.linkedin.com was set as brand. Reset to apex of their actual domain (signal-stack.io). Also canonicalize the org_domains row (www.signal-stack.io → signal-stack.io). Order: delete www row first, then insert apex, so we never hold two is_primary=true rows simultaneously.', - expected_brand_primary_before: 'www.linkedin.com', - expected_organization_domains_before: [ - { domain: 'www.signal-stack.io', is_primary: true, verified: true }, - ], - brand_primary_after: 'signal-stack.io', - organization_domains_writes: [ - { op: 'delete', domain: 'www.signal-stack.io' }, - { op: 'insert', domain: 'signal-stack.io', verified: true, is_primary: true, source: 'manual' }, - ], - }, -]; - -async function phaseCanonicalizeWww(pool: Pool): Promise { - console.log('=== PHASE: canonicalize-www ==='); - // Find profiles where primary_brand_domain starts with `www.` AND the apex - // form already exists in organization_domains for the same org. - const candidates = await pool.query<{ - workos_organization_id: string; - org_name: string; - current: string; - apex: string; - apex_in_org_domains: boolean; - }>(` - WITH profile_www AS ( - SELECT - mp.workos_organization_id, - o.name AS org_name, - mp.primary_brand_domain AS current, - SUBSTRING(mp.primary_brand_domain FROM 5) AS apex - FROM member_profiles mp - JOIN organizations o ON o.workos_organization_id = mp.workos_organization_id - WHERE LOWER(mp.primary_brand_domain) LIKE 'www.%' - ) - SELECT - pw.workos_organization_id, - pw.org_name, - pw.current, - pw.apex, - EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = pw.workos_organization_id - AND LOWER(od.domain) = LOWER(pw.apex) - ) AS apex_in_org_domains - FROM profile_www pw - ORDER BY pw.org_name - `); - - console.log(`Candidates with www. primary_brand_domain: ${candidates.rowCount}`); - let updated = 0; - let skipped = 0; - for (const r of candidates.rows) { - const status = r.apex_in_org_domains ? 'WILL UPDATE' : 'SKIP (apex not in org_domains)'; - console.log(` ${status} ${r.org_name} (${r.workos_organization_id}) ${r.current} → ${r.apex}`); - if (!r.apex_in_org_domains) { - skipped += 1; - continue; - } - if (apply) { - await pool.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2 AND primary_brand_domain = $3`, - [r.apex, r.workos_organization_id, r.current], - ); - } - updated += 1; - } - console.log(`Result: ${updated} updated${dryRun ? ' (would update)' : ''}, ${skipped} skipped`); -} - -async function phasePerCaseFixes(pool: Pool): Promise { - console.log('=== PHASE: per-case-fixes ==='); - let applied = 0; - let aborted = 0; - let skipped = 0; - - for (const fix of PER_CASE_FIXES) { - if (onlyFilter && !onlyFilter.has(fix.org_name)) { - continue; - } - - console.log(`\n--- ${fix.org_name} (${fix.org_id}) ---`); - console.log(` ${fix.description}`); - - if (fix.requires_external_proof && apply && !allowExternalProof) { - console.log(` REFUSED: this case requires external proof. ${fix.requires_external_proof.reason}`); - aborted += 1; - continue; - } - - // Read current state for guard checks. - const profile = await pool.query<{ primary_brand_domain: string | null }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [fix.org_id], - ); - if (profile.rowCount === 0) { - console.log(` ABORT: no member_profile for this org`); - aborted += 1; - continue; - } - const currentBrandPrimary = profile.rows[0].primary_brand_domain; - - // Two acceptable starting states: pre-fix (matches expected_before) or - // already-applied (matches after). Anything else means manual drift — - // abort instead of stomping. The "already-applied" path still runs the - // org_domains writes (idempotently) so partial-application drift gets - // converged on re-run. - let isReapply = false; - if (currentBrandPrimary === fix.expected_brand_primary_before) { - // forward fix - } else if (currentBrandPrimary === fix.brand_primary_after) { - isReapply = true; - } else { - console.log(` ABORT: expected_brand_primary_before=${fix.expected_brand_primary_before} or brand_primary_after=${fix.brand_primary_after}, actual=${currentBrandPrimary}`); - aborted += 1; - continue; - } - - // expected_organization_domains_before: assert each named row matches - // before-state. Only checked on the forward-fix path (in re-apply, the - // before-state is by definition stale). - if (!isReapply && fix.expected_organization_domains_before) { - let guardOk = true; - for (const e of fix.expected_organization_domains_before) { - const row = await pool.query<{ is_primary: boolean; verified: boolean }>( - `SELECT is_primary, verified FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, - [fix.org_id, e.domain], - ); - if (row.rowCount === 0 || row.rows[0].is_primary !== e.is_primary || row.rows[0].verified !== e.verified) { - console.log(` ABORT: expected_organization_domains_before mismatch for ${e.domain} (expected is_primary=${e.is_primary} verified=${e.verified}, got ${JSON.stringify(row.rows[0])})`); - guardOk = false; - break; - } - } - if (!guardOk) { - aborted += 1; - continue; - } - } - - if (apply) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query( - 'SELECT 1 FROM organizations WHERE workos_organization_id = $1 FOR UPDATE', - [fix.org_id], - ); - - for (const w of fix.organization_domains_writes) { - if (w.op === 'insert') { - await client.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) - ON CONFLICT (domain) DO UPDATE SET - verified = EXCLUDED.verified, - is_primary = EXCLUDED.is_primary, - source = EXCLUDED.source, - updated_at = NOW()`, - [fix.org_id, w.domain, w.verified, w.is_primary, w.source], - ); - } else if (w.op === 'update_primary') { - await client.query( - `UPDATE organization_domains SET is_primary = $1, updated_at = NOW() - WHERE workos_organization_id = $2 AND domain = $3`, - [w.is_primary, fix.org_id, w.domain], - ); - } else if (w.op === 'update_verified') { - await client.query( - `UPDATE organization_domains SET verified = $1, updated_at = NOW() - WHERE workos_organization_id = $2 AND domain = $3`, - [w.verified, fix.org_id, w.domain], - ); - } else if (w.op === 'delete') { - await client.query( - `DELETE FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, - [fix.org_id, w.domain], - ); - } - } - - // Update member_profiles.primary_brand_domain. - await client.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2`, - [fix.brand_primary_after, fix.org_id], - ); - - // Invariant: after writes there must be exactly ONE is_primary=true - // row on organization_domains for this org. Without this, a future - // adaptation of this script that demotes a primary without promoting - // a replacement would silently leave email_domain=NULL. - const primaryCount = await client.query<{ n: string }>( - `SELECT COUNT(*)::text AS n FROM organization_domains - WHERE workos_organization_id = $1 AND is_primary = true`, - [fix.org_id], - ); - const n = parseInt(primaryCount.rows[0].n, 10); - if (n !== 1) { - throw new Error( - `Invariant violation for ${fix.org_name} (${fix.org_id}): expected exactly 1 is_primary=true row after writes, got ${n}. Aborting transaction.`, - ); - } - - // Update organizations.email_domain to whichever org_domains row is now primary. - await client.query( - `UPDATE organizations - SET email_domain = ( - SELECT domain FROM organization_domains - WHERE workos_organization_id = $1 AND is_primary = true - LIMIT 1 - ), - updated_at = NOW() - WHERE workos_organization_id = $1`, - [fix.org_id], - ); - - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK').catch(() => {}); - throw err; - } finally { - client.release(); - } - } - - console.log(` ${apply ? 'APPLIED' : 'WOULD APPLY'}: brand_primary ${fix.expected_brand_primary_before} → ${fix.brand_primary_after}`); - for (const w of fix.organization_domains_writes) { - console.log(` ${w.op} ${w.domain}${'is_primary' in w ? ` is_primary=${w.is_primary}` : ''}${'verified' in w ? ` verified=${w.verified}` : ''}`); - } - applied += 1; - } - - console.log(`\nResult: ${applied} applied${dryRun ? ' (would apply)' : ''}, ${skipped} skipped (already at after-state), ${aborted} aborted (state diverged)`); - if (aborted > 0) process.exitCode = 2; -} - -/** - * Stage 0.3: insert organization_domains rows for member_profiles where - * `primary_brand_domain` is set but no matching organization_domains row - * exists for that domain on the same org. - * - * Trust model: these domains were claimed via the brand-claim verify flow - * (web-root publication of brand.json), not WorkOS DNS. Insert as - * `source='manual', verified=true, is_primary=true` — auto-link will route - * future @ signups to this org. Refuses non-claimable domains - * (free-email providers, shared platforms, public-suffix etlds) — the - * candidate set is filtered through `assertClaimableBrandDomain` before - * any write, since stale `primary_brand_domain` values can include junk - * (Mangrove had `linkedin.com`). Cross-org collisions also skipped and - * surfaced for manual resolution. - */ -async function phaseInsertMissingRows(pool: Pool): Promise { - console.log('=== PHASE: insert-missing-rows ==='); - - // Candidates: profiles with a brand-primary that's not on any organization_domains - // row anywhere (regardless of org). Splitting "any-org collision" out from - // "this-org missing" gives a cleaner picture of the data. - const candidates = await pool.query<{ - org_id: string; - org_name: string; - primary_brand_domain: string; - other_org_owns: string | null; - other_org_name: string | null; - }>(` - SELECT - mp.workos_organization_id AS org_id, - o.name AS org_name, - mp.primary_brand_domain, - other_od.workos_organization_id AS other_org_owns, - other_o.name AS other_org_name - FROM member_profiles mp - JOIN organizations o ON o.workos_organization_id = mp.workos_organization_id - LEFT JOIN organization_domains same_od - ON same_od.workos_organization_id = mp.workos_organization_id - AND LOWER(same_od.domain) = LOWER(mp.primary_brand_domain) - LEFT JOIN organization_domains other_od - ON LOWER(other_od.domain) = LOWER(mp.primary_brand_domain) - AND other_od.workos_organization_id != mp.workos_organization_id - LEFT JOIN organizations other_o - ON other_o.workos_organization_id = other_od.workos_organization_id - WHERE mp.primary_brand_domain IS NOT NULL - AND same_od.domain IS NULL - ORDER BY o.name - `); - - console.log(`Candidates (profile has primary_brand_domain but no matching org_domains row): ${candidates.rowCount}`); - let inserted = 0; - let skippedCollision = 0; - let skippedNonClaimable = 0; - let raceLost = 0; - const collisions: Array<{ name: string; domain: string; owner: string }> = []; - const nonClaimable: Array<{ name: string; domain: string; reason: string }> = []; - - for (const r of candidates.rows) { - // Filter out junk values that managed to land in primary_brand_domain - // (linkedin.com, hubspot CDN URLs, free-email providers, etc.). Same - // gate every live writer applies. Skipping here is safer than asserting - // them as verified — auto-link reads `od.verified=true` regardless of - // source, so an elevated junk row would route signups onto the wrong - // tenant. - let normalized: string; - try { - normalized = canonicalizeBrandDomain(r.primary_brand_domain); - assertClaimableBrandDomain(normalized); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - console.log(` NON-CLAIMABLE ${r.org_name} (${r.org_id}) ${r.primary_brand_domain} ${reason}`); - nonClaimable.push({ name: r.org_name, domain: r.primary_brand_domain, reason }); - skippedNonClaimable += 1; - continue; - } - - if (r.other_org_owns) { - console.log(` COLLISION ${r.org_name} (${r.org_id}) wants ${normalized} but org ${r.other_org_owns} (${r.other_org_name}) already owns it`); - collisions.push({ name: r.org_name, domain: normalized, owner: r.other_org_name ?? r.other_org_owns }); - skippedCollision += 1; - continue; - } - console.log(` ${apply ? 'INSERT' : 'WOULD INSERT'} ${r.org_name} (${r.org_id}) ${normalized} source=manual is_primary=true`); - if (apply) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query( - 'SELECT 1 FROM organizations WHERE workos_organization_id = $1 FOR UPDATE', - [r.org_id], - ); - - // Demote any existing is_primary=true row on this org first — there - // should be at most one. The personal-tier candidates almost never - // have one, but the assertion keeps the post-write invariant ("exactly - // one is_primary=true") clean. - await client.query( - `UPDATE organization_domains SET is_primary = false, updated_at = NOW() - WHERE workos_organization_id = $1 AND is_primary = true`, - [r.org_id], - ); - - // Insert. Lowercase the domain on insert for symmetry with the - // LOWER-based candidate lookup. ON CONFLICT (domain) DO NOTHING — - // handles the rare race where another org claimed this domain between - // the SELECT above and this INSERT. - const ins = await client.query( - `INSERT INTO organization_domains - (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, LOWER($2), true, true, 'manual', NOW(), NOW()) - ON CONFLICT (domain) DO NOTHING - RETURNING domain`, - [r.org_id, normalized], - ); - if (ins.rowCount === 0) { - // Race-loser. Roll back the demotion so we don't leave the org with - // zero is_primary=true rows. - await client.query('ROLLBACK'); - console.log(` RACE: another writer inserted this domain first; demote rolled back`); - raceLost += 1; - continue; - } - - // Mirror the new primary onto organizations.email_domain. Match the - // lowercase form the row was inserted with. - await client.query( - `UPDATE organizations - SET email_domain = LOWER($1), updated_at = NOW() - WHERE workos_organization_id = $2`, - [normalized, r.org_id], - ); - - // Same invariant assertion as per-case-fixes. - const primaryCount = await client.query<{ n: string }>( - `SELECT COUNT(*)::text AS n FROM organization_domains - WHERE workos_organization_id = $1 AND is_primary = true`, - [r.org_id], - ); - const n = parseInt(primaryCount.rows[0].n, 10); - if (n !== 1) { - throw new Error(`Invariant violation for ${r.org_name} (${r.org_id}): expected 1 is_primary=true row, got ${n}`); - } - - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK').catch(() => {}); - throw err; - } finally { - client.release(); - } - } - inserted += 1; - } - - console.log(`\nResult: ${inserted} inserted${dryRun ? ' (would insert)' : ''}, ${skippedCollision} skipped (collision with another org), ${skippedNonClaimable} skipped (non-claimable domain), ${raceLost} race-lost (rolled back)`); - if (collisions.length > 0) { - console.log('\nCollisions need manual resolution:'); - for (const c of collisions) console.log(` ${c.name}: ${c.domain} owned by ${c.owner}`); - } - if (nonClaimable.length > 0) { - console.log('\nNon-claimable primary_brand_domain values (likely stale; need manual reset):'); - for (const c of nonClaimable) console.log(` ${c.name}: ${c.domain} — ${c.reason}`); - } - if (collisions.length > 0 || nonClaimable.length > 0 || raceLost > 0) { - process.exitCode = 2; - } -} - -async function main(): Promise { - const dbConfig = getDatabaseConfig(); - if (!dbConfig) { - console.error('DATABASE_URL is required'); - process.exit(1); - } - initializeDatabase(dbConfig); - const pool = getPool(); - - console.log(`Mode: ${dryRun ? 'DRY-RUN (use --apply to write)' : 'APPLY (writing changes)'}`); - - // Sanity: do the expected counts during canonicalize-www match the survey? - // We don't gate on this, just print so an operator can spot drift. - - if (phaseArg === 'canonicalize-www') { - await phaseCanonicalizeWww(pool); - } else if (phaseArg === 'per-case-fixes') { - await phasePerCaseFixes(pool); - } else if (phaseArg === 'insert-missing-rows') { - await phaseInsertMissingRows(pool); - } else { - console.error('Pass --phase=canonicalize-www, --phase=per-case-fixes, or --phase=insert-missing-rows'); - process.exit(1); - } -} - -main() - .then(() => closeDatabase()) - .then(() => process.exit(process.exitCode ?? 0)) - .catch(async (err) => { - console.error(err); - await closeDatabase().catch(() => {}); - process.exit(1); - }); diff --git a/server/src/services/announcement-drafter.ts b/server/src/services/announcement-drafter.ts index f939a6a055..4764685481 100644 --- a/server/src/services/announcement-drafter.ts +++ b/server/src/services/announcement-drafter.ts @@ -10,7 +10,7 @@ * double-newline paragraph style that pastes cleanly. * * Member-supplied fields (tagline, description, agent descriptions, - * primary_brand_domain) come from third-party-authored brand.json and + * brand domain) come from third-party-authored brand.json and * profile content — they are treated as untrusted data, length-capped, * and enclosed in explicit markers the system prompt tells the model to * treat as data, not instructions. diff --git a/server/src/services/brand-domain-resolver.ts b/server/src/services/brand-domain-resolver.ts index 21ef1e0851..e87f1e294d 100644 --- a/server/src/services/brand-domain-resolver.ts +++ b/server/src/services/brand-domain-resolver.ts @@ -1,27 +1,14 @@ /** * Single resolver for an org's brand-primary domain. * - * Stage 1 of the domain-column rationalization (#4159, spec at - * specs/domain-column-rationalization.md). After Stage 0's backfill, the - * canonical truth is `organization_domains.is_primary=true` for both - * org-membership-inference and brand-identity. This function is the single - * read surface for the brand-identity facet. - * - * During Stage 1, callers migrate from direct reads of - * `member_profiles.primary_brand_domain` to this resolver. Writers continue - * to dual-write both fields. Once every read site has migrated, Stage 2 - * drops the column and the fallback path here becomes a no-op (and gets - * removed). Stage 3 introduces the matching `setPrimaryDomain` writer. - * - * The resolver returns `null` when an org has neither a primary on - * organization_domains nor a profile-level brand_primary — caller must - * decide whether that's a hard error or a soft "not yet set" path. + * After the Stage 2 column drop (#4159), the only source is + * `organization_domains.is_primary=true`. Both org-membership-inference + * and brand-identity now share this row. Returns null when an org has no + * is_primary=true row. * * AUTHORIZATION: This is a low-level service with no authz inside. Callers * must have already verified the requesting principal has read access to * the supplied `orgId` (typically via `requireAuth` + membership check). - * Same trust posture as the existing `member_profiles.primary_brand_domain` - * reads it replaces. */ import { getPool } from '../db/client.js'; @@ -30,24 +17,16 @@ import { createLogger } from '../logger.js'; const logger = createLogger('brand-domain-resolver'); /** - * Resolve the brand-primary domain for an org. + * Resolve the brand-primary domain for an org. Reads + * `organization_domains.is_primary=true`. Returns null when no such row. * - * Read order: - * 1. `organization_domains.is_primary=true` (Stage 1 canonical) - * 2. `member_profiles.primary_brand_domain` (transition fallback) - * - * The fallback exists for orgs Stage 0 missed (e.g., HYPD orphan, or new - * orgs joining after the backfill ran). It logs a warn so we can spot any - * remaining drift before Stage 2 drops the column. + * Detects the rare case of multiple is_primary=true rows (the "exactly one" + * invariant Stage 0 enforced); logs a loud error but still returns a valid + * domain so callers don't crash on the anomaly. */ export async function getBrandPrimaryDomain(orgId: string): Promise { const pool = getPool(); - // Read all is_primary=true rows (no LIMIT) so we can detect the rare case - // of multiple primaries — Stage 0 enforced the "exactly 1" invariant on - // every write path, but a future bug could regress it. logger.error so - // it's loud; return the first row regardless (caller still gets a valid - // primary, we don't want to bring the page down on a data anomaly). const od = await pool.query<{ domain: string }>( `SELECT domain FROM organization_domains WHERE workos_organization_id = $1 AND is_primary = true`, @@ -56,37 +35,16 @@ export async function getBrandPrimaryDomain(orgId: string): Promise 1) { logger.error( { orgId, count: od.rows.length, domains: od.rows.map((r) => r.domain) }, - 'Multiple is_primary=true rows on organization_domains for one org — Stage 0 invariant broken', - ); - } - if (od.rows[0]) return od.rows[0].domain; - - // Fallback during transition. Post-Stage-0 should be near-empty; any hit - // here is a drift signal worth surfacing for Stage-1.5 cleanup. - const mp = await pool.query<{ primary_brand_domain: string | null }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [orgId], - ); - if (mp.rows[0]?.primary_brand_domain) { - logger.warn( - { orgId, fallback_value: mp.rows[0].primary_brand_domain }, - 'getBrandPrimaryDomain fell back to member_profiles.primary_brand_domain — Stage 0 missed this org or it joined post-backfill', + 'Multiple is_primary=true rows on organization_domains for one org — invariant broken', ); - return mp.rows[0].primary_brand_domain; } - - return null; + return od.rows[0]?.domain ?? null; } /** * Batch variant. Returns a Map keyed by org_id with values of brand-primary - * domain. Orgs with neither a primary nor a fallback are absent from the - * map (not present with a null/undefined value). Use this from call sites - * that walk a list of orgs (e.g., dashboard list views, announcement-trigger - * batches) to avoid N+1 queries. - * - * Same fallback semantics as `getBrandPrimaryDomain`: org_domains.is_primary - * wins; profile field fills any gaps with a per-orgId warn. + * domain. Orgs with no is_primary=true row are absent from the map. Use + * this from call sites that walk a list of orgs to avoid N+1 queries. */ export async function getBrandPrimaryDomainsForOrgs( orgIds: ReadonlyArray, @@ -96,7 +54,6 @@ export async function getBrandPrimaryDomainsForOrgs( const pool = getPool(); - // Step 1: org_domains.is_primary=true rows for the requested orgs. // Detect multi-primary anomalies in the aggregate (one logger.error per // affected org rather than per row). const od = await pool.query<{ workos_organization_id: string; domain: string }>( @@ -117,36 +74,9 @@ export async function getBrandPrimaryDomainsForOrgs( if (domains.length > 1) { logger.error( { orgId, count: domains.length, domains }, - 'Multiple is_primary=true rows on organization_domains for one org — Stage 0 invariant broken', - ); - } - } - - // Step 2: fall back to member_profiles for orgs not yet in the result. - // Aggregate the fallback warn into a single log line per batch instead of - // one-per-row — keeps the signal usable even if a large batch hits many - // fallbacks. - const missing = orgIds.filter((id) => !result.has(id)); - if (missing.length > 0) { - const mp = await pool.query<{ workos_organization_id: string; primary_brand_domain: string }>( - `SELECT workos_organization_id, primary_brand_domain - FROM member_profiles - WHERE workos_organization_id = ANY($1::varchar[]) - AND primary_brand_domain IS NOT NULL`, - [missing], - ); - if (mp.rows.length > 0) { - logger.warn( - { - count: mp.rows.length, - orgIds: mp.rows.map((r) => r.workos_organization_id), - }, - 'getBrandPrimaryDomainsForOrgs fell back to member_profiles.primary_brand_domain for some orgs', + 'Multiple is_primary=true rows on organization_domains for one org — invariant broken', ); } - for (const row of mp.rows) { - result.set(row.workos_organization_id, row.primary_brand_domain); - } } return result; diff --git a/server/src/services/brand-identity.ts b/server/src/services/brand-identity.ts index f1583c549e..8138c894e3 100644 --- a/server/src/services/brand-identity.ts +++ b/server/src/services/brand-identity.ts @@ -25,11 +25,8 @@ export interface UpdateBrandIdentityInput { /** Display name used when minting a new brand record. */ displayName: string; /** - * Member profile, if one exists. The function dual-writes - * `member_profiles.primary_brand_domain` during the Stage 1 transition - * (Stage 2 drops the column); the profile id is needed to target the - * write. `contact_website` is a fallback brand-domain hint when the - * resolver returns null. + * Member profile, if one exists. `contact_website` is a fallback + * brand-domain hint used when the resolver returns null. */ profile?: { id: string; @@ -235,16 +232,12 @@ export async function updateBrandIdentity( ); } - // Dual-write the legacy member_profiles.primary_brand_domain column - // during the Stage 1 transition. Stage 2 drops the column; this write - // goes away with it. The compare-against-existingPrimary check skips - // a no-op write when the brand-primary hasn't actually changed. - if (profile && existingPrimary !== brandDomain) { - await client.query( - 'UPDATE member_profiles SET primary_brand_domain = $1, updated_at = NOW() WHERE id = $2', - [brandDomain, profile.id] - ); - } + // Stage 2 of #4159 dropped member_profiles.primary_brand_domain. The + // canonical brand-primary now lives on organization_domains.is_primary; + // when the brand-claim verify path needs to flip a primary, that should + // happen via setPrimaryDomain (Stage 3). This function still writes the + // brand identity itself (logos, colors) into the brands registry; it no + // longer mirrors the chosen domain back into member_profiles. await client.query('COMMIT'); } catch (error) { diff --git a/server/src/services/identifier-normalization.ts b/server/src/services/identifier-normalization.ts index 958702254a..63ab3d7ef6 100644 --- a/server/src/services/identifier-normalization.ts +++ b/server/src/services/identifier-normalization.ts @@ -81,7 +81,7 @@ const SHARED_PLATFORM_DOMAINS = new Set([ 'tumblr.com', 'wixsite.com', 'squarespace.com', // Social / profile platforms — owned by other entities, claiming one would // route their entire user base into the claiming tenant via auto-link - // (e.g., Mangrove had `linkedin.com` as their primary_brand_domain). + // (e.g., Mangrove had `linkedin.com` as their primary brand domain). 'linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'fb.com', 'instagram.com', 'youtube.com', 'tiktok.com', 'reddit.com', 'pinterest.com', 'discord.com', 'snapchat.com', 'threads.net', diff --git a/server/src/types.ts b/server/src/types.ts index ed3a634769..8f83431168 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -738,7 +738,6 @@ export interface MemberProfile { slug: string; tagline?: string; description?: string; - primary_brand_domain?: string; resolved_brand?: MemberBrandInfo; contact_email?: string; contact_website?: string; @@ -771,7 +770,6 @@ export interface CreateMemberProfileInput { slug: string; tagline?: string; description?: string; - primary_brand_domain?: string; contact_email?: string; contact_website?: string; contact_phone?: string; @@ -794,7 +792,6 @@ export interface UpdateMemberProfileInput { display_name?: string; tagline?: string; description?: string; - primary_brand_domain?: string; contact_email?: string; contact_website?: string; contact_phone?: string; diff --git a/server/tests/integration/agent-visibility-e2e.test.ts b/server/tests/integration/agent-visibility-e2e.test.ts index c196406761..49b534d647 100644 --- a/server/tests/integration/agent-visibility-e2e.test.ts +++ b/server/tests/integration/agent-visibility-e2e.test.ts @@ -127,6 +127,10 @@ describe('Agent visibility E2E', () => { }); afterAll(async () => { + await pool.query( + `DELETE FROM organization_domains WHERE workos_organization_id LIKE $1`, + [`${TEST_PREFIX}%`], + ); await pool.query( `DELETE FROM member_profiles WHERE workos_organization_id LIKE $1`, [`${TEST_PREFIX}%`], @@ -146,6 +150,18 @@ describe('Agent visibility E2E', () => { await closeDatabase(); }); + async function seedBrandPrimary(orgId: string, domain: string) { + await pool.query( + `INSERT INTO organization_domains + (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) + ON CONFLICT (domain) DO UPDATE SET + workos_organization_id = EXCLUDED.workos_organization_id, + verified = true, is_primary = true, source = 'workos'`, + [orgId, domain], + ); + } + async function provisionUser(userId: string, orgId: string) { await pool.query( `INSERT INTO users (workos_user_id, email, primary_organization_id, created_at, updated_at) @@ -169,16 +185,20 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: `Test ${slug}`, slug, - primary_brand_domain: `${slug}.example`, is_public: true, agents: [ { url: `https://a1.${slug}.example`, visibility: 'private' }, { url: `https://a2.${slug}.example`, visibility: 'members_only' }, ], }); + await seedBrandPrimary(orgId, `${slug}.example`); } beforeEach(async () => { + await pool.query( + `DELETE FROM organization_domains WHERE workos_organization_id LIKE $1`, + [`${TEST_PREFIX}%`], + ); await pool.query( `DELETE FROM member_profiles WHERE workos_organization_id LIKE $1`, [`${TEST_PREFIX}%`], @@ -280,7 +300,6 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Listing Org', slug: 'listing', - primary_brand_domain: 'listing.example', is_public: true, agents: [ { url: 'https://pub.listing.example', visibility: 'public' }, @@ -288,6 +307,7 @@ describe('Agent visibility E2E', () => { { url: 'https://priv.listing.example', visibility: 'private' }, ], }); + await seedBrandPrimary(orgId, 'listing.example'); const service = new AgentService(); const publicOnly = await service.listAgents(); @@ -310,7 +330,6 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Downgrade Org', slug: 'downgrade', - primary_brand_domain: 'downgrade.example', is_public: true, agents: [ { url: 'https://p1.downgrade.example', visibility: 'public' }, @@ -318,6 +337,7 @@ describe('Agent visibility E2E', () => { { url: 'https://m.downgrade.example', visibility: 'members_only' }, ], }); + await seedBrandPrimary(orgId, 'downgrade.example'); const result = await demotePublicAgentsOnTierDowngrade( orgId, @@ -342,10 +362,10 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Cancel Org', slug: 'cancel', - primary_brand_domain: 'cancel.example', is_public: true, agents: [{ url: 'https://p.cancel.example', visibility: 'public' }], }); + await seedBrandPrimary(orgId, 'cancel.example'); const result = await demotePublicAgentsOnTierDowngrade( orgId, @@ -420,12 +440,12 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Private Profile Org', slug: 'private-profile', - primary_brand_domain: 'privp.example', is_public: false, // Profile is not in public directory agents: [ { url: 'https://members.privp.example', visibility: 'members_only' }, ], }); + await seedBrandPrimary(orgId, 'privp.example'); const service = new AgentService(); const publicOnly = await service.listAgents(); @@ -447,12 +467,12 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Pub On Private Org', slug: 'pub-on-private', - primary_brand_domain: 'pubprivate.example', is_public: false, agents: [ { url: 'https://agent.pubprivate.example', visibility: 'public' }, ], }); + await seedBrandPrimary(orgId, 'pubprivate.example'); const service = new AgentService(); const agents = await service.listAgents(); @@ -511,12 +531,12 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Manifest Fail Org', slug: 'manifestfail', - primary_brand_domain: domain, is_public: true, agents: [ { url: `https://agent.${domain}`, visibility: 'private' }, ], }); + await seedBrandPrimary(orgId, domain); // Seed a community-hosted brand row so the publish hits the // intended code path (`target==='public' && !isSelfHosted`). Without // this, `discovered` is null and the test passes via the missing- @@ -574,12 +594,12 @@ describe('Agent visibility E2E', () => { workos_organization_id: orgId, display_name: 'Self Hosted Org', slug: 'selfhosted', - primary_brand_domain: domain, is_public: true, agents: [ { url: `https://agent.${domain}`, visibility: 'private' }, ], }); + await seedBrandPrimary(orgId, domain); await brandDb.upsertDiscoveredBrand({ domain, source_type: 'brand_json', diff --git a/server/tests/integration/brand-domain-resolver.test.ts b/server/tests/integration/brand-domain-resolver.test.ts index b02cb49b0e..a6f6311e4c 100644 --- a/server/tests/integration/brand-domain-resolver.test.ts +++ b/server/tests/integration/brand-domain-resolver.test.ts @@ -1,9 +1,10 @@ /** - * Integration tests for the brand-domain resolver (Stage 1 of #4159). + * Integration tests for the brand-domain resolver. * - * Asserts the read order: organization_domains.is_primary first, then - * member_profiles.primary_brand_domain as a transition fallback. Single + - * batch variants both covered. + * Reads `organization_domains.is_primary=true` only — the + * member_profiles.primary_brand_domain fallback was removed when the + * column was dropped (Stage 2 of #4159). Single + batch variants both + * covered. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; @@ -44,19 +45,6 @@ async function seedDomain(pool: Pool, orgId: string, domain: string, isPrimary: ); } -async function seedProfile(pool: Pool, orgId: string, slug: string, brandPrimary: string | null) { - await pool.query( - `INSERT INTO member_profiles (workos_organization_id, slug, display_name, primary_brand_domain, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO UPDATE - SET primary_brand_domain = EXCLUDED.primary_brand_domain, updated_at = NOW()`, - [orgId, slug, slug, brandPrimary], - ); -} - -// Single beforeAll/afterAll pair shared across both describe blocks via -// vitest's file-scoped lifecycle. Avoids running migrations twice and -// initializing the pool twice (which races in the same process). let pool: Pool; beforeAll(async () => { pool = initializeDatabase({ @@ -79,50 +67,23 @@ describe('getBrandPrimaryDomain', () => { await seedOrg(pool, ORG_A, 'A Co'); await seedDomain(pool, ORG_A, 'a.example', true); await seedDomain(pool, ORG_A, 'a-secondary.example', false); - await seedProfile(pool, ORG_A, 'a-co', 'a.example'); - - expect(await getBrandPrimaryDomain(ORG_A)).toBe('a.example'); - }); - - it('prefers org_domains over a divergent member_profiles value', async () => { - // Post-Stage-0 these should never diverge, but the resolver's contract - // is: org_domains wins. - await seedOrg(pool, ORG_A, 'A Co'); - await seedDomain(pool, ORG_A, 'a.example', true); - await seedProfile(pool, ORG_A, 'a-co', 'a-old.example'); expect(await getBrandPrimaryDomain(ORG_A)).toBe('a.example'); }); - it('falls back to member_profiles.primary_brand_domain when no org_domains primary exists', async () => { - await seedOrg(pool, ORG_A, 'A Co'); - await seedProfile(pool, ORG_A, 'a-co', 'a-fallback.example'); - - expect(await getBrandPrimaryDomain(ORG_A)).toBe('a-fallback.example'); - }); - - it('returns null when neither source has a value', async () => { - await seedOrg(pool, ORG_A, 'A Co'); - await seedProfile(pool, ORG_A, 'a-co', null); - - expect(await getBrandPrimaryDomain(ORG_A)).toBeNull(); - }); - - it('returns null when the org has no profile and no domains', async () => { + it('returns null when no org_domains primary row exists', async () => { await seedOrg(pool, ORG_A, 'A Co'); + await seedDomain(pool, ORG_A, 'a.example', false); expect(await getBrandPrimaryDomain(ORG_A)).toBeNull(); }); - it('does not match a non-primary verified row', async () => { + it('returns null when the org has no domains at all', async () => { await seedOrg(pool, ORG_A, 'A Co'); - await seedDomain(pool, ORG_A, 'a.example', false); // verified but not primary - await seedProfile(pool, ORG_A, 'a-co', null); - expect(await getBrandPrimaryDomain(ORG_A)).toBeNull(); }); it('returns an unverified is_primary=true row (verified is enforced at write time, not read)', async () => { - // The resolver does not filter on verified — Stage 0's writers ensure + // The resolver does not filter on verified — writers ensure // is_primary=true rows are also verified, and the publish-agent gate // re-checks verified independently. Document the contract here so a // future refactor that adds a verified filter is an intentional change. @@ -133,16 +94,15 @@ describe('getBrandPrimaryDomain', () => { ON CONFLICT (domain) DO UPDATE SET verified = false, is_primary = true`, [ORG_A, 'a-unverified.example'], ); - await seedProfile(pool, ORG_A, 'a-co', null); expect(await getBrandPrimaryDomain(ORG_A)).toBe('a-unverified.example'); }); it('returns the first row but does not throw when multiple is_primary=true rows exist (data anomaly)', async () => { - // The Stage 0 invariant says exactly one is_primary=true row per org. - // If a future bug regresses it, the resolver should still produce a - // valid primary (some primary is better than none) and surface the - // anomaly via logger.error. This test documents the don't-crash - // contract; the log assertion isn't checked here. + // The invariant is exactly one is_primary=true row per org. If a + // future bug regresses it, the resolver should still produce a valid + // primary (some primary is better than none) and surface the anomaly + // via logger.error. This test documents the don't-crash contract; + // the log assertion isn't checked here. await seedOrg(pool, ORG_A, 'A Co'); await seedDomain(pool, ORG_A, 'a-1.example', true); await seedDomain(pool, ORG_A, 'a-2.example', true); @@ -161,32 +121,22 @@ describe('getBrandPrimaryDomainsForOrgs', () => { expect(await getBrandPrimaryDomainsForOrgs([])).toEqual(new Map()); }); - it('resolves a mix of org_domains and member_profiles fallbacks', async () => { + it('resolves a mix of orgs with and without primaries', async () => { await seedOrg(pool, ORG_A, 'A Co'); await seedOrg(pool, ORG_B, 'B Co'); await seedOrg(pool, ORG_C, 'C Co'); // ORG_A: primary on org_domains await seedDomain(pool, ORG_A, 'a.example', true); - await seedProfile(pool, ORG_A, 'a-co', 'a.example'); - // ORG_B: only on member_profiles - await seedProfile(pool, ORG_B, 'b-co', 'b-fallback.example'); + // ORG_B: only a non-primary verified row + await seedDomain(pool, ORG_B, 'b.example', false); - // ORG_C: no brand identity at all - await seedProfile(pool, ORG_C, 'c-co', null); + // ORG_C: no domains at all const result = await getBrandPrimaryDomainsForOrgs([ORG_A, ORG_B, ORG_C]); expect(result.get(ORG_A)).toBe('a.example'); - expect(result.get(ORG_B)).toBe('b-fallback.example'); + expect(result.has(ORG_B)).toBe(false); expect(result.has(ORG_C)).toBe(false); }); - - it('does not include orgs with no brand identity in the result map', async () => { - await seedOrg(pool, ORG_A, 'A Co'); - await seedProfile(pool, ORG_A, 'a-co', null); - - const result = await getBrandPrimaryDomainsForOrgs([ORG_A]); - expect(result.size).toBe(0); - }); }); diff --git a/server/tests/integration/brand-orphan-adoption.test.ts b/server/tests/integration/brand-orphan-adoption.test.ts index 4d25f68266..c0d3154b7c 100644 --- a/server/tests/integration/brand-orphan-adoption.test.ts +++ b/server/tests/integration/brand-orphan-adoption.test.ts @@ -93,10 +93,9 @@ describe('Brand orphan-adoption integration', () => { [PRIOR_ORG, NEW_ORG] ); - // Seed the brand-primary on organization_domains for NEW_ORG so the Stage 1 - // resolver returns TEST_DOMAIN. Pre-Stage-1 the test relied on - // profile.primary_brand_domain passed to updateBrandIdentity; the function - // now reads via getBrandPrimaryDomain(workosOrganizationId). + // Seed the brand-primary on organization_domains for NEW_ORG so the + // resolver returns TEST_DOMAIN — updateBrandIdentity reads via + // getBrandPrimaryDomain(workosOrganizationId). await pool.query( `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source) VALUES ($1, $2, true, true, 'manual') @@ -176,7 +175,7 @@ describe('Brand orphan-adoption integration', () => { updateBrandIdentity({ workosOrganizationId: NEW_ORG, displayName: 'New Owner Inc', - profile: { id: 'profile-test', primary_brand_domain: TEST_DOMAIN }, + profile: { id: 'profile-test' }, logoUrl: 'https://newowner.example.com/logo.png', // adoptPriorManifest intentionally omitted }) @@ -194,7 +193,7 @@ describe('Brand orphan-adoption integration', () => { const result = await updateBrandIdentity({ workosOrganizationId: NEW_ORG, displayName: 'New Owner Inc', - profile: { id: 'profile-test', primary_brand_domain: TEST_DOMAIN }, + profile: { id: 'profile-test' }, logoUrl: 'https://newowner.example.com/logo.png', adoptPriorManifest: false, }); @@ -232,7 +231,7 @@ describe('Brand orphan-adoption integration', () => { const result = await updateBrandIdentity({ workosOrganizationId: NEW_ORG, displayName: 'New Owner Inc', - profile: { id: 'profile-test', primary_brand_domain: TEST_DOMAIN }, + profile: { id: 'profile-test' }, logoUrl: 'https://newowner.example.com/logo.png', adoptPriorManifest: true, }); @@ -282,7 +281,7 @@ describe('Brand orphan-adoption integration', () => { updateBrandIdentity({ workosOrganizationId: NEW_ORG, displayName: 'New Owner Inc', - profile: { id: 'profile-test', primary_brand_domain: TEST_DOMAIN }, + profile: { id: 'profile-test' }, logoUrl: 'https://newowner.example.com/logo.png', adoptPriorManifest: true, // even with adopt set, cross-org wins }) diff --git a/server/tests/integration/me-organization-domains.test.ts b/server/tests/integration/me-organization-domains.test.ts index 7d73a2674c..09708a1e15 100644 --- a/server/tests/integration/me-organization-domains.test.ts +++ b/server/tests/integration/me-organization-domains.test.ts @@ -1,9 +1,9 @@ /** * Integration tests for the member-facing /api/me/organization/domains - * surface. Covers list + set-primary, with the dual-write semantic that - * sets `member_profiles.primary_brand_domain` alongside - * `organization_domains.is_primary` so members don't have to know about - * the two-primary distinction (Media.net escalation #321 root cause). + * surface. Covers list + set-primary. After Stage 2 of #4159, + * `organization_domains.is_primary` is the single source of truth for + * both brand identity and org-membership inference, so a single PUT + * unambiguously sets the primary (Media.net escalation #321). * * Auth in dev mode reads from the local organization_memberships seed * (resolveUserOrgMembership dev bypass), so we seed memberships rather @@ -72,13 +72,12 @@ async function seedOrgWithDomains(pool: Pool, domains: Array<{ domain: string; v } } -async function seedProfile(pool: Pool, primary: string | null) { +async function seedProfile(pool: Pool) { await pool.query( - `INSERT INTO member_profiles (workos_organization_id, slug, display_name, primary_brand_domain, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO UPDATE - SET primary_brand_domain = EXCLUDED.primary_brand_domain, updated_at = NOW()`, - [TEST_ORG, 'me-domains-test', 'Me Domains Test Co', primary], + `INSERT INTO member_profiles (workos_organization_id, slug, display_name, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO NOTHING`, + [TEST_ORG, 'me-domains-test', 'Me Domains Test Co'], ); } @@ -136,7 +135,7 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { { domain: 'me-domains-2.test', verified: true, is_primary: false }, { domain: 'me-domains-pending.test', verified: false, is_primary: false }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, OWNER_USER, 'owner'); const res = await request(app) @@ -152,12 +151,12 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { expect(byDomain['me-domains-pending.test']).toMatchObject({ verified: false }); }); - it('PUT primary updates BOTH organization_domains AND member_profiles.primary_brand_domain', async () => { + it('PUT primary moves organization_domains.is_primary and updates organizations.email_domain', async () => { await seedOrgWithDomains(pool, [ { domain: 'me-domains-1.test', verified: true, is_primary: true }, { domain: 'me-domains-2.test', verified: true, is_primary: false }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, OWNER_USER, 'owner'); const res = await request(app) @@ -165,10 +164,9 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { .set('x-test-user', OWNER_USER); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ success: true, primary_domain: 'me-domains-2.test', brand_primary_updated: true }); + expect(res.body).toMatchObject({ success: true, primary_domain: 'me-domains-2.test' }); expect(cacheInvalidations).toBe(1); - // organization_domains.is_primary moved const od = await pool.query<{ domain: string; is_primary: boolean }>( `SELECT domain, is_primary FROM organization_domains WHERE workos_organization_id = $1`, [TEST_ORG], @@ -177,19 +175,11 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { expect(od_by['me-domains-2.test']).toBe(true); expect(od_by['me-domains-1.test']).toBe(false); - // organizations.email_domain follows const org = await pool.query<{ email_domain: string }>( `SELECT email_domain FROM organizations WHERE workos_organization_id = $1`, [TEST_ORG], ); expect(org.rows[0].email_domain).toBe('me-domains-2.test'); - - // member_profiles.primary_brand_domain follows (the dual-write that fixes #321) - const profile = await pool.query<{ primary_brand_domain: string | null }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [TEST_ORG], - ); - expect(profile.rows[0].primary_brand_domain).toBe('me-domains-2.test'); }); it('rejects PUT primary from a non-owner/admin member', async () => { @@ -197,7 +187,7 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { { domain: 'me-domains-1.test', verified: true, is_primary: true }, { domain: 'me-domains-2.test', verified: true, is_primary: false }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, MEMBER_USER, 'member'); const res = await request(app) @@ -220,7 +210,7 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { { domain: 'me-domains-1.test', verified: true, is_primary: true }, { domain: 'me-domains-pending.test', verified: false, is_primary: false }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, OWNER_USER, 'owner'); const res = await request(app) @@ -231,46 +221,39 @@ describe('GET /api/me/organization/domains + PUT /:domain/primary', () => { expect(res.body.error).toBe('domain_not_verified'); }); - it('refuses brand-primary dual-write for source != workos (admin-imported / manual rows)', async () => { - // An admin-imported "verified" row shouldn't be promotable to brand - // identity — only WorkOS DNS-proven domains can seed primary_brand_domain. - // The org-membership-inference primary still flips (that's a different - // concern, controlled by admin tools), but member_profiles stays put. + it('refuses PUT primary for source != workos (admin-imported / manual rows)', async () => { + // After Stage 2 of #4159, is_primary drives both org-membership + // inference and brand identity. We hold the bar at WorkOS DNS proof: + // an admin-imported verified=true row shouldn't be promotable via + // member self-service — that would let an admin escalate brand + // identity by importing a row. await seedOrgWithDomains(pool, [ { domain: 'me-domains-1.test', verified: true, is_primary: true, source: 'workos' }, { domain: 'me-domains-imported.test', verified: true, is_primary: false, source: 'import' }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, OWNER_USER, 'owner'); const res = await request(app) .put('/api/me/organization/domains/me-domains-imported.test/primary?org=' + TEST_ORG) .set('x-test-user', OWNER_USER); - expect(res.status).toBe(200); - expect(res.body.brand_primary_updated).toBe(false); - - const profile = await pool.query<{ primary_brand_domain: string | null }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [TEST_ORG], - ); - // Brand identity preserved on the original WorkOS-verified domain. - expect(profile.rows[0].primary_brand_domain).toBe('me-domains-1.test'); + expect(res.status).toBe(400); + expect(res.body.error).toBe('domain_not_workos_verified'); - // org-membership primary still moved (admin-imported domains may legitimately - // be the membership-inference primary, e.g. for a prospect we're tracking). + // is_primary unchanged — the original WorkOS-verified domain stays primary. const od = await pool.query<{ domain: string; is_primary: boolean }>( `SELECT domain, is_primary FROM organization_domains WHERE workos_organization_id = $1 AND is_primary = true`, [TEST_ORG], ); - expect(od.rows[0].domain).toBe('me-domains-imported.test'); + expect(od.rows[0].domain).toBe('me-domains-1.test'); }); it('returns 404 for a domain not on this org', async () => { await seedOrgWithDomains(pool, [ { domain: 'me-domains-1.test', verified: true, is_primary: true }, ]); - await seedProfile(pool, 'me-domains-1.test'); + await seedProfile(pool); await seedMembership(pool, OWNER_USER, 'owner'); const res = await request(app) diff --git a/server/tests/integration/member-agents-api.test.ts b/server/tests/integration/member-agents-api.test.ts index 698fa26f2a..bf6ab6e9dd 100644 --- a/server/tests/integration/member-agents-api.test.ts +++ b/server/tests/integration/member-agents-api.test.ts @@ -184,13 +184,25 @@ describe('Per-agent REST API (/api/me/agents)', () => { workos_organization_id: orgId, display_name: `Test ${slug}`, slug, - primary_brand_domain: `${slug}.example`, is_public: false, agents: [{ url: 'https://existing.example/mcp', visibility: 'private' }], }); + await pool.query( + `INSERT INTO organization_domains + (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) + ON CONFLICT (domain) DO UPDATE SET + workos_organization_id = EXCLUDED.workos_organization_id, + verified = true, is_primary = true, source = 'workos'`, + [orgId, `${slug}.example`], + ); } beforeEach(async () => { + await pool.query( + `DELETE FROM organization_domains WHERE workos_organization_id LIKE $1`, + [`${TEST_PREFIX}%`], + ); await pool.query( `DELETE FROM member_profiles WHERE workos_organization_id LIKE $1`, [`${TEST_PREFIX}%`], @@ -513,7 +525,6 @@ describe('Per-agent REST API (/api/me/agents)', () => { workos_organization_id: orgId, display_name: 'Test patchtype', slug: 'patchtype', - primary_brand_domain: 'patchtype.example', is_public: false, agents: [ { url: 'https://existing.example/mcp', type: 'sales', visibility: 'private' }, @@ -552,91 +563,6 @@ describe('Per-agent REST API (/api/me/agents)', () => { expect(swapped.body.agent.type).toBe('buying'); }); - // ── primary_brand_domain auto-backfill (PR #4235) ────────────── - // When a profile has no brand domain and agents agree on a hostname, - // backfill atomically with the JSONB write so /api/registry/operator - // discovery works without a separate setup step. - - it('POST backfills primary_brand_domain from the agent hostname when null and unanimous', async () => { - const orgId = `${TEST_PREFIX}_bd_backfill`; - const userId = `${TEST_PREFIX}_bd_backfill_user`; - await seedOrg(pool, orgId, 'individual_professional'); - await provisionUser(userId, orgId); - // Seed a profile with NULL primary_brand_domain (mirrors the - // pre-PR auto-bootstrap path that produced the harvingupta bug). - await memberDb.createProfile({ - workos_organization_id: orgId, - display_name: 'Test bdbackfill', - slug: 'bdbackfill', - // primary_brand_domain intentionally omitted — defaults to null. - is_public: false, - agents: [], - }); - - (app as any).setCurrentUser(userId); - const res = await request(app) - .post('/api/me/agents') - .send({ url: 'https://www.bdbackfill.example/api/mcp', type: 'sales', visibility: 'private' }); - expect(res.status).toBe(201); - - const profile = await memberDb.getProfileByOrgId(orgId); - // `www.` stripped to match `extractDomain` in registry-api so a - // /api/registry/operator?domain=bdbackfill.example query lands. - expect(profile!.primary_brand_domain).toBe('bdbackfill.example'); - }); - - it('POST does NOT backfill primary_brand_domain when agents disagree on hostname', async () => { - const orgId = `${TEST_PREFIX}_bd_conflict`; - const userId = `${TEST_PREFIX}_bd_conflict_user`; - await seedOrg(pool, orgId, 'individual_professional'); - await provisionUser(userId, orgId); - await memberDb.createProfile({ - workos_organization_id: orgId, - display_name: 'Test bdconflict', - slug: 'bdconflict', - is_public: false, - agents: [ - { url: 'https://first.example/mcp', type: 'sales', visibility: 'private' }, - ], - }); - - (app as any).setCurrentUser(userId); - const res = await request(app) - .post('/api/me/agents') - .send({ url: 'https://second.example/mcp', type: 'sales', visibility: 'private' }); - expect(res.status).toBe(201); - - const profile = await memberDb.getProfileByOrgId(orgId); - // Two distinct hostnames in the agents array — refuse to guess. - expect(profile!.primary_brand_domain).toBeNull(); - }); - - it('POST does NOT overwrite primary_brand_domain when one is already set', async () => { - const orgId = `${TEST_PREFIX}_bd_preset`; - const userId = `${TEST_PREFIX}_bd_preset_user`; - await seedOrg(pool, orgId, 'individual_professional'); - await provisionUser(userId, orgId); - await memberDb.createProfile({ - workos_organization_id: orgId, - display_name: 'Test bdpreset', - slug: 'bdpreset', - primary_brand_domain: 'preset.example', - is_public: false, - agents: [], - }); - - (app as any).setCurrentUser(userId); - const res = await request(app) - .post('/api/me/agents') - .send({ url: 'https://different.example/mcp', type: 'sales', visibility: 'private' }); - expect(res.status).toBe(201); - - const profile = await memberDb.getProfileByOrgId(orgId); - // The existing brand-domain wins; auto-backfill never fires when the - // column is already populated, even if the agent URL hostname differs. - expect(profile!.primary_brand_domain).toBe('preset.example'); - }); - // ── agent_registry_metadata seed on register (PR follow-up) ──── // Without this seed, an agent registered via /api/me/agents lives only // in member_profiles.agents JSONB and never enters the heartbeat's @@ -695,7 +621,6 @@ describe('Per-agent REST API (/api/me/agents)', () => { workos_organization_id: orgId, display_name: 'Test cteonly', slug: 'cteonly', - primary_brand_domain: 'cte-only.example', is_public: false, agents: [{ url: targetUrl, type: 'sales', visibility: 'private' }], }); @@ -757,7 +682,6 @@ describe('Per-agent REST API (/api/me/agents)', () => { workos_organization_id: orgId, display_name: 'Test deletepublic', slug: 'deletepublic', - primary_brand_domain: 'deletepublic.example', is_public: false, agents: [{ url: 'https://pub.example/mcp', visibility: 'public' }], }); diff --git a/server/tests/integration/member-profile-bootstrap.test.ts b/server/tests/integration/member-profile-bootstrap.test.ts index 90e9481581..9a09a4216b 100644 --- a/server/tests/integration/member-profile-bootstrap.test.ts +++ b/server/tests/integration/member-profile-bootstrap.test.ts @@ -189,11 +189,13 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => { company_type: 'publisher', revenue_tier: '5m_50m', corporate_domain: 'acme.example', - primary_brand_domain: 'acme.example', marketing_opt_in: true, }); expect(res.status).toBe(201); + // primary_brand_domain on the response is derived from + // organization_domains.is_primary, which the bootstrap auto-promotes + // for the corporate_domain. expect(res.body.profile).toMatchObject({ organization_id: orgId, organization_name: 'Acme Media', diff --git a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts index 4363a4f57a..44999dbf28 100644 --- a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts +++ b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts @@ -127,10 +127,23 @@ describe('Registry reader baseline — public endpoints', () => { 'DELETE FROM discovered_agents WHERE agent_url LIKE $1', [AGENT_LIKE] ); + await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1 OR domain LIKE $2', [ORG_ID, DOMAIN_LIKE]); await pool.query('DELETE FROM member_profiles WHERE workos_organization_id = $1', [ORG_ID]); await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [ORG_ID]); } + async function seedBrandPrimary(orgId: string, domain: string) { + await pool.query( + `INSERT INTO organization_domains + (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) + ON CONFLICT (domain) DO UPDATE SET + workos_organization_id = EXCLUDED.workos_organization_id, + verified = true, is_primary = true, source = 'workos'`, + [orgId, domain], + ); + } + beforeAll(async () => { pool = initializeDatabase({ connectionString: @@ -464,12 +477,11 @@ describe('Registry reader baseline — public endpoints', () => { await pool.query( `INSERT INTO member_profiles ( workos_organization_id, display_name, slug, - agents, primary_brand_domain, is_public, + agents, is_public, created_at, updated_at - ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, $4, true, NOW(), NOW()) + ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, true, NOW(), NOW()) ON CONFLICT (workos_organization_id) DO UPDATE SET agents = EXCLUDED.agents, - primary_brand_domain = EXCLUDED.primary_brand_domain, is_public = EXCLUDED.is_public, updated_at = NOW()`, [ @@ -478,9 +490,9 @@ describe('Registry reader baseline — public endpoints', () => { JSON.stringify([ { url: AGENT_X, name: 'Endpoint Sales X', type: 'sales', visibility: 'public' }, ]), - PUB_A, ] ); + await seedBrandPrimary(ORG_ID, PUB_A); const res = await request(app).get( `/api/registry/operator?domain=${encodeURIComponent(PUB_A)}` @@ -566,12 +578,11 @@ describe('Registry reader baseline — public endpoints', () => { await pool.query( `INSERT INTO member_profiles ( workos_organization_id, display_name, slug, - agents, primary_brand_domain, is_public, + agents, is_public, created_at, updated_at - ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, $4, true, NOW(), NOW()) + ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, true, NOW(), NOW()) ON CONFLICT (workos_organization_id) DO UPDATE SET agents = EXCLUDED.agents, - primary_brand_domain = EXCLUDED.primary_brand_domain, is_public = EXCLUDED.is_public, updated_at = NOW()`, [ @@ -580,9 +591,9 @@ describe('Registry reader baseline — public endpoints', () => { JSON.stringify([ { url: REGISTERED_URL, name: 'Endpoint Registered', type: 'sales', visibility: 'public' }, ]), - PUB_A, ] ); + await seedBrandPrimary(ORG_ID, PUB_A); const res = await request(app).get('/api/registry/agents'); expect(res.status).toBe(200); @@ -628,9 +639,9 @@ describe('Registry reader baseline — public endpoints', () => { await pool.query( `INSERT INTO member_profiles ( workos_organization_id, display_name, slug, - agents, primary_brand_domain, is_public, + agents, is_public, created_at, updated_at - ) VALUES ($1, 'Pub-on-Private Org', $2, $3::jsonb, $4, false, NOW(), NOW()) + ) VALUES ($1, 'Pub-on-Private Org', $2, $3::jsonb, false, NOW(), NOW()) ON CONFLICT (workos_organization_id) DO UPDATE SET agents = EXCLUDED.agents, is_public = EXCLUDED.is_public, updated_at = NOW()`, [ @@ -639,9 +650,9 @@ describe('Registry reader baseline — public endpoints', () => { JSON.stringify([ { url: PRIV_AGENT, name: 'Pub On Private', type: 'buying', visibility: 'public' }, ]), - PUB_A, ], ); + await seedBrandPrimary(PRIV_ORG, PUB_A); try { const res = await request(app).get('/api/registry/agents'); expect(res.status).toBe(200); @@ -650,6 +661,7 @@ describe('Registry reader baseline — public endpoints', () => { expect(found).toBeTruthy(); expect(found.member).toMatchObject({ slug: 'endpoint-pub-on-private-baseline' }); } finally { + await pool.query(`DELETE FROM organization_domains WHERE workos_organization_id = $1`, [PRIV_ORG]); await pool.query(`DELETE FROM member_profiles WHERE workos_organization_id = $1`, [PRIV_ORG]); await pool.query(`DELETE FROM organizations WHERE workos_organization_id = $1`, [PRIV_ORG]); } @@ -692,12 +704,11 @@ describe('Registry reader baseline — public endpoints', () => { await pool.query( `INSERT INTO member_profiles ( workos_organization_id, display_name, slug, - agents, primary_brand_domain, is_public, + agents, is_public, created_at, updated_at - ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, $4, true, NOW(), NOW()) + ) VALUES ($1, 'Endpoint Baseline Org', $2, $3::jsonb, true, NOW(), NOW()) ON CONFLICT (workos_organization_id) DO UPDATE SET agents = EXCLUDED.agents, - primary_brand_domain = EXCLUDED.primary_brand_domain, is_public = EXCLUDED.is_public, updated_at = NOW()`, [ @@ -707,9 +718,9 @@ describe('Registry reader baseline — public endpoints', () => { { url: AGENT_X, name: 'Endpoint Sales X', type: 'sales', visibility: 'public' }, { url: AGENT_Y, name: 'Endpoint Buyer Y', type: 'buying', visibility: 'public' }, ]), - PUB_A, ] ); + await seedBrandPrimary(ORG_ID, PUB_A); const res = await request(app).get('/api/registry/agents?properties=true'); expect(res.status).toBe(200); diff --git a/server/tests/integration/stage0-domain-cleanup.test.ts b/server/tests/integration/stage0-domain-cleanup.test.ts deleted file mode 100644 index 653141ad86..0000000000 --- a/server/tests/integration/stage0-domain-cleanup.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Integration tests for the Stage 0 domain-cleanup script - * (server/src/scripts/stage0-domain-cleanup.ts). - * - * Exercises the canonicalize-www phase and the guards around per-case-fixes - * against a synthetic org. The actual per-case fixes are exercised manually - * via dry-run on prod before --apply, so those aren't unit-asserted here — - * the value of integration tests for them would be low (the data is the - * test). - */ - -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import { initializeDatabase, getPool, closeDatabase } from '../../src/db/client.js'; -import { runMigrations } from '../../src/db/migrate.js'; -import type { Pool } from 'pg'; - -const TEST_ORG = 'org_stage0_test'; - -async function cleanup(pool: Pool) { - await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [TEST_ORG]); - await pool.query('DELETE FROM member_profiles WHERE workos_organization_id = $1', [TEST_ORG]); - await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [TEST_ORG]); -} - -async function seedOrg(pool: Pool) { - await pool.query( - `INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at) - VALUES ($1, $2, false, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO NOTHING`, - [TEST_ORG, 'Stage 0 Test Co'], - ); -} - -async function seedDomains(pool: Pool, rows: Array<{ domain: string; verified: boolean; is_primary: boolean; source?: string }>) { - for (const r of rows) { - await pool.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) - ON CONFLICT (domain) DO UPDATE SET verified = EXCLUDED.verified, is_primary = EXCLUDED.is_primary, source = EXCLUDED.source`, - [TEST_ORG, r.domain, r.verified, r.is_primary, r.source ?? 'workos'], - ); - } -} - -async function seedProfile(pool: Pool, primary: string | null) { - await pool.query( - `INSERT INTO member_profiles (workos_organization_id, slug, display_name, primary_brand_domain, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO UPDATE - SET primary_brand_domain = EXCLUDED.primary_brand_domain, updated_at = NOW()`, - [TEST_ORG, 'stage0-test', 'Stage 0 Test Co', primary], - ); -} - -describe('Stage 0 domain-cleanup: canonicalize-www phase (SQL behavior)', () => { - let pool: Pool; - - beforeAll(async () => { - pool = initializeDatabase({ - connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', - }); - await runMigrations(); - }, 60000); - - afterAll(async () => { - await cleanup(pool); - await closeDatabase(); - }); - - beforeEach(async () => { - await cleanup(pool); - await seedOrg(pool); - }); - - // The canonicalize-www phase is one SQL query + a per-row UPDATE. We - // assert the candidate-discovery query and the UPDATE together. - it('updates www.foo.com → foo.com when the apex exists in organization_domains', async () => { - await seedDomains(pool, [ - { domain: 'foo.com', verified: true, is_primary: true }, - ]); - await seedProfile(pool, 'www.foo.com'); - - // Replicate the discovery query from the script. - const candidates = await pool.query(` - WITH profile_www AS ( - SELECT - mp.workos_organization_id, - mp.primary_brand_domain AS current, - SUBSTRING(mp.primary_brand_domain FROM 5) AS apex - FROM member_profiles mp - WHERE LOWER(mp.primary_brand_domain) LIKE 'www.%' - ) - SELECT - pw.workos_organization_id, pw.current, pw.apex, - EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = pw.workos_organization_id - AND LOWER(od.domain) = LOWER(pw.apex) - ) AS apex_in_org_domains - FROM profile_www pw - WHERE pw.workos_organization_id = $1 - `, [TEST_ORG]); - - expect(candidates.rowCount).toBe(1); - expect(candidates.rows[0].apex).toBe('foo.com'); - expect(candidates.rows[0].apex_in_org_domains).toBe(true); - - // Apply the UPDATE. - await pool.query( - `UPDATE member_profiles - SET primary_brand_domain = $1, updated_at = NOW() - WHERE workos_organization_id = $2 AND primary_brand_domain = $3`, - ['foo.com', TEST_ORG, 'www.foo.com'], - ); - - const after = await pool.query<{ primary_brand_domain: string }>( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, - [TEST_ORG], - ); - expect(after.rows[0].primary_brand_domain).toBe('foo.com'); - }); - - it('skips when the apex is NOT in organization_domains', async () => { - // Only the www-prefixed form is in organization_domains; apex absent. - await seedDomains(pool, [ - { domain: 'www.bar.com', verified: true, is_primary: true }, - ]); - await seedProfile(pool, 'www.bar.com'); - - const candidates = await pool.query(` - WITH profile_www AS ( - SELECT mp.workos_organization_id, mp.primary_brand_domain AS current, - SUBSTRING(mp.primary_brand_domain FROM 5) AS apex - FROM member_profiles mp - WHERE LOWER(mp.primary_brand_domain) LIKE 'www.%' - ) - SELECT pw.apex, - EXISTS ( - SELECT 1 FROM organization_domains od - WHERE od.workos_organization_id = pw.workos_organization_id - AND LOWER(od.domain) = LOWER(pw.apex) - ) AS apex_in_org_domains - FROM profile_www pw - WHERE pw.workos_organization_id = $1 - `, [TEST_ORG]); - - expect(candidates.rows[0].apex).toBe('bar.com'); - expect(candidates.rows[0].apex_in_org_domains).toBe(false); - // Phase would skip — no UPDATE asserted. - }); - - it('does not match profiles whose primary_brand_domain has no www prefix', async () => { - await seedDomains(pool, [ - { domain: 'baz.com', verified: true, is_primary: true }, - ]); - await seedProfile(pool, 'baz.com'); - - const candidates = await pool.query(` - SELECT 1 FROM member_profiles - WHERE workos_organization_id = $1 - AND LOWER(primary_brand_domain) LIKE 'www.%' - `, [TEST_ORG]); - expect(candidates.rowCount).toBe(0); - }); -}); - -describe('Stage 0 domain-cleanup: insert-missing-rows phase (SQL behavior)', () => { - let pool: Pool; - const OTHER_ORG = 'org_stage0_other_test'; - - beforeAll(async () => { - pool = initializeDatabase({ - connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', - }); - await runMigrations(); - }, 60000); - - afterAll(async () => { - await cleanup(pool); - await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [OTHER_ORG]); - await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [OTHER_ORG]); - await closeDatabase(); - }); - - beforeEach(async () => { - await cleanup(pool); - await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [OTHER_ORG]); - await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [OTHER_ORG]); - await seedOrg(pool); - }); - - // The candidate-discovery query is what determines what gets inserted vs - // surfaced as a collision. Assert both paths. - const CANDIDATE_QUERY = ` - SELECT - mp.workos_organization_id AS org_id, - mp.primary_brand_domain, - other_od.workos_organization_id AS other_org_owns - FROM member_profiles mp - LEFT JOIN organization_domains same_od - ON same_od.workos_organization_id = mp.workos_organization_id - AND LOWER(same_od.domain) = LOWER(mp.primary_brand_domain) - LEFT JOIN organization_domains other_od - ON LOWER(other_od.domain) = LOWER(mp.primary_brand_domain) - AND other_od.workos_organization_id != mp.workos_organization_id - WHERE mp.primary_brand_domain IS NOT NULL - AND same_od.domain IS NULL - AND mp.workos_organization_id = $1 - `; - - it('lists a profile as a candidate when primary_brand_domain has no matching org_domains row', async () => { - await seedProfile(pool, 'qux-stage0test.example'); - // No organization_domains rows for this org. - - const candidates = await pool.query(CANDIDATE_QUERY, [TEST_ORG]); - expect(candidates.rowCount).toBe(1); - expect(candidates.rows[0].primary_brand_domain).toBe('qux-stage0test.example'); - expect(candidates.rows[0].other_org_owns).toBeNull(); - }); - - it('flags a candidate as a collision when another org already owns the domain', async () => { - // Seed the OTHER org with the domain claimed. - await pool.query( - `INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at) - VALUES ($1, $2, false, NOW(), NOW()) ON CONFLICT DO NOTHING`, - [OTHER_ORG, 'Other Org'], - ); - await pool.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) - ON CONFLICT (domain) DO UPDATE SET workos_organization_id = $1`, - [OTHER_ORG, 'collision-stage0test.example'], - ); - - // TEST_ORG's profile claims the same domain. - await seedProfile(pool, 'collision-stage0test.example'); - - const candidates = await pool.query(CANDIDATE_QUERY, [TEST_ORG]); - expect(candidates.rowCount).toBe(1); - expect(candidates.rows[0].other_org_owns).toBe(OTHER_ORG); - }); - - it('does NOT list as candidate when this org already owns the domain (idempotent re-run)', async () => { - await seedProfile(pool, 'already-stage0test.example'); - await seedDomains(pool, [ - { domain: 'already-stage0test.example', verified: true, is_primary: true, source: 'manual' }, - ]); - - const candidates = await pool.query(CANDIDATE_QUERY, [TEST_ORG]); - expect(candidates.rowCount).toBe(0); - }); - - it('does NOT list as candidate when primary_brand_domain is NULL', async () => { - await seedProfile(pool, null); - const candidates = await pool.query(CANDIDATE_QUERY, [TEST_ORG]); - expect(candidates.rowCount).toBe(0); - }); - - it('demote-then-insert-then-rollback leaves the existing primary intact on a race loss', async () => { - // Pre-existing is_primary=true row on TEST_ORG. We're going to simulate - // the script wanting to insert a new primary, then losing the ON CONFLICT - // race. The existing primary must survive. - await seedDomains(pool, [ - { domain: 'existing-primary.example', verified: true, is_primary: true }, - ]); - await seedProfile(pool, 'wanted-primary.example'); - - // Seed the conflict on OTHER_ORG so our INSERT race-loses. - await pool.query( - `INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at) - VALUES ($1, $2, false, NOW(), NOW()) ON CONFLICT DO NOTHING`, - [OTHER_ORG, 'Other Org'], - ); - await pool.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) - ON CONFLICT (domain) DO UPDATE SET workos_organization_id = $1`, - [OTHER_ORG, 'wanted-primary.example'], - ); - - // Replicate the phase's transaction. Demote → INSERT race-loses → ROLLBACK. - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query( - 'SELECT 1 FROM organizations WHERE workos_organization_id = $1 FOR UPDATE', - [TEST_ORG], - ); - await client.query( - `UPDATE organization_domains SET is_primary = false WHERE workos_organization_id = $1 AND is_primary = true`, - [TEST_ORG], - ); - const ins = await client.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, LOWER($2), true, true, 'manual', NOW(), NOW()) - ON CONFLICT (domain) DO NOTHING - RETURNING domain`, - [TEST_ORG, 'wanted-primary.example'], - ); - expect(ins.rowCount).toBe(0); // race-lost - await client.query('ROLLBACK'); - } finally { - client.release(); - } - - // The pre-existing primary must still be marked is_primary=true. - const after = await pool.query<{ is_primary: boolean }>( - `SELECT is_primary FROM organization_domains WHERE workos_organization_id = $1 AND domain = $2`, - [TEST_ORG, 'existing-primary.example'], - ); - expect(after.rows[0].is_primary).toBe(true); - }); - - it('insert with ON CONFLICT (domain) DO NOTHING is a no-op when another org owns the row', async () => { - // Seed the conflict. - await pool.query( - `INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at) - VALUES ($1, $2, false, NOW(), NOW()) ON CONFLICT DO NOTHING`, - [OTHER_ORG, 'Other Org'], - ); - await pool.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) - ON CONFLICT (domain) DO UPDATE SET workos_organization_id = $1`, - [OTHER_ORG, 'racewinner-stage0test.example'], - ); - - // Try the insert that would fire from phase code. - const result = await pool.query( - `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) - VALUES ($1, $2, true, true, 'manual', NOW(), NOW()) - ON CONFLICT (domain) DO NOTHING - RETURNING domain`, - [TEST_ORG, 'racewinner-stage0test.example'], - ); - expect(result.rowCount).toBe(0); - - // OTHER org still owns it. - const owner = await pool.query<{ workos_organization_id: string }>( - `SELECT workos_organization_id FROM organization_domains WHERE LOWER(domain) = $1`, - ['racewinner-stage0test.example'], - ); - expect(owner.rows[0].workos_organization_id).toBe(OTHER_ORG); - }); -}); diff --git a/server/tests/integration/workos-domain-auto-primary.test.ts b/server/tests/integration/workos-domain-auto-primary.test.ts index e2d44a0d11..c6d348d82f 100644 --- a/server/tests/integration/workos-domain-auto-primary.test.ts +++ b/server/tests/integration/workos-domain-auto-primary.test.ts @@ -1,13 +1,14 @@ /** - * Integration tests for the WorkOS verified-domain → member_profiles - * auto-populate path. Verifies that when WorkOS marks a claimable domain - * verified, `member_profiles.primary_brand_domain` gets set when null, - * and is left alone when an existing brand-claim already pointed elsewhere. + * Integration tests for the WorkOS verified-domain → organization_domains + * auto-promote path. When WorkOS marks a non-personal-org domain verified + * and no other is_primary row exists, the row gets is_primary=true and + * `organizations.email_domain` is updated in the same transaction. * - * Driver: Media.net escalation (2026-05-06). Members with WorkOS-verified - * email domains were hitting the publish-agent gate that requires - * `primary_brand_domain`, even though their email domain was the obvious - * brand identity. Auto-populate closes that surprise. + * Driver: Media.net escalation (2026-05-06). Members with a single + * WorkOS-verified domain were missing brand-primary on a separate column, + * blocking publish-agent. After Stage 2 of #4159, organization_domains.is_primary + * is the single source of truth for both org-membership inference and + * brand identity, so the auto-promote here covers the publish-agent gate too. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; @@ -17,35 +18,23 @@ import { upsertOrganizationDomain } from '../../src/routes/workos-webhooks.js'; import type { Pool } from 'pg'; const TEST_ORG = 'org_wkos_brand_primary_test'; -const PROFILE_SLUG = 'wkos-brand-primary-test'; async function cleanup(pool: Pool) { await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [TEST_ORG]); - await pool.query('DELETE FROM member_profiles WHERE workos_organization_id = $1', [TEST_ORG]); await pool.query('DELETE FROM brands WHERE domain LIKE $1', ['wkos-brand-primary-%.test']); await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [TEST_ORG]); } -async function seedOrg(pool: Pool) { +async function seedOrg(pool: Pool, isPersonal: boolean = false) { await pool.query( `INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at) - VALUES ($1, $2, false, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO NOTHING`, - [TEST_ORG, 'Auto-Primary Test Co'], + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET is_personal = EXCLUDED.is_personal`, + [TEST_ORG, 'Auto-Primary Test Co', isPersonal], ); } -async function seedProfile(pool: Pool, primaryBrandDomain: string | null) { - await pool.query( - `INSERT INTO member_profiles (workos_organization_id, slug, display_name, primary_brand_domain, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW()) - ON CONFLICT (workos_organization_id) DO UPDATE - SET primary_brand_domain = EXCLUDED.primary_brand_domain, updated_at = NOW()`, - [TEST_ORG, PROFILE_SLUG, 'Auto-Primary Test Co', primaryBrandDomain], - ); -} - -describe('WorkOS verified-domain → member_profiles.primary_brand_domain', () => { +describe('WorkOS verified-domain → organization_domains.is_primary auto-promote', () => { let pool: Pool; beforeAll(async () => { @@ -65,9 +54,7 @@ describe('WorkOS verified-domain → member_profiles.primary_brand_domain', () = await seedOrg(pool); }); - it('auto-populates primary_brand_domain when null and the verified domain is claimable', async () => { - await seedProfile(pool, null); - + it('promotes a verified domain to is_primary when no other primary exists', async () => { await upsertOrganizationDomain({ id: 'od_test_1', organization_id: TEST_ORG, @@ -75,15 +62,26 @@ describe('WorkOS verified-domain → member_profiles.primary_brand_domain', () = state: 'verified', }); - const row = await pool.query( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, + const row = await pool.query<{ is_primary: boolean }>( + `SELECT is_primary FROM organization_domains + WHERE workos_organization_id = $1 AND domain = $2`, + [TEST_ORG, 'wkos-brand-primary-1.test'], + ); + expect(row.rows[0].is_primary).toBe(true); + + const org = await pool.query<{ email_domain: string | null }>( + `SELECT email_domain FROM organizations WHERE workos_organization_id = $1`, [TEST_ORG], ); - expect(row.rows[0].primary_brand_domain).toBe('wkos-brand-primary-1.test'); + expect(org.rows[0].email_domain).toBe('wkos-brand-primary-1.test'); }); - it('does NOT clobber an existing primary_brand_domain (intentional brand-claim wins)', async () => { - await seedProfile(pool, 'wkos-brand-primary-claimed.test'); + it('does NOT clobber an existing is_primary row', async () => { + await pool.query( + `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, 'wkos-brand-primary-claimed.test', true, true, 'workos', NOW(), NOW())`, + [TEST_ORG], + ); await upsertOrganizationDomain({ id: 'od_test_2', @@ -92,58 +90,49 @@ describe('WorkOS verified-domain → member_profiles.primary_brand_domain', () = state: 'verified', }); - const row = await pool.query( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, + const claimed = await pool.query<{ is_primary: boolean }>( + `SELECT is_primary FROM organization_domains + WHERE workos_organization_id = $1 AND domain = 'wkos-brand-primary-claimed.test'`, [TEST_ORG], ); - expect(row.rows[0].primary_brand_domain).toBe('wkos-brand-primary-claimed.test'); - }); + expect(claimed.rows[0].is_primary).toBe(true); - it('does not crash when no member_profile exists yet (UPDATE is a no-op)', async () => { - // No seedProfile — verifies the webhook tolerates the org-without-profile case. - await expect( - upsertOrganizationDomain({ - id: 'od_test_3', - organization_id: TEST_ORG, - domain: 'wkos-brand-primary-noprofile.test', - state: 'verified', - }), - ).resolves.not.toThrow(); + const incoming = await pool.query<{ is_primary: boolean }>( + `SELECT is_primary FROM organization_domains + WHERE workos_organization_id = $1 AND domain = 'wkos-brand-primary-different.test'`, + [TEST_ORG], + ); + expect(incoming.rows[0].is_primary).toBe(false); }); - it('does not auto-populate from a non-claimable domain (e.g. shared platform)', async () => { - await seedProfile(pool, null); - - // vercel.app is in SHARED_PLATFORM_DOMAINS — we should never let one - // org auto-claim a hosting platform domain as their brand identity. + it('does not promote when the domain is pending (not yet verified)', async () => { await upsertOrganizationDomain({ - id: 'od_test_4', + id: 'od_test_3', organization_id: TEST_ORG, - domain: 'vercel.app', - state: 'verified', + domain: 'wkos-brand-primary-pending.test', + state: 'pending', }); - const row = await pool.query( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, + const row = await pool.query<{ is_primary: boolean }>( + `SELECT is_primary FROM organization_domains + WHERE workos_organization_id = $1 AND domain = 'wkos-brand-primary-pending.test'`, [TEST_ORG], ); - expect(row.rows[0].primary_brand_domain).toBeNull(); - }); + expect(row.rows[0].is_primary).toBe(false); - it('still auto-populates for a personal org (brand identity ≠ org-membership inference)', async () => { - // Pin the explicit decision: a personal-tier user verifying a domain - // SHOULD get primary_brand_domain set on their profile. The - // squeeze-prevention concern (which gates is_primary on - // organization_domains for personal orgs) is about org-membership - // inference, not brand identity. An Individual Professional CAN own - // and verify a brand — that's the entire purpose of the tier. If - // someone later adds `if (isPersonal) return` to the auto-populate - // path, this test fails and they have to revisit the rationale. - await pool.query( - `UPDATE organizations SET is_personal = true WHERE workos_organization_id = $1`, + const org = await pool.query<{ email_domain: string | null }>( + `SELECT email_domain FROM organizations WHERE workos_organization_id = $1`, [TEST_ORG], ); - await seedProfile(pool, null); + expect(org.rows[0].email_domain).toBeNull(); + }); + + it('does NOT auto-promote for a personal org (squeeze prevention for org-membership inference)', async () => { + // Personal-tier individual subs shouldn't auto-claim their email + // domain as the org's primary, because that would inject every + // signup with that domain into the lone individual's org via + // membership inference. + await seedOrg(pool, true); await upsertOrganizationDomain({ id: 'od_test_personal', @@ -152,27 +141,17 @@ describe('WorkOS verified-domain → member_profiles.primary_brand_domain', () = state: 'verified', }); - const row = await pool.query( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, + const row = await pool.query<{ is_primary: boolean }>( + `SELECT is_primary FROM organization_domains + WHERE workos_organization_id = $1 AND domain = 'wkos-brand-primary-personal.test'`, [TEST_ORG], ); - expect(row.rows[0].primary_brand_domain).toBe('wkos-brand-primary-personal.test'); - }); - - it('does not auto-populate when the domain is pending (not yet verified)', async () => { - await seedProfile(pool, null); - - await upsertOrganizationDomain({ - id: 'od_test_5', - organization_id: TEST_ORG, - domain: 'wkos-brand-primary-pending.test', - state: 'pending', - }); + expect(row.rows[0].is_primary).toBe(false); - const row = await pool.query( - `SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1`, + const org = await pool.query<{ email_domain: string | null }>( + `SELECT email_domain FROM organizations WHERE workos_organization_id = $1`, [TEST_ORG], ); - expect(row.rows[0].primary_brand_domain).toBeNull(); + expect(org.rows[0].email_domain).toBeNull(); }); }); diff --git a/server/tests/unit/member-profile-bootstrap-skip-rule.test.ts b/server/tests/unit/member-profile-bootstrap-skip-rule.test.ts index 1537b8ac93..c632fb84ea 100644 --- a/server/tests/unit/member-profile-bootstrap-skip-rule.test.ts +++ b/server/tests/unit/member-profile-bootstrap-skip-rule.test.ts @@ -28,7 +28,6 @@ describe('isMemberProfileBootstrapBody', () => { company_type: 'publisher', revenue_tier: '5m_50m', corporate_domain: 'acme.example', - primary_brand_domain: 'acme.example', marketing_opt_in: true, membership_tier: 'individual_academic', }), diff --git a/tests/billing/listing-autopublish.test.ts b/tests/billing/listing-autopublish.test.ts index 1a4ad69081..4b93b4f0fe 100644 --- a/tests/billing/listing-autopublish.test.ts +++ b/tests/billing/listing-autopublish.test.ts @@ -59,7 +59,7 @@ describe('ensureMemberProfilePublished — real behavior against a mocked pg bou workos_organization_id: params[0], display_name: params[1], slug: params[2], - is_public: params[19], + is_public: params[18], }], }; } @@ -82,7 +82,7 @@ describe('ensureMemberProfilePublished — real behavior against a mocked pg bou const insert = calls.find(c => c.op === 'insert'); expect(insert).toBeDefined(); expect(insert!.params[2]).toBe('acme-corp'); - expect(insert!.params[19]).toBe(true); // is_public + expect(insert!.params[18]).toBe(true); // is_public const activity = calls.find(c => c.op === 'activity'); expect(activity!.params[0]).toBe('org_abc');