File size: 8,601 Bytes
d9ffd67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
---
title: "Pro activation deep-link targeted API Keys, a capability the Pro plan does not have"
date: 2026-07-25
category: logic-errors
module: pro-activation
problem_type: logic_error
component: payments
severity: high
symptoms:
  - "Activation wizard step 3 read 'Get your API & MCP keys' and deep-linked to Settings -> API Keys, which renders 'Upgrade to API Starter' for a user who had just paid for Pro"
  - "Pro entitlement is apiAccess:false / mcpAccess:true (convex/config/productCatalog.ts:138,149), so the promised capability never existed on that plan"
  - "Naively repointing the link at the MCP tab would have traded the upsell for a settings modal with no active panel, because that tab and its panel are both entitlement-gated"
  - "A capability read captured into the flow-options closure could go stale before the pointer was clicked, since ProActivationChip replays the captured object"
root_cause: wrong_api
resolution_type: code_fix
related_components: [testing_framework]
tags: [entitlements, pro-activation, deep-link, mcp, capability-gating, stale-closure, test-fidelity]
---

# Pro activation deep-link targeted API Keys, a capability the Pro plan does not have

## Problem

The Pro activation wizard's step-3 "power toolkit" card advertised **"Get your API & MCP keys"** and deep-linked into Settings -> **API Keys**. The Pro plan's feature set is `apiAccess: false, mcpAccess: true`:

```ts
// convex/config/productCatalog.ts:135-150
const PRO_FEATURES: PlanFeatures = {
  tier: 1,
  apiAccess: false,
  // ...
  mcpAccess: true,
};
```

So the card sold a capability the plan does not include, and routed the buyer to a panel whose entitlement gate immediately offered them **"Upgrade to API Starter"** β€” seconds after they paid for Pro. (Issue #5607, fixed in PR #5613.)

## Symptoms

- Step-3 copy promised API keys to a plan with `apiAccess: false`.
- The deep link landed on the API Keys tab, which renders an upgrade CTA for any user without `apiAccess`.
- Both were reached only on the post-purchase path, so the failure was invisible outside a live purchase repro.

## What Didn't Work

**Repointing the deep link at the MCP tab, unconditionally.** This is the obvious one-line fix and it is wrong. `UnifiedSettings` gates *both* the MCP tab button and its panel on the same feature:

```ts
// src/components/UnifiedSettings.ts:518 (tab button)
${hasFeature('mcpAccess') ? `<button ... data-tab="mcp-clients" ...>MCP Clients ...</button>` : ''}

// src/components/UnifiedSettings.ts:560-563 (panel)
${hasFeature('mcpAccess') ? `<div ... data-panel-id="mcp-clients" ...>...</div>` : ''}
```

and `open()` assigns the target with no check that it is rendered:

```ts
// src/components/UnifiedSettings.ts:343
if (tab) this.activeTab = tab;
```

An ungated `open('mcp-clients')` therefore swaps a wrong-upsell bug for a *blank modal* bug whenever the feature is absent.

**Gating on the plan key instead of the feature.** Tempting, because the activation flow already runs only for `pro_monthly` / `pro_annual`. But the panel renders on `hasFeature('mcpAccess')`, so a plan-key gate can disagree with the thing it is trying to predict. Gate on the same signal the destination gates on.

**Reading the capability once, when the options object is built.** This survives the interstitial (short-lived, opened immediately post-purchase) but not the finish-setup chip, which replays the *captured* options object much later:

```ts
// src/components/ProActivationChip.ts:134
void openProActivationFlow(options).catch((err) =>
```

A build-time-only check can therefore deep-link into a tab that stopped rendering in the interim.

## Solution

Point the pointer at the capability Pro actually has, gate it on that capability, and re-read the capability at click time:

```ts
// src/app/pro-activation-controller.ts:582-590
openMcpClients: hasFeature('mcpAccess')
  ? () => {
      // Re-check at click time, not just here: the finish-setup chip
      // replays this captured options object long after it was built, so
      // an entitlement that lapsed in between would otherwise deep-link
      // to a tab UnifiedSettings no longer renders.
      if (hasFeature('mcpAccess')) ctx.unifiedSettings?.open('mcp-clients');
      else ctx.unifiedSettings?.open('settings');
    }
  : undefined,
```

Leaving the opener `undefined` is the suppression mechanism β€” `buildPowerExtra` drops any pointer without an opener:

```ts
// src/components/ProActivationInterstitial.ts:1025
if (typeof open !== 'function') return;
```

Copy became "Set up MCP", and the i18n key `components.proActivation.steps.power.pointers.apiKeys` was renamed to `.mcpClients` across all 25 locales.

## Why This Works

The gate and the destination now consult the same predicate, so the pointer exists exactly when the panel it targets exists. The click-time re-read closes the build->click window that the chip's replay opens, and the `settings` fallback is a tab that renders unconditionally β€” so no path reaches a modal with no active panel.

## Prevention

**1. Gate a deep link on the capability its destination renders on β€” not on the plan, tier, or role you believe implies it.** Plan-to-capability mappings drift; the destination's own predicate does not. When the target is conditionally rendered, suppressing the entry point beats pointing it somewhere broken.

**2. A capability read that gets captured into a long-lived closure must be re-read at use time.** Build-time and click-time are different moments whenever the options object outlives the interaction that built it. Ask what replays the closure β€” here it was a "finish setup later" chip.

**3. Do not reason about "legacy rows predating a field" in this repo without checking the read path.** Entitlements read-merge catalog defaults, so a row written before a feature flag existed still resolves the current default:

```ts
// convex/entitlements.ts:50-53
const catalogDefaults = getFeaturesForPlan(entitlement.planKey);
return {
  planKey: entitlement.planKey,
  features: { ...catalogDefaults, ...entitlement.features },
```

The original fix comment justified its gate with legacy snapshots; that rationale was wrong β€” the gate's real (narrow) purpose is an explicit per-user override. A comment that misstates *why* a guard exists is how the guard gets deleted later. Note `src/services/entitlements.ts:28-33` still carries the same stale "wait for the next webhook" framing.

**4. Make a test stub mirror production exactly, then let the fixtures fail.** The stub for `hasFeature` used optional chaining (`state.features?.[flag]`) where production indexes directly:

```ts
// src/services/entitlements.ts:156
return Boolean(currentState.features[flag]);
```

Tightening the stub to match immediately threw on two fixtures that built an `EntitlementState` with no `features` β€” a shape the Convex query never returns. The lenient stub had been quietly reporting `false` for them. A stub more forgiving than production converts fixture drift into silent green.

**5. An assertion that something is *absent* is vacuous unless the harness could have produced it.** The e2e guard

```ts
await expect(page.locator('.pro-activation-pointer[data-pointer="apiKeys"]')).toHaveCount(0);
```

could not fail: the harness never injected `openApiKeys`, and `add()` skips openerless pointers, so a reintroduced pointer would have been dropped by the harness rather than by the code under test. Injecting the retired opener gave it teeth β€” confirmed by mutation (reintroducing the pointer produced `Received: 1`).

**6. Mutation-verify each new assertion against the specific regression it guards.** Every assertion in this fix was confirmed red before being trusted: reverting the copy, removing the click-time re-check, reintroducing an `apiKeys` pointer, and both unconditional and inverted gate mutations.

## Related

- [Billing state: cancelled-but-paid-through misclassified as lapsed](../logic-errors/billing-state-cancelled-but-paid-through-misclassified-as-lapsed.md) β€” same failure family: a client-side derivation disagreeing with the server predicate it was meant to mirror.
- [i18n shell namespaces are a byte-budgeted first-paint surface](../conventions/i18n-shell-namespaces-are-byte-budgeted-first-paint-surface.md) β€” why the 25-locale rename here did not touch `en.shell.json`.
- Follow-ups filed from this work: #5611 (`UnifiedSettings.open()` can leave the modal with no active panel β€” the general case behind prevention rule 1), #5612 (power step re-sells MCP to users who already connected a client).