From dac6ba46b9ac8bfd802d0d04c32880e5fbb82b0f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 16 Sep 2026 13:32:39 -0400 Subject: [PATCH 1/4] seal of reliability using user feature flag over firebase config --- .../[locale]/feeds/components/FeedsScreen.tsx | 8 +- src/app/components/Footer.tsx | 6 +- src/app/components/SealOfReliabilityChip.tsx | 8 +- .../hooks/useSealOfReliabilityFilterAccess.ts | 12 ++- src/app/interface/UserFeatureFlags.ts | 3 + .../ClientQualityAnalysisButton.tsx | 8 +- .../screens/Feed/components/FeedSummary.tsx | 87 +++++++++---------- src/app/screens/Feeds/AdvancedSearchTable.tsx | 6 +- src/app/screens/Feeds/SearchTable.tsx | 6 +- 9 files changed, 74 insertions(+), 70 deletions(-) diff --git a/src/app/[locale]/feeds/components/FeedsScreen.tsx b/src/app/[locale]/feeds/components/FeedsScreen.tsx index 42189c2b..25f25b19 100644 --- a/src/app/[locale]/feeds/components/FeedsScreen.tsx +++ b/src/app/[locale]/feeds/components/FeedsScreen.tsx @@ -646,9 +646,7 @@ export default function FeedsScreen(): React.ReactElement { {searchView === 'simple' ? ( ) : ( )} diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index c469fdc8..3f5d5ff6 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -9,12 +9,16 @@ import Image from 'next/image'; import { useTranslations } from 'next-intl'; import { FooterLink, FooterColumnTitle } from './FooterElements'; import { useRemoteConfig } from '../context/RemoteConfigProvider'; +import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags'; const Footer: React.FC = () => { const theme = useTheme(); const { colorScheme } = useColorScheme(); const t = useTranslations('footer'); const { config } = useRemoteConfig(); + const { + flags: { isSealEnabled }, + } = useUserFeatureFlags(); const FOOTER_COLUMN_WIDTH = '185px'; const SlackSvg = ( { {/* Resources column */} - {config.enableSealOfReliability && ( + {isSealEnabled && ( {t('columns.resources')} diff --git a/src/app/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx index 397fa956..2b5bfd87 100644 --- a/src/app/components/SealOfReliabilityChip.tsx +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -3,7 +3,7 @@ import { Box, Chip, Tooltip } from '@mui/material'; import { useTranslations } from 'next-intl'; import { Link } from '../../i18n/navigation'; import SealOfReliability, { SEAL_INK } from './SealOfReliability'; -import { useRemoteConfig } from '../context/RemoteConfigProvider'; +import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags'; export interface SealOfReliabilityChipProps { hasSeal: boolean | undefined; @@ -20,9 +20,11 @@ export default function SealOfReliabilityChip({ disableLink = false, }: SealOfReliabilityChipProps): React.ReactElement | null { const t = useTranslations('feeds'); - const { config } = useRemoteConfig(); + const { + flags: { isSealEnabled }, + } = useUserFeatureFlags(); - if (!config.enableSealOfReliability || hasSeal == undefined) { + if (!isSealEnabled || hasSeal == undefined) { return null; } diff --git a/src/app/hooks/useSealOfReliabilityFilterAccess.ts b/src/app/hooks/useSealOfReliabilityFilterAccess.ts index aff1fb1c..c9649154 100644 --- a/src/app/hooks/useSealOfReliabilityFilterAccess.ts +++ b/src/app/hooks/useSealOfReliabilityFilterAccess.ts @@ -1,9 +1,8 @@ import { useAuthSession } from '../components/AuthSessionProvider'; -import { useRemoteConfig } from '../context/RemoteConfigProvider'; import { useUserFeatureFlags } from './useUserFeatureFlags'; export interface SealOfReliabilityFilterAccess { - /** Global Remote Config switch — whether the feature is live at all. */ + /** Whether the Seal of Reliability UI is live for this user at all. */ isFeatureLive: boolean; /** * Entitlement is genuinely unknown until the user feature flags resolve — @@ -18,16 +17,15 @@ export interface SealOfReliabilityFilterAccess { } /** - * Combines the global `enableSealOfReliability` Remote Config flag with the - * per-user `isSealFilterEnabled` feature flag, so every + * Combines the per-user `isSealEnabled` feature flag with the per-user + * `isSealFilterEnabled` feature flag, so every * consumer (the search filter checkbox, the active-filter chip, and the * search fetcher) agrees on whether a given user may filter by the seal. */ export function useSealOfReliabilityFilterAccess(): SealOfReliabilityFilterAccess { - const { config } = useRemoteConfig(); const { isAuthenticated } = useAuthSession(); const { - flags: { isSealFilterEnabled }, + flags: { isSealEnabled, isSealFilterEnabled }, isResolved, } = useUserFeatureFlags(); @@ -36,7 +34,7 @@ export function useSealOfReliabilityFilterAccess(): SealOfReliabilityFilterAcces const hasAccess = !isPending && !hasNoAccess; return { - isFeatureLive: config.enableSealOfReliability, + isFeatureLive: isSealEnabled, isPending, hasAccess, hasNoAccess, diff --git a/src/app/interface/UserFeatureFlags.ts b/src/app/interface/UserFeatureFlags.ts index ee4524ca..9153254a 100644 --- a/src/app/interface/UserFeatureFlags.ts +++ b/src/app/interface/UserFeatureFlags.ts @@ -12,12 +12,15 @@ export interface UserFeatureFlags { isNotificationsEnabled: boolean; /** Enable the Seal of Reliability filter in the feeds search */ isSealFilterEnabled: boolean; + /** Enable all of the Seal of Reliability UI elements to display on the app */ + isSealEnabled: boolean; } /** Default values returned when the cookie is absent or a flag is not set for the user. */ export const defaultUserFeatureFlags: UserFeatureFlags = { isNotificationsEnabled: false, isSealFilterEnabled: false, + isSealEnabled: false, }; /** Union of all known feature flag IDs — derived from UserFeatureFlags. */ diff --git a/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx b/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx index f4c6be09..721892eb 100644 --- a/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx +++ b/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx @@ -4,7 +4,7 @@ import { Button } from '@mui/material'; import { sendGAEvent } from '@next/third-parties/google'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; -import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; +import { useUserFeatureFlags } from '../../../hooks/useUserFeatureFlags'; export default function ClientQualityAnalysisButton({ feedId, @@ -14,7 +14,9 @@ export default function ClientQualityAnalysisButton({ feedDataType: string; }): React.ReactElement | null { const t = useTranslations('feeds'); - const { config } = useRemoteConfig(); + const { + flags: { isSealEnabled }, + } = useUserFeatureFlags(); const handleViewFeedQualityAnalysisClick = (): void => { sendGAEvent('event', 'view_feed_quality_analysis', { @@ -23,7 +25,7 @@ export default function ClientQualityAnalysisButton({ }); }; - if (!config.enableSealOfReliability) { + if (!isSealEnabled) { return null; } diff --git a/src/app/screens/Feed/components/FeedSummary.tsx b/src/app/screens/Feed/components/FeedSummary.tsx index 116d3bf5..f044fab4 100644 --- a/src/app/screens/Feed/components/FeedSummary.tsx +++ b/src/app/screens/Feed/components/FeedSummary.tsx @@ -56,7 +56,7 @@ import { formatDateShort } from '../../../utils/date'; import ExternalIds from './ExternalIds'; import SealQualitySummary from './SealQualitySummary'; import SealOfReliability from '../../../components/SealOfReliability'; -import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; +import { useUserFeatureFlags } from '../../../hooks/useUserFeatureFlags'; const Locations = dynamic( async () => await import('../../../components/Locations'), @@ -87,7 +87,9 @@ export default function FeedSummary({ }: FeedSummaryProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); - const { config } = useRemoteConfig(); + const { + flags: { isSealEnabled }, + } = useUserFeatureFlags(); const theme = useTheme(); const [openLocationDetails, setOpenLocationDetails] = useState< 'summary' | 'fullList' | undefined @@ -640,49 +642,46 @@ export default function FeedSummary({ )} - {isGtfsFeedType(feed) && - config.enableSealOfReliability && - reliability != null && ( - - {feed.reliability_seal?.has_seal === true && ( - - - - )} - - - - - {t('sealOfReliabilityAlt')} - + {feed.reliability_seal?.has_seal === true && ( + + + + )} + + + + + {t('sealOfReliabilityAlt')} + + - - - - - - - - )} + + + + + + + )} {latestDataset?.validation_report?.features != undefined && latestDataset?.validation_report?.features.length > 0 && ( diff --git a/src/app/screens/Feeds/AdvancedSearchTable.tsx b/src/app/screens/Feeds/AdvancedSearchTable.tsx index 9af972fd..bac9d408 100644 --- a/src/app/screens/Feeds/AdvancedSearchTable.tsx +++ b/src/app/screens/Feeds/AdvancedSearchTable.tsx @@ -37,7 +37,7 @@ export interface AdvancedSearchTableProps { selectedGbfsVersions: string[] | undefined; selectedLicenseTags: string[] | undefined; isLoadingFeeds: boolean; - enableSealOfReliability?: boolean; + isSealEnabled?: boolean; } interface DetailsContainerProps { @@ -223,7 +223,7 @@ export default function AdvancedSearchTable({ selectedGbfsVersions, selectedLicenseTags, isLoadingFeeds, - enableSealOfReliability = false, + isSealEnabled = false, }: AdvancedSearchTableProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); @@ -340,7 +340,7 @@ export default function AdvancedSearchTable({ > - {enableSealOfReliability && + {isSealEnabled && feed.reliability_seal?.has_seal === true && ( )} diff --git a/src/app/screens/Feeds/SearchTable.tsx b/src/app/screens/Feeds/SearchTable.tsx index 15a6bccf..2e990d4b 100644 --- a/src/app/screens/Feeds/SearchTable.tsx +++ b/src/app/screens/Feeds/SearchTable.tsx @@ -30,7 +30,7 @@ import ProviderTitle from './ProviderTitle'; export interface SearchTableProps { feedsData: AllFeedsType | undefined; - enableSealOfReliability?: boolean; + isSealEnabled?: boolean; } const HeaderTableCell = styled(TableCell)(() => ({ @@ -71,7 +71,7 @@ export const getDataTypeElement = ( export default function SearchTable({ feedsData, - enableSealOfReliability = false, + isSealEnabled = false, }: SearchTableProps): React.ReactElement { const theme = useTheme(); const router = useRouter(); @@ -227,7 +227,7 @@ export default function SearchTable({ gap: 0.5, }} > - {enableSealOfReliability && + {isSealEnabled && feed.reliability_seal?.has_seal === true && ( )} From 26a3369e83c057039efffc7bcc75686fa32f7abb Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 16 Sep 2026 13:43:21 -0400 Subject: [PATCH 2/4] route guarding the seal of reliability detail page --- .../authed/seal-of-reliability/layout.tsx | 47 ++++++++++ .../static/seal-of-reliability/page.tsx | 90 ++++++++++--------- 2 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx new file mode 100644 index 00000000..61ae83e1 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { type ReactElement, type ReactNode } from 'react'; +import { notFound } from 'next/navigation'; +import { useUserFeatureFlags } from '../../../../../../hooks/useUserFeatureFlags'; +import SealReliabilitySkeleton from '../../components/SealReliabilitySkeleton'; + +interface Props { + children: ReactNode; +} + +/** + * Entitlement gate for the Seal of Reliability breakdown page. + * + * `isSealEnabled` is a per-user flag with no server-side read by design (see + * docs/user-feature-flags.md), so this is the only place in the route where + * its value exists. The guest half of the route is blocked server-side + * instead — see the sibling `static/seal-of-reliability/page.tsx`. + * + * NOTE: This is a UX gate, not a security boundary. `children` is rendered on + * the server regardless, so an unentitled user can still read the seal data + * out of the RSC payload. If that matters, the feed API is the place to + * enforce it — it already receives `x-mdb-user-context` on these calls, and + * this segment's `error.tsx` handles the rejection. + * + * Once the seal feature fully rolls out, this route guard will no longer be needed + */ +export default function AuthedFeedReliabilityLayout({ + children, +}: Props): ReactElement { + const { + flags: { isSealEnabled }, + isResolved, + } = useUserFeatureFlags(); + + // Pending is not the same as not-entitled: until the flags resolve the + // value is a placeholder, so render the loading state rather than a 404. + if (!isResolved) { + return ; + } + + if (!isSealEnabled) { + notFound(); + } + + return <>{children}; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index 8eb348e3..2ddbb5e0 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -52,55 +52,59 @@ export async function generateMetadata( export default async function StaticFeedReliabilityPage({ params, }: Props): Promise { - const { feedId, feedDataType } = await params; + // Since this page is only accessible through a user feature flag + // It will temporarily return a not found immediately for any not + // authed users + notFound(); + // const { feedId, feedDataType } = await params; - // Seal of Reliability only exists for GTFS Schedule feeds (see - // isSealAnalysisApplicable in lib/seal-analysis-data.ts). - if (feedDataType !== 'gtfs') { - throw new Error( - `Seal of Reliability is not available for data type ${feedDataType}`, - ); - } + // // Seal of Reliability only exists for GTFS Schedule feeds (see + // // isSealAnalysisApplicable in lib/seal-analysis-data.ts). + // if (feedDataType !== 'gtfs') { + // throw new Error( + // `Seal of Reliability is not available for data type ${feedDataType}`, + // ); + // } - // Settled rather than all-or-nothing: the two requests fail for unrelated - // reasons and need unrelated responses. A missing feed is a 404; a seal - // loader that can't mint a token or reach its cache is a reliability error - // on a page that does exist. - const [feedResult, sealResult] = await Promise.allSettled([ - fetchGuestFeedData(feedDataType, feedId), - fetchGuestSealAnalysisData(feedDataType, feedId), - ]); + // // Settled rather than all-or-nothing: the two requests fail for unrelated + // // reasons and need unrelated responses. A missing feed is a 404; a seal + // // loader that can't mint a token or reach its cache is a reliability error + // // on a page that does exist. + // const [feedResult, sealResult] = await Promise.allSettled([ + // fetchGuestFeedData(feedDataType, feedId), + // fetchGuestSealAnalysisData(feedDataType, feedId), + // ]); - if (feedResult.status === 'rejected') { - // Layout should have caught non-existent feeds, but handle edge case - console.error( - `[StaticFeedReliabilityPage] Failed to fetch feed ${feedId}:`, - feedResult.reason, - ); - notFound(); - } + // if (feedResult.status === 'rejected') { + // // Layout should have caught non-existent feeds, but handle edge case + // console.error( + // `[StaticFeedReliabilityPage] Failed to fetch feed ${feedId}:`, + // feedResult.reason, + // ); + // notFound(); + // } - // Rethrown as-is so this segment's error.tsx renders the full-page - // reliability error, and the original cause keeps its stack. - if (sealResult.status === 'rejected') { - throw sealResult.reason; - } + // // Rethrown as-is so this segment's error.tsx renders the full-page + // // reliability error, and the original cause keeps its stack. + // if (sealResult.status === 'rejected') { + // throw sealResult.reason; + // } - const sealAnalysis = sealResult.value; + // const sealAnalysis = sealResult.value; - if (sealAnalysis?.reliabilityError === true) { - throw new Error( - `Failed to load Seal of Reliability data for feed ${feedId}`, - ); - } + // if (sealAnalysis?.reliabilityError === true) { + // throw new Error( + // `Failed to load Seal of Reliability data for feed ${feedId}`, + // ); + // } - const { feed, initialDatasets } = feedResult.value; + // const { feed, initialDatasets } = feedResult.value; - return ( - - ); + // return ( + // + // ); } From 4f54f7d1c69fe976ead71c82b1389fbc40194b49 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 16 Sep 2026 14:05:40 -0400 Subject: [PATCH 3/4] lint fix --- .../[feedId]/authed/seal-of-reliability/layout.tsx | 2 +- .../[feedId]/static/seal-of-reliability/page.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx index 61ae83e1..acf6ffca 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/layout.tsx @@ -22,7 +22,7 @@ interface Props { * out of the RSC payload. If that matters, the feed API is the place to * enforce it — it already receives `x-mdb-user-context` on these calls, and * this segment's `error.tsx` handles the rejection. - * + * * Once the seal feature fully rolls out, this route guard will no longer be needed */ export default function AuthedFeedReliabilityLayout({ diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index 2ddbb5e0..1a6abfd5 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -1,12 +1,12 @@ -import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; +// import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; import { type ReactElement } from 'react'; import { notFound } from 'next/navigation'; import type { Metadata, ResolvingMetadata } from 'next'; import { getTranslations } from 'next-intl/server'; import { fetchGuestFeedData } from '../../lib/guest-feed-data'; import { generateSealFeedMetadata } from '../../lib/generate-feed-metadata'; -import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data'; -import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; +// import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data'; +// import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; interface Props { params: Promise<{ feedDataType: string; feedId: string }>; @@ -53,7 +53,7 @@ export default async function StaticFeedReliabilityPage({ params, }: Props): Promise { // Since this page is only accessible through a user feature flag - // It will temporarily return a not found immediately for any not + // It will temporarily return a not found immediately for any not // authed users notFound(); // const { feedId, feedDataType } = await params; From 428b34a632702827ed3d5e7aadbb0b052fdf93c6 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 16 Sep 2026 14:04:59 -0400 Subject: [PATCH 4/4] test fix --- cypress/e2e/userFeatureFlags.cy.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/userFeatureFlags.cy.ts b/cypress/e2e/userFeatureFlags.cy.ts index 1f455096..fe18e176 100644 --- a/cypress/e2e/userFeatureFlags.cy.ts +++ b/cypress/e2e/userFeatureFlags.cy.ts @@ -30,6 +30,7 @@ const TEST_FEED_URL = '/feeds/gtfs/test-516'; const ALL_DEFAULTS = { isNotificationsEnabled: false, isSealFilterEnabled: false, + isSealEnabled: false, }; interface MockFeature { @@ -149,10 +150,7 @@ describe('User Feature Flags', () => { cy.visit('/'); - expectResolvedFlags({ - isNotificationsEnabled: false, - isSealFilterEnabled: true, - }); + expectResolvedFlags({ ...ALL_DEFAULTS, isSealFilterEnabled: true }); }); it('resolves to defaults when the profile carries no flags', () => {