feat(visibility): three-tier agent visibility with tier gate#2793
Conversation
Replaces the boolean agent is_public flag with visibility: 'private' | 'members_only' | 'public'. Public listing is gated on API-access membership tiers (Professional+). members_only is the discovery pool for paid-member-only agents, solving the Scope3 use case: a non-paying org can register agents and be discoverable by Professional+ members without landing in the public directory. Stripe tier-downgrade auto-demotes public agents to members_only and strips them from the community brand.json manifest. MCP list_agents, get_agent, list_members, get_member receive the caller's org tier and return members_only agents to API-access callers only. PUT on the whole profile silently coerces smuggled visibility=public to members_only for non-API callers and surfaces the coercion via a warnings[] array. Drive-by fix: brand_revisions.domain was renamed to brand_domain in migration 389 but the code still queried the old column name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
github-code-quality bot flagged an unused variable left over from the visibility refactor. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review — Three-Tier Agent VisibilityBlockers2. Tier bypass via 3. High4. TOCTOU on the tier gate. 5. Medium
Nits
Positives
RecommendationRequest changes. Blockers: migration idempotency, POST creation tier bypass, MCP |
Main landed two migrations numbered 419: #2793 (agent visibility, first) and #2800 (OAuth client-credentials, second). Per-PR "No duplicate migration numbers" CI passed on each because at check-time the other wasn't on main yet. Merge-queue race. Every fresh DB boot now fails with: Migration filename validation failed: Duplicate migration version 419: 419_agent_visibility.sql and 419_oauth_client_credentials.sql Every already-deployed instance fails on next restart. Fix: rename 419_oauth_client_credentials.sql to 420. The SQL is idempotent (ADD COLUMN IF NOT EXISTS, DROP VIEW IF EXISTS + CREATE VIEW) so the rename is safe for all three deployment states: - Fresh DBs apply 419+420 in order. - Instances that ran the old 419_oauth_client_credentials.sql: 420 re-runs cleanly, columns/view no-op. - Instances that ran #2793's 419_agent_visibility.sql: 420 applies fresh. Verified locally: docker compose up proceeds past the load step. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(visibility): close tier-gate blockers from #2793 review #2793 (three-tier agent visibility) shipped before the EmmaLouise2018 review was addressed. This PR closes the three Blocker / two High findings. - POST /api/me/member-profile (create) persisted agents verbatim, so an Explorer could land visibility:'public' on first write and later readers filtered strictly on === 'public' with no tier re-check. Extracted coercion from PUT into a shared agent-visibility-gate helper; both paths use it. Create response surfaces structured warnings[] on downgrade, matching PUT. - MCP addAgent (member-tools.ts) defaulted to visibility:'public' with no tier gate. Changed default to 'members_only' — safer than resolving caller tier inline and matches the UI default for new agents. - applyAgentVisibility had a TOCTOU: the outer requireApiAccessTier ran before the transaction. A concurrent Stripe downgrade could commit between the outer check and the UPDATE, letting a 'public' write land on a now-downgraded org. Now re-reads membership tier inside the profile FOR UPDATE transaction and rolls back with 403 if the current tier lacks API access. - demotePublicAgentsOnTierDowngrade called getProfileByOrgId then updateProfileByOrgId as two separate statements — no row lock. Rewritten to hold a pg client, BEGIN, SELECT FOR UPDATE on member_profiles, UPDATE, COMMIT. Combined with the inner-tx tier check above, a publish and a demote cannot interleave past each other's lock on the same profile. Tests: - New agent-visibility-gate.test.ts — 10 scenarios for the shared helper - agent-visibility-enforcement.test.ts rewritten to mock getPool() and the pg client (9 scenarios, including "commits profile tx before touching brand.json") - Full server unit suite green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(visibility): expert-review follow-ups on #2822 Addresses Must-Fix and Should-Fix items from the three-expert review: - DX Must-Fix: dashboard client silently dropped warnings[]. member- profile.html now renders a "saved, but N agents could not be listed publicly" banner with an upgrade CTA when the response carries downgrade warnings. Applies to both POST (new create flow) and PUT (existing update flow) — both paths return the same shape. - DX Must-Fix: save_agent tool description didn't mention visibility. Added explicit members_only default + publish-flow semantics to the tool description, and Addie's save confirmation now names the visibility state instead of the generic "added to dashboard". - Security Should-Fix: POST /api/me/member-profile and PUT bulk update accepted `is_public: true` verbatim — same smuggle class as the agent-visibility bug this PR fixes. Both paths now route the flag through orgDb.hasActiveSubscription, fall back to false, and emit a structured warning. - Security Should-Fix: agents[].type was cast from arbitrary caller string into brand.json's manifest. Now gated through isValidAgentType and dropped if unknown. - Code-reviewer Should-Fix: dead memberDb DI arg on demotePublicAgentsOnTierDowngrade removed. Enforcement test rewritten to assert the SELECT contains FOR UPDATE and the params[0] matches orgId (regression guard for the transactional invariant). - DX Should-Fix: VisibilityWarning.agent_url typed as string and coerced at emit time — clients no longer risk rendering whatever raw shape arrived in the request body. Out of scope, filed as follow-ups: - #2825: brand.json write inside applyAgentVisibility's profile tx - #2826: inner-tx tier column list duplicates resolveMembershipTier Full server unit suite: 1814 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: silence CodeQL property-access warnings on mocked pg client CodeQL was flagging every read of `__client` / `__connect` (imported through `vi.mock` via a `@ts-expect-error` shim) as "property access on null or undefined" — the mock factory always provides them, but the static analyzer sees unknown/untyped imports. Replace the bare imports with a validated module-level binding: reach into the mocked module once, assert the expected shape, and hand the rest of the file concretely-typed locals (`mockedClient`, `mockedConnect`). Fails loudly with a descriptive error if the mock factory ever drifts, which is a test-setup bug we'd want to surface immediately rather than silently degrade into nullish reads. Same behavior, same test coverage (10 scenarios pass). No `?.` noise sprinkled across the assertion bodies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ction (#3288) The pull_request workflow correctly catches a stale-view-of-main case when a single PR runs against an out-of-date base, but two PRs whose checks run in parallel against different snapshots can both pass legitimately. That's how #3235 + #3244 both landed with migration 433 (and #2793 + #2800 with 419 in February). Three layered fixes: 1. merge_group trigger — runs the check on the real merge commit when the GitHub merge queue is enabled. Only way to fully serialize. 2. Daily scheduled run as a safety net. If a duplicate slips through, the workflow goes red on main within 24 hours and branch protection blocks subsequent merges until the duplicate is renumbered. 3. Filesystem invariant test in server/tests/unit/migrate.test.ts that scans the migrations directory and asserts no duplicate version numbers / malformed filenames. Runs locally on npm test:unit and on every CI run regardless of workflow timing. Error message also clearer about the rebase-and-renumber recovery procedure, including the IF NOT EXISTS requirement so re-running on systems that already applied the old number is a no-op. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
is_publicboolean withvisibility: 'private' | 'members_only' | 'public'. Public listing requires Professional+ (API access) tier; Explorer and non-paying users can setprivateormembers_only.members_onlyis the paid-member discovery pool: non-paying orgs can register agents and be found by Professional+ members via MCPlist_agents/get_member, without landing in the public directory or brand.json. Solves the Scope3-style use case.public→members_onlyon any tier change where the new tier lacks API access (including full cancellation), and strips those agents from the community brand.json manifest.list_agents,get_agent,list_members,get_memberare tier-aware: API-access callers receivemembers_onlyagents; non-API callers never do. Tool descriptions spell out the semantics. Thevisibilityfield is omitted for non-API callers to cut context bloat./api/me/member-profilecoerces smuggledvisibility=publicdown tomembers_onlyfor non-API callers and surfaces the coercion via awarnings[]array in the response — closes the whole-profile bypass path.applyAgentVisibilityruns in a transaction withSELECT ... FOR UPDATE, eliminating the publish/downgrade race./checkendpoint is report-only now — returns adriftvalue instead of silently mutating visibility.server/public/member-card.jsrenders a three-radio selector with an Upgrade-to-Professional upsell on the disabledpublicoption; new agents default tomembers_only.brand_revisions.domainwas renamed tobrand_domainin migration 389 but the code still queried the old name — caused 500s on the secondupdateManifestAgentscall for the same community brand.agentsJSONB (is_public: true→visibility: 'public', false →'private') and drops any pre-existingvisibilitykey to prevent pre-migration smuggling.Test plan
agent-visibility.test.ts(tier helper + type guard, 10 tests)agent-service-visibility.test.ts(filter by viewer tier, 6 tests)agent-visibility-enforcement.test.ts(demote helper, 8 tests)mcp-tools-viewer-context.test.ts(MCP callerHasApiAccess filtering, 6 tests)agent-visibility-e2e.test.ts— Explorer 403, Professional 200, PUT-bypass coercion, private-profile members_only, tier downgrade, full cancellation, legacy is_public normalization, 11 teststsc --noEmitclean across server/Deferred (not blockers)
PATCH /visibility(low abuse surface)/publish//check(PATCH useserror: tier_required; others still human strings)🤖 Generated with Claude Code