Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
97 changes: 97 additions & 0 deletions .cursor/rules/version-gated-feature-flags.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
description: Use shared version-gated feature flag helpers; never duplicate hasMinimumRequiredVersion or validatedVersionGatedFeatureFlag
globs: app/**/*.{ts,tsx}
alwaysApply: false
---

# Version-Gated Feature Flags

> Canonical human guide: [`docs/readme/version-gated-feature-flags.md`](../../docs/readme/version-gated-feature-flags.md)

## Canonical source (SSOT)

All version-gated feature flag logic lives in `app/util/remoteFeatureFlag/index.ts`:

- `validatedVersionGatedFeatureFlag` — preferred for remote flags shaped as `{ enabled, minimumVersion }` (or progressive-rollout wrappers)
- `hasMinimumRequiredVersion` — version comparison only; import when needed, never redefine

## Required patterns

### ✅ Use `validatedVersionGatedFeatureFlag` in selectors

```typescript
import { validatedVersionGatedFeatureFlag, VersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag';

export const selectMyFeatureEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
const remoteFlag = remoteFeatureFlags?.myFeature as unknown as VersionGatedFeatureFlag;
return validatedVersionGatedFeatureFlag(remoteFlag) ?? false;
},
);
```

### ✅ Non-standard flag shapes — map, don't duplicate

Configs that use `active` instead of `enabled` (e.g. `depositConfig`) must map to the canonical shape and call the shared helper:

```typescript
import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag';

return (
validatedVersionGatedFeatureFlag({
enabled: depositConfig.active ?? false,
minimumVersion: depositConfig.minimumVersion ?? '',
}) ?? false
);
```

### ✅ Import `hasMinimumRequiredVersion` only from the util

```typescript
import { hasMinimumRequiredVersion } from '../../../util/remoteFeatureFlag';
```

Never copy the `compare-versions` + `getVersion` pattern into feature modules.

## Forbidden patterns

### ❌ Do not define local version-gating helpers

```typescript
// FORBIDDEN — duplicate of app/util/remoteFeatureFlag
function hasMinimumRequiredVersion(minVersion: string) { ... }
const validatedVersionGatedFeatureFlag = (flag: unknown) => { ... }
```

### ❌ Do not inline version checks for feature flags

```typescript
// FORBIDDEN
import { getVersion } from 'react-native-device-info';
import compareVersions from 'compare-versions';

const ok = compareVersions.compare(getVersion(), flag.minimumVersion, '>=');
```

### ❌ Do not add duplicate utils under feature folders

Do not create files like:

- `app/components/UI/**/utils/hasMinimumRequiredVersion.ts`
- `app/core/redux/slices/**/hasMinimumRequiredVersion.ts`

## Where logic belongs

| Layer | Responsibility |
|-------|----------------|
| `RemoteFeatureFlagController` | Fetch/store raw flags — **no** client version gating |
| `app/util/remoteFeatureFlag` | Version comparison + `validatedVersionGatedFeatureFlag` |
| Selectors (`featureFlagController`, `**/selectors/featureFlags`) | Call shared helpers; return `boolean` |
| Hooks / components | `useSelector(selectXEnabled)` — not local version math |

## Enforcement

- **REJECT** new local definitions of `hasMinimumRequiredVersion` or `validatedVersionGatedFeatureFlag`
- **REJECT** new `compare-versions` + `getVersion` usage for feature-flag gating outside `app/util/remoteFeatureFlag`
- **REQUIRE** imports from `app/util/remoteFeatureFlag` for all version-gated flag checks
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,12 @@ release-signoffs.json
.claude/skills/
.claude/commands/
.agents/skills/
.cursor/rules/
.cursor/rules/*
!.cursor/rules/*.mdc
.cursor/skills/
.cursor/commands/
# Carve-outs: IDE/bugbot config that must stay tracked
!.cursor/rules/*.mdc
!.cursor/BUGBOT.md
!.cursor/hooks.json
!.cursor/worktrees.json
Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ scripts/ # Build and automation scripts

**Detailed guidelines are in `docs/testing/`** (canonical, in-repo) and the `mms-*` skill set installed via `yarn skills` (Cursor / Codex / Claude harnesses):

| Guide | Scope |
| ------------------------------------------------------------------------------ | ------------------------------------------------- |
| [`docs/testing/unit-testing.md`](docs/testing/unit-testing.md) | `*.test.*` files — test patterns, mocking, AAA |
| [`docs/testing/e2e-testing.md`](docs/testing/e2e-testing.md) | Detox smoke/regression — Page Objects, gestures |
| [`docs/testing/component-view-tests.md`](docs/testing/component-view-tests.md) | `*.view.test.tsx` — framework, presets, renderers |
| Guide | Scope |
| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| [`docs/testing/unit-testing.md`](docs/testing/unit-testing.md) | `*.test.*` files — test patterns, mocking, AAA |
| [`docs/testing/e2e-testing.md`](docs/testing/e2e-testing.md) | Detox smoke/regression — Page Objects, gestures |
| [`docs/testing/component-view-tests.md`](docs/testing/component-view-tests.md) | `*.view.test.tsx` — framework, presets, renderers |
| [`docs/readme/version-gated-feature-flags.md`](docs/readme/version-gated-feature-flags.md) | Version-gated remote flags — `validatedVersionGatedFeatureFlag` in selectors |

General coding, UI, deeplink-handler, and PR-creation guidance now lives in the centralized `mms-*` skill set installed via `yarn skills` (see `.agents/skills/mms-*` after sync).

Expand All @@ -115,6 +116,7 @@ General coding, UI, deeplink-handler, and PR-creation guidance now lives in the
- **Components**: Design system first → `component-library` second → custom last
- **Styling**: Use `useTailwind()` hook, `Box`/`Text` components, design tokens
- **Testing**: Mandatory for all code, AAA pattern, mock everything external
- **Version-gated feature flags**: Use `validatedVersionGatedFeatureFlag` from `app/util/remoteFeatureFlag` in selectors — see [`docs/readme/version-gated-feature-flags.md`](docs/readme/version-gated-feature-flags.md) and [`.cursor/rules/version-gated-feature-flags.mdc`](.cursor/rules/version-gated-feature-flags.mdc)
- **Commands**: ONLY use yarn (never npm/npx)

## Environment Setup
Expand Down
4 changes: 3 additions & 1 deletion docs/compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ To enable compliance:
1. Set `complianceEnabled: true` in LaunchDarkly (or via the local feature flag override screen in dev builds).
2. Compliance status will be fetched per-address whenever a gated action is triggered.

> **Version gating:** The feature flag uses `validatedVersionGatedFeatureFlag`, which compares against the **native binary version** reported by `react-native-device-info`'s `getVersion()` — not `package.json`. If your device has a stale build installed, the version check may fail even if the flag is enabled. Do a clean native rebuild to pick up the correct version.
> **Version gating:** The feature flag uses `validatedVersionGatedFeatureFlag` in `selectComplianceEnabled`, which compares against the **native binary version** reported by `react-native-device-info`'s `getVersion()` — not `package.json`. If your device has a stale build installed, the version check may fail even if the flag is enabled. Do a clean native rebuild to pick up the correct version.

### Feature flag selector

Expand All @@ -60,6 +60,8 @@ import { selectComplianceEnabled } from 'app/selectors/featureFlagController/com
const isEnabled = useSelector(selectComplianceEnabled);
```

The selector resolves the remote `{ enabled, minimumVersion }` payload via `validatedVersionGatedFeatureFlag`. See [`docs/readme/version-gated-feature-flags.md`](./readme/version-gated-feature-flags.md).

## Usage in Flows

### Option 1: `gate()` from `useComplianceGate` (recommended for action guards)
Expand Down
7 changes: 6 additions & 1 deletion docs/musd/musd-convert-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,14 @@ export const selectMusdConversionStatuses = createSelector(

**Path:** `app/components/UI/Earn/selectors/featureFlags/index.ts`

Add new version-gated flag with local fallback (following the same pattern as other mUSD flags):
Add new version-gated flag with local fallback (see [`docs/readme/version-gated-feature-flags.md`](../../readme/version-gated-feature-flags.md)):

```typescript
import {
validatedVersionGatedFeatureFlag,
type VersionGatedFeatureFlag,
} from '../../../../util/remoteFeatureFlag';

export const selectMusdQuickConvertEnabledFlag = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
Expand Down
22 changes: 15 additions & 7 deletions docs/perps/perps-feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Framework for controlling Perps feature availability through LaunchDarkly with l
**Key Design Principles:**

- LaunchDarkly is the single source of truth for feature enablement
- Version-gated flags ensure features only activate on compatible app versions
- Version-gated flags use `validatedVersionGatedFeatureFlag` from `app/util/remoteFeatureFlag` (see [Version-gated feature flags](../readme/version-gated-feature-flags.md))
- Local environment variables provide development/testing fallback
- Graceful degradation when LaunchDarkly is unavailable

Expand Down Expand Up @@ -74,6 +74,11 @@ See [Perps A/B Testing Framework](./perps-ab-testing.md) for variant-based flags
**File:** `app/components/UI/Perps/selectors/featureFlags/index.ts`

```typescript
import {
validatedVersionGatedFeatureFlag,
type VersionGatedFeatureFlag,
} from '../../../../util/remoteFeatureFlag';

/**
* Selector for My Feature flag
* Controls visibility of My Feature in the UI
Expand Down Expand Up @@ -230,12 +235,14 @@ These flags are managed via `FeatureFlagConfigurationService` and control runtim

### Version Gating

The `minimumVersion` field ensures features only activate on compatible app versions:
The `minimumVersion` field ensures features only activate on compatible app versions. **Do not** compare versions in selectors or components — use `validatedVersionGatedFeatureFlag`:

- **Format:** Semantic version string (e.g., `"7.60.0"`)
- **Comparison:** Uses `compare-versions` library with `>=` operator
- **Format:** Semantic version string (e.g. `"7.60.0"`)
- **Evaluation:** `validatedVersionGatedFeatureFlag(remoteFlag)` returns `true` only when `enabled` is true **and** the native app version is `>= minimumVersion`
- **Use case:** Prevent feature activation on older app versions that lack required code

See [`docs/readme/version-gated-feature-flags.md`](../readme/version-gated-feature-flags.md) for the full pattern and non-standard flag shapes.

---

## Troubleshooting
Expand All @@ -249,8 +256,8 @@ The `minimumVersion` field ensures features only activate on compatible app vers
### Version Gating Not Working

1. **Verify `minimumVersion` format:** Must be valid semver string
2. **Check app version:** `getVersion()` from `react-native-device-info`
3. **Check comparison:** Uses `>=` operator
2. **Check native app version:** `getVersion()` from `react-native-device-info` (used inside `validatedVersionGatedFeatureFlag`) — not `package.json`
3. **Confirm selector uses the helper:** `validatedVersionGatedFeatureFlag(remoteFlag) ?? localFallback` — not inline `compare-versions`

### Local Flag Not Overriding

Expand All @@ -265,7 +272,8 @@ The `minimumVersion` field ensures features only activate on compatible app vers
- **Selectors:** `app/components/UI/Perps/selectors/featureFlags/index.ts`
- **Mocks:** `app/components/UI/Perps/mocks/remoteFeatureFlagMocks.ts`
- **Tests:** `app/components/UI/Perps/selectors/featureFlags/index.test.ts`
- **Version validation:** `app/util/remoteFeatureFlag/index.ts`
- **Version validation:** `app/util/remoteFeatureFlag/index.ts` (`validatedVersionGatedFeatureFlag`)
- **Version-gated flags guide:** `docs/readme/version-gated-feature-flags.md`
- **Controller init:** `app/core/Engine/controllers/remote-feature-flag-controller-init.ts`
- **Configuration service:** `app/controllers/perps/services/FeatureFlagConfigurationService.ts`

Expand Down
7 changes: 6 additions & 1 deletion docs/predict/live-nfl-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ The Live NFL feature introduces real-time sports betting functionality with WebS

## Feature Flag

The feature is gated behind `predictLiveNflEnabled`:
The feature is gated behind `predictLiveNflEnabled` using `validatedVersionGatedFeatureFlag` (see [`docs/readme/version-gated-feature-flags.md`](../readme/version-gated-feature-flags.md)):

```typescript
// app/components/UI/Predict/selectors/featureFlags/index.ts
import {
validatedVersionGatedFeatureFlag,
type VersionGatedFeatureFlag,
} from '../../../../util/remoteFeatureFlag';

export const selectPredictLiveNflEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
Expand Down
5 changes: 5 additions & 0 deletions docs/predict/tasks/00-feature-flag-and-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Set up the feature flag for Live NFL and define all necessary TypeScript types f
Create/update `app/components/UI/Predict/selectors/featureFlags/index.ts`:

```typescript
import {
validatedVersionGatedFeatureFlag,
type VersionGatedFeatureFlag,
} from '../../../../util/remoteFeatureFlag';

/**
* Selector for Predict Live NFL feature enablement
*/
Expand Down
97 changes: 97 additions & 0 deletions docs/readme/version-gated-feature-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Version-Gated Feature Flags

Canonical guide for evaluating remote feature flags that include a minimum app version. Applies to all teams (Ramp, Perps, Money, Predict, platform selectors, etc.).

## SSOT

| Concern | Location |
| ------------------------------------ | -------------------------------------------------------------------------------------- |
| Version comparison + flag validation | [`app/util/remoteFeatureFlag/index.ts`](../../app/util/remoteFeatureFlag/index.ts) |
| Raw flag storage | `RemoteFeatureFlagController.remoteFeatureFlags` (no client version gating) |
| Boolean for UI / hooks | Selectors under `app/selectors/featureFlagController/` or `**/selectors/featureFlags/` |

**Always use** `validatedVersionGatedFeatureFlag` in selectors. **Never** duplicate `hasMinimumRequiredVersion` or `validatedVersionGatedFeatureFlag` in feature modules.

`hasMinimumRequiredVersion` is a lower-level helper used inside `validatedVersionGatedFeatureFlag`. Import it from the util only when you need a standalone version check — do not reimplement it.

## Flag shapes

### Standard (preferred)

LaunchDarkly JSON:

```json
{
"enabled": true,
"minimumVersion": "7.65.0"
}
```

Progressive-rollout wrappers (`{ name, value: { enabled, minimumVersion } }`) are normalized automatically.

### Non-standard configs

Some flags use different property names (e.g. `depositConfig` uses `active` instead of `enabled`). **Map** to the canonical shape before calling the helper:

```typescript
validatedVersionGatedFeatureFlag({
enabled: depositConfig.active ?? false,
minimumVersion: depositConfig.minimumVersion ?? '',
}) ?? false;
```

### Multi-version flags (controller-processed)

`RemoteFeatureFlagController` resolves flags shaped as `{ versions: { "7.53.0": value, "7.60.0": value } }` at fetch time using `clientVersion`. Consumers read the **processed** value from state — no `validatedVersionGatedFeatureFlag` call needed for that shape.

## Implementation pattern

### 1. Selector (required)

```typescript
import { createSelector } from 'reselect';
import { selectRemoteFeatureFlags } from '../index'; // adjust path
import {
validatedVersionGatedFeatureFlag,
type VersionGatedFeatureFlag,
} from '../../../util/remoteFeatureFlag';

export const selectMyFeatureEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
const localFlag = process.env.MM_MY_FEATURE_ENABLED === 'true';
const remoteFlag =
remoteFeatureFlags?.myFeature as unknown as VersionGatedFeatureFlag;

return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag;
},
);
```

- Remote flag wins when valid; `undefined` from the helper triggers env/local fallback.
- When `OVERRIDE_REMOTE_FEATURE_FLAGS=true`, the helper returns `undefined` so local overrides apply.

### 2. Hook or component

```typescript
const isEnabled = useSelector(selectMyFeatureEnabled);
```

Do not call `getVersion()`, `compare-versions`, or local version helpers in hooks/components.

## Version source

`validatedVersionGatedFeatureFlag` compares against the **native binary version** from `react-native-device-info`'s `getVersion()` — not `package.json`. After a version bump, do a clean native rebuild if gating behaves unexpectedly.

## Forbidden

- Local `function hasMinimumRequiredVersion(...)` copies
- Inline `compareVersions.compare(getVersion(), minimumVersion, '>=')` for feature flags
- Duplicate util files under `app/components/UI/**/utils/` or `app/core/redux/slices/**/`

## Related

- [Perps feature flags](../perps/perps-feature-flags.md) — team-specific examples
- [Compliance OFAC gating](../compliance.md#feature-flag) — real selector using this pattern
- [`.cursor/rules/version-gated-feature-flags.mdc`](../../.cursor/rules/version-gated-feature-flags.mdc) — Cursor agent rule
- External: [MetaMask remote feature flags](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md)
Loading