feat(member-profile): self-service Linked Domains card (Stage 2 of #321)#4179
Merged
Conversation
GET /api/me/organization/domains lists verified org domains; PUT /api/me/organization/domains/:domain/primary lets owners/admins switch the primary. The PUT writes BOTH organization_domains.is_primary AND member_profiles.primary_brand_domain in one transaction so members don't have to know about the two-primary distinction (Media.net escalation #321 root cause). Adds a Linked Domains card to the member-profile UI for company orgs, mirroring what admins see in admin-account-detail.html. Closes #4158 (Stage 2 of the domain-cleanup series). POST (add domain → WorkOS verification challenge) and DELETE deferred to a follow-up since they require WorkOS-side wiring that's materially more work than the read+set paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s card E2E caught: section never rendered because the conditional always evaluated false (the /api/me/member-profile response does not include is_personal). The empty-state path in loadLinkedDomains already handles personal orgs (zero verified domains keeps the section hidden), so the gate was unnecessary anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restrict brand-primary dual-write to source='workos' rows only — admin-imported / manual 'verified' rows aren't DNS-proven and shouldn't seed brand identity. Org-membership inference primary still flips for any verified domain. Matches the trust boundary the backfill-primary-brand-domain script applies. (security-reviewer) - Add SELECT 1 ... FOR UPDATE on the organizations row to serialize against concurrent WorkOS upsertOrganizationDomain webhooks that take the same lock. (code-reviewer) - Canonicalize input :domain via canonicalizeBrandDomain instead of lowercase+trim — rejects malformed shapes early and matches the GET path's claimable check. (code-reviewer) - Length-cap input domain at 253 chars (RFC 1035). (code-reviewer) - Log ROLLBACK failure instead of silently swallowing. (code-reviewer) - UI: keep the section hidden when 0 domains rather than rendering an empty-state copy that's confusing for personal orgs. (code-reviewer) - Test cleanup: drop redundant inline auth middleware in buildApp; the vi.mock of requireAuth is what makes auth bypass work. - Test coverage: add a case asserting source='import' rows can flip the org-membership primary but not the brand-identity primary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(semgrep) Caught by CI semgrep rule error-message-leak. Log the underlying error server-side, return only the error code to the caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/api/me/organization/domains: GET (list) and PUT/:domain/primary(set primary). Member self-service mirror of the admin "Linked Domains" card.organization_domains.is_primaryANDmember_profiles.primary_brand_domainin one transaction. This is the bit the admin set-primary path doesn't do — and the cause of the Media.net escalation Hide billing button until user subscribes, move membership to top #321.member-profile.htmlfor company orgs.Closes #4158 (Stage 2 of the domain-cleanup series).
Why this PR's PUT is dual-write
In the admin Set-Primary path (
admin-account-detail.html→PUT /api/admin/organizations/:orgId/domains/:domain/primary), onlyorganization_domains.is_primaryandorganizations.email_domainget written.member_profiles.primary_brand_domainis a separate column with a separate write path (the brand-claim verification flow). That's the two-primary surprise documented in #4159.For a member self-service surface, we don't want to inflict that distinction on the user. So PUT writes both — when the domain is claimable (
assertClaimableBrandDomain),primary_brand_domainfollows. Existing brand-claim values are still preserved by the auto-populate path'sIS NULLguard at the webhook (PR #4157); this PR only writes through an explicit member action.Out of scope
Test plan
organization_domainsANDmember_profiles.primary_brand_domain(asserts the dual-write)🤖 Generated with Claude Code