Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/4159-stage2-drop-primary-brand-domain.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion server/src/addie/jobs/announcement-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
2 changes: 1 addition & 1 deletion server/src/addie/mcp/brand-property-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 12 additions & 6 deletions server/src/db/member-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,18 @@ export class MemberDatabase {
const result = await query<MemberProfile>(
`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,
input.display_name,
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,
Expand Down Expand Up @@ -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<MemberProfile | null> {
const result = await query<MemberProfile>(
'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]
);

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 8 additions & 1 deletion server/src/db/organization-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 4 additions & 14 deletions server/src/routes/brand-feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand All @@ -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 };
}
Expand Down
83 changes: 30 additions & 53 deletions server/src/routes/me-organization-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
});
Expand All @@ -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);
Expand Down Expand Up @@ -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`,
Expand All @@ -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.
Expand All @@ -209,53 +213,26 @@ 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(
{
orgId,
domain: normalizedDomain,
actor: req.user!.id,
via_dev_bypass: membership.via_dev_bypass,
brand_primary_updated: brandPrimaryUpdated,
},
'Set primary domain via member self-service',
);

return res.json({
success: true,
primary_domain: normalizedDomain,
brand_primary_updated: brandPrimaryUpdated,
});
} catch (err) {
await client.query('ROLLBACK').catch((rbErr) => {
Expand Down
Loading
Loading