diff --git a/README.md b/README.md index db8dc414..2ab40925 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,14 @@ npx firebase use {project_name_or_alias} npx firebase hosting:channel:deploy {channel_name} ``` +# Firebase Remote Configs + +Firebase remote configs help us toggle new features on and off. Due to the nature of static pages, there are some nuances. For static pages, the remote configs are called and set at build time and will be the same for the remainder of the static page's cache. + +When remote configs change (they rarily do), it is recommended to redeploy the app as that will trigger a new cache for all pages, that will include the updated remote configs + +What this also means is that client components will be able to access the firebase remote configs using the Context but server components will have to fetch them each time. This isn't a big deal as the firebase remote configs are cached (for 1 hour on the server) + # Component and E2E tests Component and E2E tests are executed with [Cypress](https://docs.cypress.io/). Cypress tests are located in the cypress folder. diff --git a/src/app/context/RemoteConfigProvider.tsx b/src/app/context/RemoteConfigProvider.tsx index 0b4fdb96..12afcb0a 100644 --- a/src/app/context/RemoteConfigProvider.tsx +++ b/src/app/context/RemoteConfigProvider.tsx @@ -1,10 +1,18 @@ 'use client'; -import React, { createContext, type ReactNode, useContext } from 'react'; +import React, { + createContext, + useState, + useEffect, + type ReactNode, + useContext, +} from 'react'; import { defaultRemoteConfigValues, + matchesFeatureFlagBypass, type RemoteConfigValues, } from '../interface/RemoteConfig'; +import { useAuthSession } from '../components/AuthSessionProvider'; const RemoteConfigContext = createContext<{ config: RemoteConfigValues; @@ -17,16 +25,41 @@ interface RemoteConfigProviderProps { config: RemoteConfigValues; } +function applyAdminBypass(config: RemoteConfigValues): RemoteConfigValues { + const overridden = { ...config }; + for (const key of Object.keys(overridden) as Array< + keyof RemoteConfigValues + >) { + if (typeof overridden[key] === 'boolean') { + (overridden as Record)[key] = true; + } + } + return overridden; +} + /** * Client-side Remote Config provider that hydrates server-fetched config into React Context. - * This provider does NOT fetch config - it receives pre-fetched values from the server. + * Applies admin bypass for @mobilitydata.org users after client-side auth resolves, + * which ensures correct flags even on statically generated pages. */ export const RemoteConfigProvider = ({ children, config, }: RemoteConfigProviderProps): React.ReactElement => { + const { email, isAuthReady } = useAuthSession(); + const [effectiveConfig, setEffectiveConfig] = useState(config); + + useEffect(() => { + if (!isAuthReady) return; + setEffectiveConfig( + matchesFeatureFlagBypass(email, config.featureFlagBypass) + ? applyAdminBypass(config) + : config, + ); + }, [email, isAuthReady, config]); + return ( - + {children} ); diff --git a/src/app/interface/RemoteConfig.ts b/src/app/interface/RemoteConfig.ts index 55c908a8..620f42a3 100644 --- a/src/app/interface/RemoteConfig.ts +++ b/src/app/interface/RemoteConfig.ts @@ -47,3 +47,23 @@ export const defaultRemoteConfigValues: RemoteConfigValues = { enableDetailedCoveredArea: false, gbfsValidator: false, }; + +/** + * Returns true if the given email matches any regex pattern in the + * featureFlagBypass config value (format: `{ "regex": [".+@example.org"] }`). + */ +export function matchesFeatureFlagBypass( + email: string | null | undefined, + featureFlagBypass: string, +): boolean { + if (email == null || email === '' || featureFlagBypass === '') return false; + try { + const parsed = JSON.parse(featureFlagBypass) as { regex?: unknown }; + if (!Array.isArray(parsed.regex)) return false; + return (parsed.regex as string[]).some((pattern) => + new RegExp(pattern).test(email), + ); + } catch { + return false; + } +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 77c8667e..bdc53983 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -44,11 +44,13 @@ export function Providers({ return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/src/app/screens/Feed/components/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index c52892fd..36f80819 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -7,7 +7,7 @@ import { WarningContentBox } from '../../../components/WarningContentBox'; import { FeedStatusChip } from '../../../components/FeedStatus'; import OfficialChip from '../../../components/OfficialChip'; import { getTranslations } from 'next-intl/server'; -import { getRemoteConfigValues } from '../../../../lib/remote-config.server'; +import { getUserRemoteConfigValues } from '../../../../lib/remote-config.server'; export interface DataQualitySummaryProps { feedStatus: components['schemas']['Feed']['status']; @@ -24,7 +24,7 @@ export default async function DataQualitySummary({ const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), getTranslations('common'), - getRemoteConfigValues(), + getUserRemoteConfigValues(), ]); return ( diff --git a/src/lib/remote-config.server.spec.ts b/src/lib/remote-config.server.spec.ts new file mode 100644 index 00000000..3dbc8a4a --- /dev/null +++ b/src/lib/remote-config.server.spec.ts @@ -0,0 +1,289 @@ +/** + * @jest-environment node + */ + +import { + getRemoteConfigValuesForUser, + getUserRemoteConfigValues, + refreshRemoteConfig, + applyAdminBypass, +} from './remote-config.server'; +import { + defaultRemoteConfigValues, + matchesFeatureFlagBypass, +} from '../app/interface/RemoteConfig'; + +jest.mock('server-only', () => ({})); +jest.mock('react', () => ({ + cache: (fn: unknown) => fn, +})); +jest.mock('next/cache', () => ({ + unstable_cache: (fn: unknown) => fn, + revalidateTag: jest.fn(), +})); + +const mockGetTemplate = jest.fn(); + +jest.mock('firebase-admin/remote-config', () => ({ + getRemoteConfig: jest.fn(() => ({ getTemplate: mockGetTemplate })), +})); + +jest.mock('../app/utils/config', () => ({ + getEnvConfig: jest.fn().mockReturnValue(''), +})); + +const mockGetCurrentUserFromCookie = jest.fn(); + +jest.mock('../app/utils/auth-server', () => ({ + getCurrentUserFromCookie: (...args: unknown[]) => + mockGetCurrentUserFromCookie(...args), +})); + +jest.mock('./firebase-admin', () => ({ + getFirebaseAdminApp: jest.fn().mockReturnValue({}), +})); + +const BYPASS_CONFIG = JSON.stringify({ regex: ['.+@mobilitydata\\.org'] }); + +describe('remote-config.server', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('matchesFeatureFlagBypass', () => { + it('returns true for matching email', () => { + expect( + matchesFeatureFlagBypass('engineer@mobilitydata.org', BYPASS_CONFIG), + ).toBe(true); + }); + + it('returns false for non-matching email', () => { + expect(matchesFeatureFlagBypass('user@example.com', BYPASS_CONFIG)).toBe( + false, + ); + }); + + it('returns false for empty featureFlagBypass', () => { + expect(matchesFeatureFlagBypass('engineer@mobilitydata.org', '')).toBe( + false, + ); + }); + + it('returns false for null email', () => { + expect(matchesFeatureFlagBypass(null, BYPASS_CONFIG)).toBe(false); + }); + + it('returns false for undefined email', () => { + expect(matchesFeatureFlagBypass(undefined, BYPASS_CONFIG)).toBe(false); + }); + + it('returns false for invalid JSON', () => { + expect( + matchesFeatureFlagBypass('engineer@mobilitydata.org', 'not-json'), + ).toBe(false); + }); + + it('returns false when regex key is missing', () => { + expect( + matchesFeatureFlagBypass( + 'engineer@mobilitydata.org', + JSON.stringify({ other: [] }), + ), + ).toBe(false); + }); + }); + + describe('applyAdminBypass', () => { + it('sets all boolean flags to true', () => { + const config = { + ...defaultRemoteConfigValues, + enableMetrics: false, + enableLanguageToggle: false, + }; + const result = applyAdminBypass(config); + expect(result.enableMetrics).toBe(true); + expect(result.enableLanguageToggle).toBe(true); + expect(result.enableFeedStatusBadge).toBe(true); + }); + + it('preserves non-boolean values', () => { + const config = { + ...defaultRemoteConfigValues, + gtfsMetricsBucketEndpoint: 'https://custom-endpoint.com', + visualizationMapFullDataLimit: 10, + }; + const result = applyAdminBypass(config); + expect(result.gtfsMetricsBucketEndpoint).toBe( + 'https://custom-endpoint.com', + ); + expect(result.visualizationMapFullDataLimit).toBe(10); + }); + }); + + describe('getRemoteConfigValuesForUser', () => { + it('returns base config when email does not match featureFlagBypass', async () => { + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getRemoteConfigValuesForUser('user@example.com'); + + expect(result.enableMetrics).toBe(false); + expect(result.enableLanguageToggle).toBe( + defaultRemoteConfigValues.enableLanguageToggle, + ); + }); + + it('returns config with all boolean flags true when email matches featureFlagBypass', async () => { + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + enableLanguageToggle: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getRemoteConfigValuesForUser( + 'engineer@mobilitydata.org', + ); + + expect(result.enableMetrics).toBe(true); + expect(result.enableLanguageToggle).toBe(true); + expect(result.enableFeedStatusBadge).toBe(true); + expect(result.enableDetailedCoveredArea).toBe(true); + expect(result.gbfsValidator).toBe(true); + }); + + it('returns base config when featureFlagBypass is empty', async () => { + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: '' } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getRemoteConfigValuesForUser( + 'engineer@mobilitydata.org', + ); + + expect(result.enableMetrics).toBe(false); + }); + + it('returns base config for undefined email', async () => { + mockGetTemplate.mockResolvedValue({ + parameters: { + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getRemoteConfigValuesForUser(undefined); + + expect(result.enableMetrics).toBe( + defaultRemoteConfigValues.enableMetrics, + ); + }); + + it('preserves non-boolean config values when applying bypass', async () => { + mockGetTemplate.mockResolvedValue({ + parameters: { + gtfsMetricsBucketEndpoint: { + defaultValue: { + value: 'https://storage.googleapis.com/custom-gtfs-bucket', + }, + }, + visualizationMapFullDataLimit: { + defaultValue: { value: '10' }, + }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getRemoteConfigValuesForUser( + 'engineer@mobilitydata.org', + ); + + expect(result.gtfsMetricsBucketEndpoint).toBe( + 'https://storage.googleapis.com/custom-gtfs-bucket', + ); + expect(result.visualizationMapFullDataLimit).toBe(10); + expect(result.enableMetrics).toBe(true); + }); + }); + + describe('getUserRemoteConfigValues', () => { + it('returns bypass config for authenticated admin user', async () => { + mockGetCurrentUserFromCookie.mockResolvedValue({ + email: 'engineer@mobilitydata.org', + }); + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getUserRemoteConfigValues(); + + expect(result.enableMetrics).toBe(true); + }); + + it('returns base config for non-admin authenticated user', async () => { + mockGetCurrentUserFromCookie.mockResolvedValue({ + email: 'user@example.com', + }); + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getUserRemoteConfigValues(); + + expect(result.enableMetrics).toBe(false); + }); + + it('returns base config for unauthenticated user', async () => { + mockGetCurrentUserFromCookie.mockResolvedValue(undefined); + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'false' } }, + featureFlagBypass: { defaultValue: { value: BYPASS_CONFIG } }, + }, + }); + await refreshRemoteConfig(); + + const result = await getUserRemoteConfigValues(); + + expect(result.enableMetrics).toBe(false); + }); + }); + + describe('refreshRemoteConfig', () => { + it('calls revalidateTag with remote-config tag', async () => { + const { revalidateTag } = jest.requireMock('next/cache'); + mockGetTemplate.mockResolvedValue({ parameters: {} }); + + await refreshRemoteConfig(); + + expect(revalidateTag).toHaveBeenCalledWith('remote-config', 'max'); + }); + }); +}); diff --git a/src/lib/remote-config.server.ts b/src/lib/remote-config.server.ts index fa79af3a..d1b6df08 100644 --- a/src/lib/remote-config.server.ts +++ b/src/lib/remote-config.server.ts @@ -1,13 +1,16 @@ import 'server-only'; import { cache } from 'react'; +import { unstable_cache, revalidateTag } from 'next/cache'; import { getRemoteConfig } from 'firebase-admin/remote-config'; import { getFirebaseAdminApp } from './firebase-admin'; import { getEnvConfig } from '../app/utils/config'; import { defaultRemoteConfigValues, + matchesFeatureFlagBypass, type RemoteConfigValues, } from '../app/interface/RemoteConfig'; +import { getCurrentUserFromCookie } from '../app/utils/auth-server'; /** * Cache duration for Remote Config fetches (in seconds). @@ -17,13 +20,6 @@ import { const CACHE_DURATION_SECONDS = process.env.NODE_ENV === 'development' ? 300 : 3600; -/** - * In-memory cache for Remote Config values. - * This provides fast access on subsequent requests within the same server instance. - */ -let cachedConfig: RemoteConfigValues | null = null; -let cacheTimestamp: number = 0; - /** * Parse a Remote Config parameter value into the appropriate type. */ @@ -91,42 +87,43 @@ async function fetchRemoteConfigFromFirebase(): Promise { } } +/** + * Fetch Remote Config from Firebase, backed by Next.js Data Cache. + * On Vercel, this cache is shared across all function instances and persists + * across invocations, unlike in-memory caching. + * Tagged with 'remote-config' for on-demand revalidation via revalidateTag(). + */ +const fetchRemoteConfigCached = unstable_cache( + fetchRemoteConfigFromFirebase, + ['remote-config'], + { revalidate: CACHE_DURATION_SECONDS, tags: ['remote-config'] }, +); + /** * Get Remote Config values with server-side caching. * This function is safe to call from Server Components and Server Actions. * * Caching strategy: - * - React cache() deduplicates calls within the same request (e.g., layout + page) - * - In-memory cache for fast access across multiple requests within the same server instance - * - Cache invalidates after CACHE_DURATION_SECONDS - * - On error, returns cached values if available, otherwise defaults + * - react cache() deduplicates calls within the same request (e.g., layout + page) + * - unstable_cache persists across requests and Vercel function instances + * - Cache revalidates after CACHE_DURATION_SECONDS + * - On error, returns defaults */ export const getRemoteConfigValues = cache( async (): Promise => { - // Dev/mock bypass: use defaults immediately + // Dev/mock bypass: skip cache entirely const isMock = getEnvConfig('NEXT_PUBLIC_API_MOCKING') === 'enabled' || getEnvConfig('LOCAL_DEV_NO_ADMIN') === '1'; if (isMock) { return defaultRemoteConfigValues; } - const now = Date.now(); - const cacheAge = (now - cacheTimestamp) / 1000; - - // Return cached config if still valid - if (cachedConfig != undefined && cacheAge < CACHE_DURATION_SECONDS) { - return cachedConfig; - } try { - const freshConfig = await fetchRemoteConfigFromFirebase(); - cachedConfig = freshConfig; - cacheTimestamp = now; - return freshConfig; + return await fetchRemoteConfigCached(); } catch (error) { console.error('Error fetching Remote Config:', error); - // Return stale cache if available, otherwise defaults - return cachedConfig ?? defaultRemoteConfigValues; + return defaultRemoteConfigValues; } }, ); @@ -136,7 +133,54 @@ export const getRemoteConfigValues = cache( * Useful for admin operations or webhooks that need immediate updates. */ export async function refreshRemoteConfig(): Promise { - cachedConfig = null; - cacheTimestamp = 0; + revalidateTag('remote-config', 'max'); return await getRemoteConfigValues(); } + +/** + * Returns a copy of the config with all boolean flags set to `true`. + * Used to give specific users ex: internal @mobilitydata.org users access to all features. + * Exported for use in server components that receive isAdmin as a prop. + */ +export function applyAdminBypass( + config: RemoteConfigValues, +): RemoteConfigValues { + const overridden = { ...config }; + for (const key of Object.keys(overridden) as Array< + keyof RemoteConfigValues + >) { + if (typeof overridden[key] === 'boolean') { + (overridden as Record)[key] = true; + } + } + return overridden; +} + +/** + * Get Remote Config values for a specific user. + * Specific users ex: @mobilitydata.org users receive all boolean feature flags enabled. + */ +export async function getRemoteConfigValuesForUser( + email?: string, +): Promise { + const config = await getRemoteConfigValues(); + if (matchesFeatureFlagBypass(email, config.featureFlagBypass)) { + return applyAdminBypass(config); + } + return config; +} + +/** + * Get Remote Config values for the current request's authenticated user. + * Reads the session cookie internally — no prop threading required. + * Safe to call from any server component; cookies() is request-scoped. + */ +export async function getUserRemoteConfigValues(): Promise { + const [config, currentUser] = await Promise.all([ + getRemoteConfigValues(), + getCurrentUserFromCookie(), + ]); + return matchesFeatureFlagBypass(currentUser?.email, config.featureFlagBypass) + ? applyAdminBypass(config) + : config; +}