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
11 changes: 11 additions & 0 deletions .changeset/fix-visibility-tier-gate-blockers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
---

Close the three-tier agent visibility blockers flagged in the PR #2793 review that landed before the review was addressed.

- **POST /api/me/member-profile (create) now runs the same tier gate as PUT.** A fresh profile containing `visibility: 'public'` on an Explorer-tier org was accepted verbatim, then filtered-through strictly without a tier re-check. Extracted the coercion into a shared helper (`agent-visibility-gate`), wired into both paths, now returns structured `warnings[]` on the create response too.
- **MCP `addAgent` now defaults to `members_only`** instead of `public`. Callers without an API-access tier could implicitly publish an agent via Addie, bypassing the explicit `/publish` tier check.
- **`applyAgentVisibility` re-reads the membership tier inside its transaction.** The outer `requireApiAccessTier` check is a fast-fail only; a concurrent Stripe downgrade committing between the outer read and the profile UPDATE would otherwise slip a `public` write past the gate.
- **`demotePublicAgentsOnTierDowngrade` now wraps the profile lock + update in a single transaction** with `SELECT ... FOR UPDATE`. Previously it read and wrote the profile as two statements, so a concurrent PATCH could reinsert a `public` entry between them. The two transactional guarantees together (apply + demote) mean a tier-gated publish and a tier downgrade can't interleave past each other's lock.

New unit tests cover the shared gate helper and the transactional demote path; existing enforcement tests were rewritten to exercise the new pg client contract. Full unit suite green (1811 pass).
28 changes: 25 additions & 3 deletions server/public/member-profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -2242,15 +2242,37 @@ <h2>Tags</h2>
const wasNewProfile = isNewProfile;
isNewProfile = false;

// Server may have coerced visibility settings the caller isn't
// eligible for (e.g. public → members_only on a non-API-access
// tier). Surface those warnings so the user knows we didn't
// silently persist what they asked for.
const warnings = Array.isArray(data.warnings) ? data.warnings : [];
const downgrades = warnings.filter(w => w && w.code === 'visibility_downgraded');

// If this was a new profile creation, redirect to dashboard with success message
if (wasNewProfile) {
const orgParam = currentOrgId ? `&org=${currentOrgId}` : '';
window.location.href = `/dashboard?profile_created=true${orgParam}`;
const warnParam = downgrades.length ? `&visibility_downgraded=${downgrades.length}` : '';
window.location.href = `/dashboard?profile_created=true${orgParam}${warnParam}`;
return;
}

// For updates, show success message and stay on page
showSuccess('Profile saved successfully!');
// For updates, show success message and stay on page.
// If visibility entries got downgraded, pin the reason above the
// generic banner so the user sees the upgrade CTA.
if (downgrades.length > 0) {
const agents = downgrades.map(w => w.agent_url).filter(Boolean);
const detail = agents.length
? ` Affected agents: ${agents.join(', ')}.`
: '';
showError(
`Saved, but ${downgrades.length} agent${downgrades.length > 1 ? 's' : ''} could not be listed publicly — ` +
`public listing requires Professional tier or higher, so they were stored as members_only instead.${detail} ` +
`Upgrade at /membership to publish publicly.`
);
} else {
showSuccess('Profile saved successfully!');
}
saveBtn.disabled = false;
saveBtn.textContent = 'Save Profile';

Expand Down
13 changes: 10 additions & 3 deletions server/src/addie/mcp/member-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
{
name: 'save_agent',
description:
'Save an agent URL to the organization\'s context and add it to the dashboard for compliance monitoring. Optionally store credentials securely (encrypted, never shown in conversations). Three auth modes, any of which may be combined with a new or existing save: (1) static bearer/basic via `auth_token`, (2) OAuth 2.0 client credentials (RFC 6749 §4.4, machine-to-machine) via `oauth_client_credentials`. Use this when users want to connect their agent, set up compliance monitoring, save their agent for testing, or provide credentials.',
'Save an agent URL to the organization\'s context and add it to the dashboard for compliance monitoring. New agents land in the dashboard with `members_only` visibility — discoverable to fellow Professional-tier (or higher) members, but not publicly listed in the directory or brand.json. To list publicly, the caller promotes the agent via the dashboard publish flow; that flow gates on an API-access subscription tier. Optionally store credentials securely (encrypted, never shown in conversations). Three auth modes, any of which may be combined with a new or existing save: (1) static bearer/basic via `auth_token`, (2) OAuth 2.0 client credentials (RFC 6749 §4.4, machine-to-machine) via `oauth_client_credentials`. Use this when users want to connect their agent, set up compliance monitoring, save their agent for testing, or provide credentials.',
usage_hints: 'use for "connect my agent", "add agent for compliance monitoring", "save my agent", "remember this agent URL", "store my auth token", "configure client credentials", "save OAuth client credentials"',
input_schema: {
type: 'object',
Expand Down Expand Up @@ -4903,7 +4903,14 @@ export function createMemberToolHandlers(
if (profile) {
const agents = profile.agents || [];
if (!agents.some((a: any) => a.url === agentUrl)) {
agents.push({ url: agentUrl, name: displayName, visibility: 'public' });
// Default to members_only, not public. The public directory
// requires an API-access tier (Professional+); defaulting to
// 'public' here lets Addie implicitly publish an agent for an
// Explorer-tier caller who hasn't been tier-gated. Members_only
// keeps the agent discoverable to peer members with API access
// and lets the owner promote to public through the explicit,
// tier-checked /publish route when eligible.
agents.push({ url: agentUrl, name: displayName, visibility: 'members_only' });
await memberDb.updateProfile(profile.id, { agents });
}
}
Expand Down Expand Up @@ -4977,7 +4984,7 @@ export function createMemberToolHandlers(
response += `\n🔐 OAuth client-credentials saved securely for token endpoint ${clientCredentials.token_endpoint}\n`;
response += `_The client secret is encrypted and will never be shown again. The SDK exchanges and refreshes at test time._\n`;
}
response += `\nThe agent has been added to your dashboard. When you test this agent, I'll automatically use the saved credentials.`;
response += `\nThe agent has been added to your dashboard with **members_only** visibility — other Professional-tier members can discover it, but it won't appear in the public directory. To publish publicly, use the dashboard publish flow (requires a Professional or higher subscription). When you test this agent, I'll automatically use the saved credentials.`;

return response;
} catch (error) {
Expand Down
135 changes: 100 additions & 35 deletions server/src/routes/member-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { MemberBrandInfo, AgentVisibility, AgentConfig } from "../types.js"
import type { CrawlerService } from "../crawler.js";
import { validateCrawlDomain } from "../utils/url-security.js";
import { recordProfilePublishedIfNeeded } from "../services/profile-publish-event.js";
import { gateAgentVisibilityForCaller, type VisibilityWarning } from "../services/agent-visibility-gate.js";

const orgKnowledgeDb = new OrgKnowledgeDatabase();

Expand Down Expand Up @@ -310,6 +311,36 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
}

// Gate agent visibility on create using the same helper the PUT
// path uses. Without this, an Explorer-tier user creating their
// first profile can land `visibility: 'public'` directly in the
// JSONB — subsequent readers filter strictly on `=== 'public'`
// with no tier re-check, so the entry stays public forever.
const createOrgForTier = await orgDb.getOrganization(targetOrgId);
const createCallerHasApi = hasApiAccess(resolveMembershipTier(createOrgForTier));
const { agents: gatedAgents, warnings: createWarnings } =
gateAgentVisibilityForCaller(agents, createCallerHasApi);

// Same tier gate for the profile-level `is_public` flag. The
// `/visibility` PUT route gates this through hasActiveSubscription
// (line 1343), but the POST create and PUT bulk-update paths
// previously accepted the raw body value — same smuggle class as
// the agent-visibility bug this PR fixes.
let effectiveIsPublic = is_public === true;
if (effectiveIsPublic && !isDevModeEnabled()) {
if (!(await orgDb.hasActiveSubscription(targetOrgId))) {
effectiveIsPublic = false;
createWarnings.push({
code: 'visibility_downgraded',
agent_url: 'profile',
requested: 'public',
applied: 'members_only',
reason: 'tier_required',
message: 'Making the profile publicly visible requires an active paid membership; stored as private instead.',
});
}
}

const profile = await memberDb.createProfile({
workos_organization_id: targetOrgId,
display_name,
Expand All @@ -323,11 +354,11 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
linkedin_url,
twitter_url,
offerings: offerings || [],
agents: agents || [],
agents: gatedAgents,
headquarters,
markets: markets || [],
tags: tags || [],
is_public: is_public ?? false,
is_public: effectiveIsPublic,
show_in_carousel: show_in_carousel ?? false,
});

Expand Down Expand Up @@ -385,7 +416,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro

logger.info({ profileId: profile.id, orgId: targetOrgId, slug, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile completed');

res.status(201).json({ profile });
res.status(201).json({
profile,
...(createWarnings.length ? { warnings: createWarnings } : {}),
});
} catch (error) {
logger.error({ err: error, durationMs: Date.now() - startTime }, 'POST /api/me/member-profile error');
res.status(500).json({
Expand Down Expand Up @@ -496,41 +530,33 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// Enforce the tier gate on agent visibility so bulk-profile updates
// cannot bypass the per-agent PATCH. Non-API-access callers may only
// set 'private' or 'members_only' on any agent in the array; when
// they send 'public' we downgrade and tell them we did.
const warnings: Array<Record<string, unknown>> = [];
// they send 'public' we downgrade and tell them we did. Shared with
// the POST create path via gateAgentVisibilityForCaller.
let warnings: VisibilityWarning[] = [];
if (Array.isArray(updates.agents)) {
const localOrgForTier = await orgDb.getOrganization(targetOrgId);
const callerHasApi = hasApiAccess(resolveMembershipTier(localOrgForTier));
updates.agents = updates.agents.map((raw: unknown) => {
const a = (raw ?? {}) as Record<string, unknown>;
const requested = a.visibility;
let visibility: AgentVisibility;
if (isValidAgentVisibility(requested)) {
visibility = requested;
} else if (a.is_public === true) {
visibility = 'public';
} else {
visibility = 'private';
}
if (visibility === 'public' && !callerHasApi) {
warnings.push({
code: 'visibility_downgraded',
agent_url: a.url,
requested: 'public',
applied: 'members_only',
reason: 'tier_required',
message: 'Publicly listing an agent requires Professional tier or higher; stored as members_only instead.',
});
visibility = 'members_only';
}
const cleaned: Record<string, unknown> = {
url: a.url,
visibility,
};
if (typeof a.name === 'string') cleaned.name = a.name;
if (typeof a.type === 'string') cleaned.type = a.type;
return cleaned;
});
const gated = gateAgentVisibilityForCaller(updates.agents, callerHasApi);
updates.agents = gated.agents;
warnings = gated.warnings;
}

// Same gate for the profile-level `is_public` flag. The dedicated
// `/visibility` PUT route already gates this; the bulk-profile
// update path previously forwarded the raw body value, reopening
// the same smuggle a non-paying caller could use on POST create.
if (updates.is_public === true && !isDevModeEnabled()) {
if (!(await orgDb.hasActiveSubscription(targetOrgId))) {
updates.is_public = false;
warnings.push({
code: 'visibility_downgraded',
agent_url: 'profile',
requested: 'public',
applied: 'members_only',
reason: 'tier_required',
message: 'Making the profile publicly visible requires an active paid membership; left as private.',
});
}
}

const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates);
Expand Down Expand Up @@ -649,6 +675,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
): Promise<
| { status: 404; body: { error: string } }
| { status: 400; body: { error: string } }
| { status: 403; body: { error: string; message: string } }
| { status: 200; body: Record<string, unknown> }
> {
const pool = getPool();
Expand All @@ -667,6 +694,44 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
await client.query('ROLLBACK');
return { status: 404, body: { error: 'Profile not found' } };
}

// Re-read tier INSIDE the transaction when the caller is trying
// to publish. The outer `requireApiAccessTier` check is a
// fast-fail for UX, but it reads the org row before this tx
// starts — a concurrent Stripe downgrade webhook can commit
// between the outer check and this UPDATE, letting a `public`
// write land on an org that's no longer API-access. Reading the
// tier-relevant columns here closes that window: if the org was
// downgraded after our outer check, we see the new state and
// ROLLBACK. The Stripe demote path locks member_profiles via
// FOR UPDATE, so it can't interleave past our own lock.
if (target === 'public') {
const orgRow = await client.query<{
membership_tier: string | null;
subscription_price_lookup_key: string | null;
subscription_status: string | null;
subscription_amount: number | null;
subscription_interval: string | null;
is_personal: boolean;
}>(
`SELECT membership_tier, subscription_price_lookup_key, subscription_status,
subscription_amount, subscription_interval, is_personal
FROM organizations
WHERE workos_organization_id = $1`,
[orgId]
);
const currentTier = resolveMembershipTier(orgRow.rows[0] ?? null);
if (!hasApiAccess(currentTier)) {
await client.query('ROLLBACK');
return {
status: 403,
body: {
error: 'tier_required',
message: 'Publicly listing an agent requires Professional tier or higher.',
},
};
}
}
const row = profileRow.rows[0] as { id: string; agents: unknown; primary_brand_domain: string | null };
const parsedAgents = typeof row.agents === 'string'
? JSON.parse(row.agents)
Expand Down
Loading
Loading