diff --git a/.changeset/stage1-3-dashboard-feeds-resolver.md b/.changeset/stage1-3-dashboard-feeds-resolver.md
new file mode 100644
index 0000000000..96a0b212b5
--- /dev/null
+++ b/.changeset/stage1-3-dashboard-feeds-resolver.md
@@ -0,0 +1,4 @@
+---
+---
+
+Stage 1.3 of #4159: migrate the public member-list endpoints (`/api/members`, `/api/members/carousel`, `/api/members/:slug` in `server/src/http.ts`) and the brand-feeds ownership check (`server/src/routes/brand-feeds.ts`) from direct `primary_brand_domain` reads to `getBrandPrimaryDomain[sForOrgs]`. List endpoints exercise the batched variant introduced in PR #4299. No behavioral change post-Stage-0; brand-primary now resolves from `organization_domains.is_primary` (canonical) with `member_profiles` fallback.
diff --git a/server/src/http.ts b/server/src/http.ts
index f2338dc195..70fa8f6421 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -37,6 +37,7 @@ import Stripe from "stripe";
import { OrganizationDatabase, getUserSeatType, buildSubscriptionUpdate, TIER_PRESERVING_STATUSES, type SeatType, type MembershipTier } from "./db/organization-db.js";
import { MemberDatabase } from "./db/member-db.js";
import { ensureMemberProfilePublished } from "./services/member-profile-autopublish.js";
+import { getBrandPrimaryDomain, getBrandPrimaryDomainsForOrgs } from "./services/brand-domain-resolver.js";
import { getGitHubConnectedAccount, getGitHubAuthorizeUrl, disconnectGitHub, buildPipesReturnTo } from "./services/pipes.js";
import { BrandDatabase, resolveBrandFromJson } from "./db/brand-db.js";
import { CatalogEventsDatabase } from "./db/catalog-events-db.js";
@@ -8309,11 +8310,13 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
offset: offset ? parseInt(offset as string, 10) : 0,
});
- // Batch-fetch all brand data and credentials in two queries instead of N+1
- const brandDomains = profiles
- .map(p => p.primary_brand_domain)
- .filter((d): d is string => !!d);
+ // Batch-fetch brand data and credentials in a constant number of queries
+ // (resolver + brandsMap + credentialsMap) instead of N+1. Brand-primary
+ // domains come from the Stage 1 resolver (org_domains.is_primary first,
+ // member_profiles fallback), keyed by org_id rather than a per-row column.
const orgIds = profiles.map(p => p.workos_organization_id);
+ const brandPrimaryByOrg = await getBrandPrimaryDomainsForOrgs(orgIds);
+ const brandDomains = Array.from(brandPrimaryByOrg.values());
const [brandsMap, credentialsMap] = await Promise.all([
this.brandDb.getDiscoveredBrandsByDomains(brandDomains),
@@ -8323,11 +8326,12 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
]);
for (const profile of profiles) {
- if (profile.primary_brand_domain) {
- const brand = brandsMap.get(profile.primary_brand_domain.toLowerCase());
+ const brandPrimaryDomain = brandPrimaryByOrg.get(profile.workos_organization_id);
+ if (brandPrimaryDomain) {
+ const brand = brandsMap.get(brandPrimaryDomain.toLowerCase());
if (brand?.brand_manifest) {
profile.resolved_brand = resolveBrandFromJson(
- profile.primary_brand_domain,
+ brandPrimaryDomain,
brand.brand_manifest as Record,
brand.domain_verified ?? false
);
@@ -8353,19 +8357,22 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
try {
const profiles = await memberDb.getCarouselProfiles();
- // Batch-fetch all brand data in a single query to avoid pool exhaustion
+ // Batch-fetch all brand data in two queries to avoid pool exhaustion.
+ // Brand-primary domains come from the Stage 1 resolver (org_domains.is_primary
+ // first, member_profiles fallback), keyed by org_id.
// codeql[js/user-controlled-bypass] - brand domains come from server-side DB, not user input
- const brandDomains = profiles
- .map(p => p.primary_brand_domain)
- .filter((d): d is string => !!d);
+ const orgIds = profiles.map(p => p.workos_organization_id);
+ const brandPrimaryByOrg = await getBrandPrimaryDomainsForOrgs(orgIds);
+ const brandDomains = Array.from(brandPrimaryByOrg.values());
const brandsMap = await this.brandDb.getDiscoveredBrandsByDomains(brandDomains);
for (const profile of profiles) {
- if (profile.primary_brand_domain) {
- const brand = brandsMap.get(profile.primary_brand_domain.toLowerCase());
+ const brandPrimaryDomain = brandPrimaryByOrg.get(profile.workos_organization_id);
+ if (brandPrimaryDomain) {
+ const brand = brandsMap.get(brandPrimaryDomain.toLowerCase());
if (brand?.brand_manifest) {
profile.resolved_brand = resolveBrandFromJson(
- profile.primary_brand_domain,
+ brandPrimaryDomain,
brand.brand_manifest as Record,
brand.domain_verified ?? false
);
@@ -8471,11 +8478,12 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
// Resolve brand data from registry if linked. Skip orphaned brands —
// the manifest is preserved server-side for adoption-at-claim-time
// but must not surface on the public member-profile endpoint.
- if (profile.primary_brand_domain) {
- const brand = await this.brandDb.getDiscoveredBrandByDomain(profile.primary_brand_domain);
+ const brandPrimaryDomain = await getBrandPrimaryDomain(profile.workos_organization_id);
+ if (brandPrimaryDomain) {
+ const brand = await this.brandDb.getDiscoveredBrandByDomain(brandPrimaryDomain);
if (brand?.brand_manifest && !brand.manifest_orphaned) {
profile.resolved_brand = resolveBrandFromJson(
- profile.primary_brand_domain,
+ brandPrimaryDomain,
brand.brand_manifest as Record,
brand.domain_verified ?? false
);
diff --git a/server/src/routes/brand-feeds.ts b/server/src/routes/brand-feeds.ts
index 6c4f9bc4b2..bc46258ed3 100644
--- a/server/src/routes/brand-feeds.ts
+++ b/server/src/routes/brand-feeds.ts
@@ -20,6 +20,7 @@ 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'];
@@ -47,17 +48,19 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
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',
[orgId]
);
- const memberProfile = await query<{ primary_brand_domain: string | null }>(
- 'SELECT primary_brand_domain FROM member_profiles WHERE workos_organization_id = $1',
- [orgId]
- );
+ const brandPrimary = await getBrandPrimaryDomain(orgId);
const ownedDomains = new Set([
...orgDomains.rows.map(r => r.domain.toLowerCase()),
- ...(memberProfile.rows[0]?.primary_brand_domain ? [memberProfile.rows[0].primary_brand_domain.toLowerCase()] : []),
+ ...(brandPrimary ? [brandPrimary.toLowerCase()] : []),
]);
if (!ownedDomains.has(domain.toLowerCase())) {
return { error: 'You do not own this brand domain', status: 403 };