Files
supabase/packages/common/enabled-features/overrides.test.ts
Alaister Young 19027e73f8 [FE-3036] feat(studio): runtime env var overrides for enabled features (#45049)
Lets self-hosted Studio toggle flags in `enabled-features.json` at
container start time via `ENABLED_FEATURES_*` env vars, without
rebuilding the prebuilt image. Addresses
[FE-3036](https://linear.app/supabase/issue/FE-3036/allow-enabled-featuresjson-flags-to-be-overridden-via-env-vars)
and is a prerequisite for
[COM-205](https://linear.app/supabase/issue/COM-205/add-feature-flag-to-disable-all-logs-in-studio).

**Added:**
- `packages/common/enabled-features/overrides.ts` — pure parser that
maps `ENABLED_FEATURES_*` env vars to a disabled-features list
(forward-only key mapping, boolean validation, typo warnings) + 10
vitest tests
- `apps/studio/pages/api/enabled-features-overrides.ts` — Next.js API
route reading `process.env` at request time; no-op (`{
disabled_features: [] }`) when `IS_PLATFORM`
- `apps/studio/data/misc/enabled-features-override-query.ts` — React
Query hook with `staleTime: Infinity`, `enabled: !IS_PLATFORM`
- `packages/common/enabled-features/README.md` — docs the env var
convention, resolution order, `IS_PLATFORM` gating, and the
`Support.constants.ts` build-time caveat

**Changed:**
- `apps/studio/hooks/misc/useIsFeatureEnabled.ts` — merges the
override's `disabled_features` with `profile.disabled_features`

### Env var shape

One var per flag, prefixed `ENABLED_FEATURES_`. Feature key → env name:
uppercase with every non-alphanumeric char replaced by `_`.

```bash
ENABLED_FEATURES_LOGS_ALL=false
ENABLED_FEATURES_BRANDING_LARGE_LOGO=true
```

Values are `true`/`false` case-insensitively. Other values and prefixed
vars that don't match a known feature are logged and ignored.

### Resolution order (runtime, Studio only)

1. `ENABLED_FEATURES_*` (self-hosted, via API route → React Query →
hook)
2. `profile.disabled_features` (hosted, from `/platform/profile`)
3. `enabled-features.json` static value
4. Default (enabled)

`ENABLED_FEATURES_OVERRIDE_DISABLE_ALL` still short-circuits everything.

### Known limitation

`apps/studio/components/interfaces/Support/Support.constants.ts:4` calls
`isFeatureEnabled('billing:all')` at module load to build
`CATEGORY_OPTIONS`, which is spread into Zod form schemas. That call
site stays resolved from the JSON — documented in the package README.
`billing:all` isn't on the radar for self-hosted runtime toggling.

## To test

- `cd packages/common && pnpm exec vitest run enabled-features` — 10 new
tests pass
- `pnpm --filter studio run typecheck` clean
- Spin Studio locally with `NEXT_PUBLIC_IS_PLATFORM=false` and
`ENABLED_FEATURES_LOGS_TEMPLATES=false`;
`/project/[ref]/logs/explorer/templates` should reflect the flag after
the override fetch resolves
- Confirm the API route returns `{ disabled_features: [] }` when
`NEXT_PUBLIC_IS_PLATFORM=true`
- Set a typo like `ENABLED_FEATURES_LOGS_TMEPLATES=false` and check the
warning in container logs; flag stays enabled

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Runtime feature-flag overrides for self-hosted deployments (env var
driven), new API endpoint and client-side hook to fetch overrides, and
client logic now merges profile and runtime overrides.

* **Documentation**
* Added comprehensive README describing the feature-flag system and
override configuration.

* **Tests**
* Added unit tests for override parsing and E2E tests covering runtime
override behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-04-20 22:28:56 +08:00

85 lines
2.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getEnabledFeaturesOverrideDisabledList } from './overrides'
describe('getEnabledFeaturesOverrideDisabledList', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns empty list when no env vars are set', () => {
expect(getEnabledFeaturesOverrideDisabledList({})).toEqual([])
})
it('disables a feature when its env var is "false"', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_LOGS_TEMPLATES: 'false' })
).toEqual(['logs:templates'])
})
it('does not disable features whose env var is "true"', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_LOGS_TEMPLATES: 'true' })
).toEqual([])
})
it('accepts booleans case-insensitively and trims whitespace', () => {
const result = getEnabledFeaturesOverrideDisabledList({
ENABLED_FEATURES_LOGS_TEMPLATES: 'FALSE',
ENABLED_FEATURES_LOGS_METADATA: ' False ',
})
expect(result.sort()).toEqual(['logs:metadata', 'logs:templates'])
})
it('maps snake_case and kebab-case keys to the env name convention', () => {
const result = getEnabledFeaturesOverrideDisabledList({
ENABLED_FEATURES_BRANDING_LARGE_LOGO: 'false',
ENABLED_FEATURES_DOCS_SELF_HOSTING: 'false',
})
expect(result.sort()).toEqual(['branding:large_logo', 'docs:self-hosting'])
})
it('warns and ignores non-boolean values', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_LOGS_TEMPLATES: 'maybe' })
).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('must be "true" or "false"'))
})
it('warns and ignores env vars prefixed but not matching a known feature', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_NOT_A_FEATURE: 'false' })
).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('does not match any known feature')
)
})
it('does not warn for reserved ENABLED_FEATURES_OVERRIDE_DISABLE_ALL', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_OVERRIDE_DISABLE_ALL: 'true' })
).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
})
it('ignores env vars outside the prefix without warning', () => {
expect(
getEnabledFeaturesOverrideDisabledList({ NODE_ENV: 'production', PATH: '/usr/bin' })
).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
})
it('treats an empty string value as unset', () => {
expect(getEnabledFeaturesOverrideDisabledList({ ENABLED_FEATURES_LOGS_TEMPLATES: '' })).toEqual(
[]
)
expect(warnSpy).not.toHaveBeenCalled()
})
})