diff --git a/.changeset/operator-member-tier-public-card.md b/.changeset/operator-member-tier-public-card.md new file mode 100644 index 0000000000..2e98e24871 --- /dev/null +++ b/.changeset/operator-member-tier-public-card.md @@ -0,0 +1,26 @@ +--- +--- + +`/api/registry/operator?domain=X` now surfaces the queried member's public +level info when the profile owner has opted their member card into public +visibility (`is_public=true`). The `member` object grows three optional +fields: + +- `is_founding_member` (boolean) — present whenever the profile is public + (true or false; absent for private profiles) +- `membership_tier` (raw enum, e.g. `company_icl`, `company_leader`) — + present only when the org also has a resolvable tier +- `membership_tier_label` (human-readable, e.g. `Partner`, `Leader`) — + matches the AAO pricing page; presence mirrors `membership_tier` + +Private profiles still return only `{ slug, display_name }`. Tier and +founding-member status reflect billing/cohort state and follow the existing +profile-card visibility toggle rather than introducing a second one. Fields +are absent (not `null`) when not applicable, so existing consumers see no +shape change. + +Also fixes the `company_icl` label in `tierLabel()` from `Member` to +`Partner` so it matches the public pricing page and the dashboard (which +already used `Partner`). Founding Member is orthogonal to tier — founding +orgs typically display both badges (e.g. Scope3 shows `Partner` + +`Founding Member`). diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index b83cd5b406..c5c84cb1ea 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -31,7 +31,7 @@ import { import { getPublicJwks } from "../services/verification-token.js"; import { renderBadgeSvg, VALID_BADGE_ROLES } from "../services/badge-svg.js"; import { runBadgeFanOut } from "../services/badge-issuance.js"; -import { resolveOwnerMembership } from "../services/membership-tiers.js"; +import { resolveOwnerMembership, tierLabel } from "../services/membership-tiers.js"; import { inferDiagnosticAgentType } from "../lib/diagnostic-agent-type-inference.js"; import { isValidAdcpVersionShape } from "../services/adcp-taxonomy.js"; import { buildAaoVerificationBlock } from "../services/aao-verification-enrichment.js"; @@ -755,7 +755,12 @@ registry.registerPath({ "**Response shape is auth-aware.** Anonymous callers see only `public` agents. " + "Authenticated callers on an AAO membership tier with API access also see `members_only` agents. " + "Profile owners (callers whose org owns the queried domain) additionally see `private` agents. " + - "This is the primary mechanism by which AAO membership unlocks deeper registry visibility.", + "This is the primary mechanism by which AAO membership unlocks deeper registry visibility.\n\n" + + "**Member level visibility.** When the profile owner has set their member card to public " + + "(`is_public=true`), the `member` object additionally carries `is_founding_member` (boolean) " + + "plus `membership_tier` (raw enum) and `membership_tier_label` (e.g. `Professional`, `Partner`, " + + "`Leader`) when the org has a resolvable tier. Founding Member is orthogonal to tier — founding " + + "orgs typically display both. For private profiles these fields are absent.", tags: ["Authorization Lookups"], request: { query: z.object({ @@ -6042,8 +6047,35 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { const federatedIndex = crawler.getFederatedIndex(); const profile = await memberDb.getProfileByDomain(domain); + + // Membership tier and Founding Member status are surfaced on the + // public response only when the profile owner has opted their member + // card into public visibility (`is_public=true`). Tier reflects billing + // state, so we don't leak it for private profiles even though + // slug/display_name are exposed for domain-keyed lookup. Profile owner + // controls visibility via the member card; we follow it here rather + // than introducing a second toggle. Founding Member is orthogonal to + // tier — founding orgs typically display both badges. + let memberTier: string | null = null; + if (profile?.is_public && profile.workos_organization_id) { + const profileOrg = await orgDb.getOrganization(profile.workos_organization_id); + memberTier = resolveMembershipTier(profileOrg); + } + const member = profile - ? { slug: profile.slug, display_name: profile.display_name } + ? { + slug: profile.slug, + display_name: profile.display_name, + ...(profile.is_public + ? { is_founding_member: profile.is_founding_member === true } + : {}), + ...(memberTier + ? { + membership_tier: memberTier, + membership_tier_label: tierLabel(memberTier), + } + : {}), + } : null; const callerOrgId = await resolveCallerOrgId(req); diff --git a/server/src/schemas/registry.ts b/server/src/schemas/registry.ts index e243a98683..f4eeea2f0d 100644 --- a/server/src/schemas/registry.ts +++ b/server/src/schemas/registry.ts @@ -182,6 +182,18 @@ export const PropertySummarySchema = z const MemberRefSchema = z.object({ slug: z.string().optional(), display_name: z.string().optional(), + membership_tier: z.string().optional().openapi({ + description: + "Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription.", + }), + membership_tier_label: z.string().optional().openapi({ + description: + "Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`.", + }), + is_founding_member: z.boolean().optional().openapi({ + description: + "True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`).", + }), }); export const ResolvedBrandSchema = z diff --git a/server/src/services/membership-tiers.ts b/server/src/services/membership-tiers.ts index 3c4595deee..eace07faec 100644 --- a/server/src/services/membership-tiers.ts +++ b/server/src/services/membership-tiers.ts @@ -55,7 +55,7 @@ const TIER_LABELS: Record = { explorer: 'Explorer', individual_professional: 'Professional', company_standard: 'Builder', - company_icl: 'Member', + company_icl: 'Partner', company_leader: 'Leader', }; diff --git a/server/tests/unit/membership-tiers.test.ts b/server/tests/unit/membership-tiers.test.ts index e31b2202e7..218d633048 100644 --- a/server/tests/unit/membership-tiers.test.ts +++ b/server/tests/unit/membership-tiers.test.ts @@ -72,7 +72,7 @@ describe('membership-tiers', () => { expect(tierLabel('explorer')).toBe('Explorer'); expect(tierLabel('individual_professional')).toBe('Professional'); expect(tierLabel('company_standard')).toBe('Builder'); - expect(tierLabel('company_icl')).toBe('Member'); + expect(tierLabel('company_icl')).toBe('Partner'); expect(tierLabel('company_leader')).toBe('Leader'); }); diff --git a/server/tests/unit/operator-publisher-lookup.test.ts b/server/tests/unit/operator-publisher-lookup.test.ts index 5b51add719..b6e0921402 100644 --- a/server/tests/unit/operator-publisher-lookup.test.ts +++ b/server/tests/unit/operator-publisher-lookup.test.ts @@ -37,6 +37,81 @@ describe('OperatorLookupResult schema', () => { } }); + it('validates a public founding-member profile with tier (Scope3 shape)', () => { + const data = { + domain: 'scope3.com', + member: { + slug: 'scope3', + display_name: 'Scope3', + is_founding_member: true, + membership_tier: 'company_icl', + membership_tier_label: 'Partner', + }, + agents: [], + }; + const result = OperatorLookupResultSchema.safeParse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.member?.is_founding_member).toBe(true); + expect(result.data.member?.membership_tier).toBe('company_icl'); + expect(result.data.member?.membership_tier_label).toBe('Partner'); + } + }); + + it('validates a public non-founding profile with tier', () => { + const data = { + domain: 'example.com', + member: { + slug: 'example', + display_name: 'Example Co', + is_founding_member: false, + membership_tier: 'company_leader', + membership_tier_label: 'Leader', + }, + agents: [], + }; + const result = OperatorLookupResultSchema.safeParse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.member?.is_founding_member).toBe(false); + expect(result.data.member?.membership_tier_label).toBe('Leader'); + } + }); + + it('validates a public profile without a resolvable tier (founding flag still present)', () => { + const data = { + domain: 'newco.example', + member: { + slug: 'newco', + display_name: 'NewCo', + is_founding_member: false, + }, + agents: [], + }; + const result = OperatorLookupResultSchema.safeParse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.member?.is_founding_member).toBe(false); + expect(result.data.member?.membership_tier).toBeUndefined(); + expect(result.data.member?.membership_tier_label).toBeUndefined(); + } + }); + + it('validates a private profile with no tier or founding fields', () => { + const data = { + domain: 'private.example', + member: { slug: 'private', display_name: 'Private Co' }, + agents: [], + }; + const result = OperatorLookupResultSchema.safeParse(data); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.member?.is_founding_member).toBeUndefined(); + expect(result.data.member?.membership_tier).toBeUndefined(); + expect(result.data.member?.membership_tier_label).toBeUndefined(); + } + }); + it('validates an unfound operator', () => { const data = { domain: 'unknown.com', diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index 548dbc167e..36fefe73f6 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -387,6 +387,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). description: AAO member that owns this agent record. The registry contains only agents that members have explicitly enrolled on their member profile. health: $ref: "#/components/schemas/AgentHealth" @@ -652,6 +661,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). agent_count: type: integer last_validated: @@ -682,6 +700,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). required: - url sales_agents_claiming: @@ -698,6 +725,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). required: - url required: @@ -719,6 +755,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). agents: type: array items: @@ -781,6 +826,15 @@ components: type: string display_name: type: string + membership_tier: + type: string + description: Raw AAO membership tier enum (e.g. `individual_professional`, `company_leader`). Present only when the profile owner has set their member card to public (`is_public=true`) AND the org has a resolvable tier. Absent for private profiles and for orgs without an active tier-bearing subscription. + membership_tier_label: + type: string + description: Human-readable label for `membership_tier` (e.g. `Professional`, `Partner`, `Leader`). Matches the AAO pricing page. Use this for UI display; the raw enum is for programmatic gating. Presence rules match `membership_tier`. + is_founding_member: + type: boolean + description: True when the profile owner carries the Founding Member badge (joined before the founding-cohort cutoff). Surfaced when the profile owner has set their member card to public (`is_public=true`). Absent for private profiles. Founding Member is orthogonal to tier — founding orgs typically display both (e.g. Scope3 shows `Partner` + `Founding Member`). adagents_valid: type: - boolean @@ -3194,6 +3248,27 @@ paths: description: "Measurement-vendor filter: case-insensitive substring match against `measurement.metrics[].metric_id`. v1 scope: metric_id only (description/standard search is a follow-up). Max 64 chars; SQL wildcard characters are escaped. Implies `type=measurement`." name: q in: query + - schema: + type: array + items: + type: string + enum: + - spec + - live + description: "Filter to agents whose active badge covers the given verification axis. Repeat the parameter for AND semantics: ?verification_mode=spec&verification_mode=live returns only agents verified on both axes." + required: false + description: "Filter to agents whose active badge covers the given verification axis. Repeat the parameter for AND semantics: ?verification_mode=spec&verification_mode=live returns only agents verified on both axes." + name: verification_mode + in: query + - schema: + type: string + enum: + - "true" + description: When true, filter to agents that hold any active verification badge. + required: false + description: When true, filter to agents that hold any active verification badge. + name: verified + in: query responses: "200": description: Agent list @@ -3216,7 +3291,16 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Error" + type: object + properties: + error: + type: string + valid_values: + type: array + items: + type: string + required: + - error /api/registry/publishers: get: operationId: listPublishers @@ -3360,6 +3444,8 @@ paths: Given a domain, returns the agents this entity operates and which publishers trust them. **Response shape is auth-aware.** Anonymous callers see only `public` agents. Authenticated callers on an AAO membership tier with API access also see `members_only` agents. Profile owners (callers whose org owns the queried domain) additionally see `private` agents. This is the primary mechanism by which AAO membership unlocks deeper registry visibility. + + **Member level visibility.** When the profile owner has set their member card to public (`is_public=true`), the `member` object additionally carries `is_founding_member` (boolean) plus `membership_tier` (raw enum) and `membership_tier_label` (e.g. `Professional`, `Partner`, `Leader`) when the org has a resolvable tier. Founding Member is orthogonal to tier — founding orgs typically display both. For private profiles these fields are absent. tags: - Authorization Lookups parameters: