From 112406bcf6f7e857108a0ca7e8929fff49f7c313 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 10 Jun 2026 12:58:04 -0600 Subject: [PATCH 1/3] Warn on default Firebase config --- src/components/ErrorLoadingConfig.tsx | 1 + src/components/interface/AppHeader.tsx | 11 +++ .../interface/tests/AppHeader.spec.tsx | 24 +++++- .../tests/ErrorLoadingConfig.spec.tsx | 23 ++++++ src/parser/parser.ts | 27 +++++-- src/parser/tests/parser.spec.ts | 73 +++++++++++++++++- src/parser/types.ts | 2 +- src/utils/defaultFirebaseConfig.ts | 67 ++++++++++++++++ src/utils/tests/defaultFirebaseConfig.spec.ts | 76 +++++++++++++++++++ 9 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 src/utils/defaultFirebaseConfig.ts create mode 100644 src/utils/tests/defaultFirebaseConfig.spec.ts diff --git a/src/components/ErrorLoadingConfig.tsx b/src/components/ErrorLoadingConfig.tsx index 204f689db9..d20d4d9a4d 100644 --- a/src/components/ErrorLoadingConfig.tsx +++ b/src/components/ErrorLoadingConfig.tsx @@ -26,6 +26,7 @@ export function ErrorLoadingConfig({ 'unused-component', 'disabled-sidebar', 'default-contact-email', + 'default-firebase-config', ]; // Format category labels by capitalizing each word and replacing hyphens with spaces diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 138d95840a..06327835a2 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -21,6 +21,7 @@ import { IconMicrophone, IconMicrophoneOff, IconSchema, + IconAlertTriangle, IconUserPlus, } from '@tabler/icons-react'; import { @@ -42,6 +43,10 @@ import { hideNotification, showNotification } from '../../utils/notifications'; import { getMutedInstruction } from '../../utils/recordingWarnings'; import classes from './AppHeader.module.css'; import { useDeviceRules } from '../../utils/useDeviceRules'; +import { + DEFAULT_FIREBASE_WARNING_MESSAGE, + shouldWarnForDefaultFirebaseConfig, +} from '../../utils/defaultFirebaseConfig'; export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { developmentModeEnabled: boolean; dataCollectionEnabled: boolean }) { const studyConfig = useStoreSelector((state) => state.config); @@ -110,6 +115,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d } = useDeviceRules(studyConfig.studyRules); const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); + const showDefaultFirebaseWarning = shouldWarnForDefaultFirebaseConfig(); const isScreenRecordingPermission = currentComponent === '$screen-recording.components.screenRecordingPermission'; const showAudioStatus = currentComponentHasAudioRecording || isAudioRecording @@ -246,6 +252,11 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d )} {storageEngineFailedToConnect && Storage Disconnected} + {showDefaultFirebaseWarning && ( + + }>Default Firebase + + )} {!storageEngineFailedToConnect && !dataCollectionEnabled && Demo Mode} {hasUnmetDeviceRequirement && developmentModeEnabled && Device Requirement Not Met} {studyConfig?.uiConfig.helpTextPath !== undefined && ( diff --git a/src/components/interface/tests/AppHeader.spec.tsx b/src/components/interface/tests/AppHeader.spec.tsx index eae9b433a7..ea519c505f 100644 --- a/src/components/interface/tests/AppHeader.spec.tsx +++ b/src/components/interface/tests/AppHeader.spec.tsx @@ -78,6 +78,7 @@ vi.mock('@mantine/core', () => ({ })); vi.mock('@tabler/icons-react', () => ({ + IconAlertTriangle: () => null, IconChartHistogram: () => null, IconDotsVertical: () => null, IconMail: () => null, @@ -164,7 +165,12 @@ vi.mock('../../../utils/recordingWarnings', () => ({ describe('AppHeader interactive', () => { beforeEach(() => { mockStorageEngineFailedToConnect = false; }); - afterEach(() => { cleanup(); vi.useRealTimers(); }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); test('covers storageEngineFailedToConnect effect: setTimeout setup and callback', async () => { mockStorageEngineFailedToConnect = true; @@ -185,6 +191,9 @@ describe('AppHeader interactive', () => { describe('AppHeader', () => { beforeEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + mockStorageEngineFailedToConnect = false; mockedCurrentComponent = 'componentA'; mockedRecordingContext = { isScreenRecording: false, @@ -223,8 +232,7 @@ describe('AppHeader', () => { const html = renderToStaticMarkup( , ); - // With no storageEngine connection, shows disconnected state - expect(html).toContain('Storage Disconnected'); + expect(html).toContain('Demo Mode'); // Dev mode controls should not appear expect(html).not.toContain('Study Browser'); }); @@ -296,4 +304,14 @@ describe('AppHeader', () => { expect(html).toContain('Recording permission denied'); }); + + test('shows default Firebase warning badge on direct study pages', () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const html = renderToStaticMarkup(); + + expect(html).toContain('Default Firebase'); + }); }); diff --git a/src/components/tests/ErrorLoadingConfig.spec.tsx b/src/components/tests/ErrorLoadingConfig.spec.tsx index bdf2548963..17b7fdafc9 100644 --- a/src/components/tests/ErrorLoadingConfig.spec.tsx +++ b/src/components/tests/ErrorLoadingConfig.spec.tsx @@ -105,4 +105,27 @@ describe('ErrorLoadingConfig', () => { expect(textOnly).toContain('Default Contact Email'); expect(textOnly).toContain('contact@revisit.dev'); }); + + test('renders default-firebase-config warning category and message', () => { + const issues: ParsedConfig['errors'] = [ + { + category: 'default-firebase-config', + instancePath: 'environment/VITE_FIREBASE_CONFIG', + message: 'This study is connected to ReVISit\'s default Firebase project. Participant data may not be saved to a backend controlled by the study designer.', + params: { + action: 'Set VITE_FIREBASE_CONFIG to a Firebase project controlled by the study designer or choose another storage engine', + }, + }, + ]; + + const html = renderToStaticMarkup( + + + , + ); + const textOnly = html.replace(/<[^>]*>/g, ' '); + + expect(textOnly).toContain('Default Firebase Config'); + expect(textOnly).toContain('default Firebase project'); + }); }); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 9f75a38940..14174f8a38 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -8,6 +8,14 @@ import { import { getSequenceFlatMapWithInterruptions } from '../utils/getSequenceFlatMap'; import { expandLibrarySequences, loadLibrariesParseNamespace, verifyLibraryUsage } from './libraryParser'; import { isDynamicBlock, isInheritedComponent } from './utils'; +import { + DEFAULT_CONTACT_EMAIL, + DEFAULT_FIREBASE_WARNING_ACTION, + DEFAULT_FIREBASE_WARNING_MESSAGE, + getCurrentHostname, + shouldSuppressDefaultDeploymentWarnings, + shouldWarnForDefaultFirebaseConfig, +} from '../utils/defaultFirebaseConfig'; const ajv1 = new Ajv({ allowUnionTypes: true }); ajv1.addSchema(globalSchema); @@ -165,14 +173,9 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco }); } - // Warn if the default contact email is left in the config and the study is not hosted on a known ReVISit domain - const DEFAULT_CONTACT_EMAIL = 'contact@revisit.dev'; - const REVISIT_DOMAINS = ['revisit.dev', 'vdl.sci.utah.edu']; - const LOCAL_DEVELOPMENT_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']); - const hostname = typeof window !== 'undefined' ? window.location.hostname : ''; - const isRevisitDomain = REVISIT_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); - const isLocalDevelopment = LOCAL_DEVELOPMENT_HOSTNAMES.has(hostname); - if (studyConfig.uiConfig.contactEmail === DEFAULT_CONTACT_EMAIL && !isRevisitDomain && !isLocalDevelopment) { + // Warn if deployment defaults are left in place outside known ReVISit/local hosts. + const hostname = getCurrentHostname(); + if (studyConfig.uiConfig.contactEmail === DEFAULT_CONTACT_EMAIL && !shouldSuppressDefaultDeploymentWarnings(hostname)) { warnings.push({ message: `The contact email is set to the default value \`${DEFAULT_CONTACT_EMAIL}\`. Please update it to your own email address.`, instancePath: '/uiConfig/contactEmail', @@ -180,6 +183,14 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco category: 'default-contact-email', }); } + if (shouldWarnForDefaultFirebaseConfig({ hostname })) { + warnings.push({ + message: DEFAULT_FIREBASE_WARNING_MESSAGE, + instancePath: 'environment/VITE_FIREBASE_CONFIG', + params: { action: DEFAULT_FIREBASE_WARNING_ACTION }, + category: 'default-firebase-config', + }); + } // Verify components are well defined Object.entries(studyConfig.components) diff --git a/src/parser/tests/parser.spec.ts b/src/parser/tests/parser.spec.ts index deb69c28da..88989f2ee1 100644 --- a/src/parser/tests/parser.spec.ts +++ b/src/parser/tests/parser.spec.ts @@ -1,11 +1,16 @@ import { - describe, expect, test, vi, + afterEach, describe, expect, test, vi, } from 'vitest'; import { parseStudyConfig } from '../parser'; import { isDynamicBlock } from '../utils'; global.fetch = vi.fn(); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + function mockFetchText(body: string) { return { text: () => Promise.resolve(body) } as Response; } @@ -1478,4 +1483,70 @@ describe('Parser Warnings', () => { expect(contactEmailWarning).toBeUndefined(); }); + + test('adds default-firebase-config warning for the default Firebase project on a custom host', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + const defaultFirebaseWarning = result.warnings.find( + (warning) => warning.category === 'default-firebase-config', + ); + + expect(defaultFirebaseWarning).toBeDefined(); + expect(defaultFirebaseWarning?.instancePath).toBe('environment/VITE_FIREBASE_CONFIG'); + expect(defaultFirebaseWarning?.message).toContain('default Firebase project'); + expect(defaultFirebaseWarning?.message).toContain('backend controlled by the study designer'); + }); + + test('adds default-firebase-config warning when authDomain identifies the default Firebase project', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ authDomain: 'revisit-utah.firebaseapp.com' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(true); + }); + + test('does not add default-firebase-config warning for a custom Firebase project', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'research-owned-project' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false); + }); + + test.each([ + ['supabase'], + ['localStorage'], + ])('does not add default-firebase-config warning when storage engine is %s', async (storageEngine) => { + vi.stubEnv('VITE_STORAGE_ENGINE', storageEngine); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false); + }); + + test.each([ + ['localhost'], + ['revisit.dev'], + ['study.revisit.dev'], + ['vdl.sci.utah.edu'], + ['study.vdl.sci.utah.edu'], + ])('does not add default-firebase-config warning on %s', async (hostname) => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'revisit-utah' })); + vi.stubGlobal('window', { location: { hostname } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false); + }); }); diff --git a/src/parser/types.ts b/src/parser/types.ts index 8639a87cf7..08077a5507 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1917,7 +1917,7 @@ export interface LibraryConfig { baseComponents?: BaseComponents; } -export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email'; +export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email' | 'default-firebase-config'; /** * @ignore diff --git a/src/utils/defaultFirebaseConfig.ts b/src/utils/defaultFirebaseConfig.ts new file mode 100644 index 0000000000..9b856ed17d --- /dev/null +++ b/src/utils/defaultFirebaseConfig.ts @@ -0,0 +1,67 @@ +import { parse as hjsonParse } from 'hjson'; + +export const DEFAULT_CONTACT_EMAIL = 'contact@revisit.dev'; +export const DEFAULT_FIREBASE_PROJECT_ID = 'revisit-utah'; +export const DEFAULT_FIREBASE_AUTH_DOMAIN = 'revisit-utah.firebaseapp.com'; + +export const DEFAULT_FIREBASE_WARNING_MESSAGE = 'This study is connected to ReVISit\'s default Firebase project. Participant data may not be saved to a backend controlled by the study designer.'; +export const DEFAULT_FIREBASE_WARNING_ACTION = 'Set VITE_FIREBASE_CONFIG to a Firebase project controlled by the study designer or choose another storage engine'; + +const REVISIT_DOMAINS = ['revisit.dev', 'vdl.sci.utah.edu']; +const LOCAL_DEVELOPMENT_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']); + +type FirebaseConfig = { + projectId?: unknown; + authDomain?: unknown; +}; + +export function getCurrentHostname() { + return typeof window !== 'undefined' ? window.location.hostname : ''; +} + +export function isLocalDevelopmentHostname(hostname: string) { + return LOCAL_DEVELOPMENT_HOSTNAMES.has(hostname); +} + +export function isRevisitControlledHostname(hostname: string) { + return REVISIT_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); +} + +export function shouldSuppressDefaultDeploymentWarnings(hostname = getCurrentHostname()) { + return isLocalDevelopmentHostname(hostname) || isRevisitControlledHostname(hostname); +} + +export function parseFirebaseConfig(firebaseConfigText: string | undefined) { + if (!firebaseConfigText) { + return null; + } + + try { + return hjsonParse(firebaseConfigText) as unknown; + } catch { + return null; + } +} + +export function isDefaultRevisitFirebaseConfig(firebaseConfig: unknown) { + if (!firebaseConfig || typeof firebaseConfig !== 'object') { + return false; + } + + const { projectId, authDomain } = firebaseConfig as FirebaseConfig; + return projectId === DEFAULT_FIREBASE_PROJECT_ID || authDomain === DEFAULT_FIREBASE_AUTH_DOMAIN; +} + +export function shouldWarnForDefaultFirebaseConfig({ + storageEngine = import.meta.env.VITE_STORAGE_ENGINE, + firebaseConfigText = import.meta.env.VITE_FIREBASE_CONFIG, + hostname = getCurrentHostname(), +}: { + storageEngine?: string; + firebaseConfigText?: string; + hostname?: string; +} = {}) { + return storageEngine === 'firebase' + && !shouldSuppressDefaultDeploymentWarnings(hostname) + && isDefaultRevisitFirebaseConfig(parseFirebaseConfig(firebaseConfigText)); +} diff --git a/src/utils/tests/defaultFirebaseConfig.spec.ts b/src/utils/tests/defaultFirebaseConfig.spec.ts new file mode 100644 index 0000000000..52e5416989 --- /dev/null +++ b/src/utils/tests/defaultFirebaseConfig.spec.ts @@ -0,0 +1,76 @@ +import { + afterEach, describe, expect, test, vi, +} from 'vitest'; +import { + DEFAULT_FIREBASE_AUTH_DOMAIN, + DEFAULT_FIREBASE_PROJECT_ID, + isDefaultRevisitFirebaseConfig, + isLocalDevelopmentHostname, + isRevisitControlledHostname, + parseFirebaseConfig, + shouldSuppressDefaultDeploymentWarnings, + shouldWarnForDefaultFirebaseConfig, +} from '../defaultFirebaseConfig'; + +describe('defaultFirebaseConfig', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('detects the default Firebase project by projectId or authDomain', () => { + expect(isDefaultRevisitFirebaseConfig({ projectId: DEFAULT_FIREBASE_PROJECT_ID })).toBe(true); + expect(isDefaultRevisitFirebaseConfig({ authDomain: DEFAULT_FIREBASE_AUTH_DOMAIN })).toBe(true); + expect(isDefaultRevisitFirebaseConfig({ projectId: 'custom-project', authDomain: 'custom.firebaseapp.com' })).toBe(false); + }); + + test('parses JSON and HJSON Firebase config text', () => { + expect(parseFirebaseConfig(JSON.stringify({ projectId: DEFAULT_FIREBASE_PROJECT_ID }))).toEqual({ + projectId: DEFAULT_FIREBASE_PROJECT_ID, + }); + expect(parseFirebaseConfig(`{ projectId: "${DEFAULT_FIREBASE_PROJECT_ID}" }`)).toEqual({ + projectId: DEFAULT_FIREBASE_PROJECT_ID, + }); + expect(parseFirebaseConfig('{')).toBeNull(); + }); + + test('identifies local and ReVISit-controlled hosts', () => { + expect(isLocalDevelopmentHostname('localhost')).toBe(true); + expect(isLocalDevelopmentHostname('127.0.0.1')).toBe(true); + expect(isRevisitControlledHostname('revisit.dev')).toBe(true); + expect(isRevisitControlledHostname('study.revisit.dev')).toBe(true); + expect(isRevisitControlledHostname('vdl.sci.utah.edu')).toBe(true); + expect(isRevisitControlledHostname('study.vdl.sci.utah.edu')).toBe(true); + expect(shouldSuppressDefaultDeploymentWarnings('example.com')).toBe(false); + }); + + test('warns only for the default Firebase project on non-controlled hosts', () => { + const firebaseConfigText = JSON.stringify({ projectId: DEFAULT_FIREBASE_PROJECT_ID }); + expect(shouldWarnForDefaultFirebaseConfig({ + storageEngine: 'firebase', + firebaseConfigText, + hostname: 'study.example.com', + })).toBe(true); + expect(shouldWarnForDefaultFirebaseConfig({ + storageEngine: 'firebase', + firebaseConfigText: JSON.stringify({ projectId: 'research-project' }), + hostname: 'study.example.com', + })).toBe(false); + expect(shouldWarnForDefaultFirebaseConfig({ + storageEngine: 'supabase', + firebaseConfigText, + hostname: 'study.example.com', + })).toBe(false); + expect(shouldWarnForDefaultFirebaseConfig({ + storageEngine: 'firebase', + firebaseConfigText, + hostname: 'localhost', + })).toBe(false); + }); + + test('uses Vite env values when no explicit options are provided', () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ authDomain: DEFAULT_FIREBASE_AUTH_DOMAIN })); + + expect(shouldWarnForDefaultFirebaseConfig({ hostname: 'study.example.com' })).toBe(true); + }); +}); From 85aba38b6ea696dee2696ca6a5a3739771560220 Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Wed, 10 Jun 2026 23:42:55 -0600 Subject: [PATCH 2/3] Address default storage warning feedback --- src/components/ErrorLoadingConfig.tsx | 1 + src/components/interface/AppHeader.tsx | 11 ++++- .../interface/tests/AppHeader.spec.tsx | 11 ++++- .../tests/ErrorLoadingConfig.spec.tsx | 23 +++++++++ src/parser/parser.ts | 11 +++++ src/parser/tests/parser.spec.ts | 47 +++++++++++++++++++ src/parser/types.ts | 2 +- src/utils/defaultFirebaseConfig.ts | 41 +++++++++++++++- src/utils/tests/defaultFirebaseConfig.spec.ts | 42 ++++++++++++++++- 9 files changed, 181 insertions(+), 8 deletions(-) diff --git a/src/components/ErrorLoadingConfig.tsx b/src/components/ErrorLoadingConfig.tsx index d20d4d9a4d..3b8e22a064 100644 --- a/src/components/ErrorLoadingConfig.tsx +++ b/src/components/ErrorLoadingConfig.tsx @@ -27,6 +27,7 @@ export function ErrorLoadingConfig({ 'disabled-sidebar', 'default-contact-email', 'default-firebase-config', + 'default-supabase-config', ]; // Format category labels by capitalizing each word and replacing hyphens with spaces diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 06327835a2..d2e05683b7 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -21,7 +21,6 @@ import { IconMicrophone, IconMicrophoneOff, IconSchema, - IconAlertTriangle, IconUserPlus, } from '@tabler/icons-react'; import { @@ -45,7 +44,9 @@ import classes from './AppHeader.module.css'; import { useDeviceRules } from '../../utils/useDeviceRules'; import { DEFAULT_FIREBASE_WARNING_MESSAGE, + DEFAULT_SUPABASE_WARNING_MESSAGE, shouldWarnForDefaultFirebaseConfig, + shouldWarnForDefaultSupabaseConfig, } from '../../utils/defaultFirebaseConfig'; export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { developmentModeEnabled: boolean; dataCollectionEnabled: boolean }) { @@ -116,6 +117,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const hasUnmetDeviceRequirement = developmentModeEnabled && (!isBrowserAllowed || !isDeviceAllowed || !isInputAllowed || !isDisplayAllowed); const showDefaultFirebaseWarning = shouldWarnForDefaultFirebaseConfig(); + const showDefaultSupabaseWarning = shouldWarnForDefaultSupabaseConfig(); const isScreenRecordingPermission = currentComponent === '$screen-recording.components.screenRecordingPermission'; const showAudioStatus = currentComponentHasAudioRecording || isAudioRecording @@ -254,7 +256,12 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d {storageEngineFailedToConnect && Storage Disconnected} {showDefaultFirebaseWarning && ( - }>Default Firebase + Default Firebase + + )} + {showDefaultSupabaseWarning && ( + + Default Supabase )} {!storageEngineFailedToConnect && !dataCollectionEnabled && Demo Mode} diff --git a/src/components/interface/tests/AppHeader.spec.tsx b/src/components/interface/tests/AppHeader.spec.tsx index ea519c505f..c03f2e678a 100644 --- a/src/components/interface/tests/AppHeader.spec.tsx +++ b/src/components/interface/tests/AppHeader.spec.tsx @@ -78,7 +78,6 @@ vi.mock('@mantine/core', () => ({ })); vi.mock('@tabler/icons-react', () => ({ - IconAlertTriangle: () => null, IconChartHistogram: () => null, IconDotsVertical: () => null, IconMail: () => null, @@ -314,4 +313,14 @@ describe('AppHeader', () => { expect(html).toContain('Default Firebase'); }); + + test('shows default Supabase warning badge on direct study pages', () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev'); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const html = renderToStaticMarkup(); + + expect(html).toContain('Default Supabase'); + }); }); diff --git a/src/components/tests/ErrorLoadingConfig.spec.tsx b/src/components/tests/ErrorLoadingConfig.spec.tsx index 17b7fdafc9..89f9658d36 100644 --- a/src/components/tests/ErrorLoadingConfig.spec.tsx +++ b/src/components/tests/ErrorLoadingConfig.spec.tsx @@ -128,4 +128,27 @@ describe('ErrorLoadingConfig', () => { expect(textOnly).toContain('Default Firebase Config'); expect(textOnly).toContain('default Firebase project'); }); + + test('renders default-supabase-config warning category and message', () => { + const issues: ParsedConfig['errors'] = [ + { + category: 'default-supabase-config', + instancePath: 'environment/VITE_SUPABASE_URL', + message: 'This study is connected to ReVISit\'s default Supabase project. Participant data may not be saved to a backend controlled by the study designer.', + params: { + action: 'Set VITE_SUPABASE_URL to a Supabase project controlled by the study designer or choose another storage engine', + }, + }, + ]; + + const html = renderToStaticMarkup( + + + , + ); + const textOnly = html.replace(/<[^>]*>/g, ' '); + + expect(textOnly).toContain('Default Supabase Config'); + expect(textOnly).toContain('default Supabase project'); + }); }); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 14174f8a38..4965340d34 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -12,9 +12,12 @@ import { DEFAULT_CONTACT_EMAIL, DEFAULT_FIREBASE_WARNING_ACTION, DEFAULT_FIREBASE_WARNING_MESSAGE, + DEFAULT_SUPABASE_WARNING_ACTION, + DEFAULT_SUPABASE_WARNING_MESSAGE, getCurrentHostname, shouldSuppressDefaultDeploymentWarnings, shouldWarnForDefaultFirebaseConfig, + shouldWarnForDefaultSupabaseConfig, } from '../utils/defaultFirebaseConfig'; const ajv1 = new Ajv({ allowUnionTypes: true }); @@ -191,6 +194,14 @@ function verifyStudyConfig(studyConfig: StudyConfig, importedLibrariesData: Reco category: 'default-firebase-config', }); } + if (shouldWarnForDefaultSupabaseConfig({ hostname })) { + warnings.push({ + message: DEFAULT_SUPABASE_WARNING_MESSAGE, + instancePath: 'environment/VITE_SUPABASE_URL', + params: { action: DEFAULT_SUPABASE_WARNING_ACTION }, + category: 'default-supabase-config', + }); + } // Verify components are well defined Object.entries(studyConfig.components) diff --git a/src/parser/tests/parser.spec.ts b/src/parser/tests/parser.spec.ts index 88989f2ee1..b9783daeb6 100644 --- a/src/parser/tests/parser.spec.ts +++ b/src/parser/tests/parser.spec.ts @@ -1511,6 +1511,16 @@ describe('Parser Warnings', () => { expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(true); }); + test('adds default-firebase-config warning when storageBucket identifies the default Firebase project', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ storageBucket: 'revisit-utah.appspot.com' })); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(true); + }); + test('does not add default-firebase-config warning for a custom Firebase project', async () => { vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); vi.stubEnv('VITE_FIREBASE_CONFIG', JSON.stringify({ projectId: 'research-owned-project' })); @@ -1549,4 +1559,41 @@ describe('Parser Warnings', () => { expect(result.warnings.some((warning) => warning.category === 'default-firebase-config')).toBe(false); }); + + test('adds default-supabase-config warning when Supabase URL is a revisit.dev domain on a custom host', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev'); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + const defaultSupabaseWarning = result.warnings.find( + (warning) => warning.category === 'default-supabase-config', + ); + + expect(defaultSupabaseWarning).toBeDefined(); + expect(defaultSupabaseWarning?.instancePath).toBe('environment/VITE_SUPABASE_URL'); + expect(defaultSupabaseWarning?.message).toContain('default Supabase project'); + expect(defaultSupabaseWarning?.message).toContain('backend controlled by the study designer'); + }); + + test('does not add default-supabase-config warning for a custom Supabase URL', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + vi.stubEnv('VITE_SUPABASE_URL', 'https://research-project.supabase.co'); + vi.stubGlobal('window', { location: { hostname: 'study.example.com' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-supabase-config')).toBe(false); + }); + + test('does not add default-supabase-config warning on local or ReVISit-controlled hosts', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + vi.stubEnv('VITE_SUPABASE_URL', 'https://supabase.revisit.dev'); + vi.stubGlobal('window', { location: { hostname: 'revisit.dev' } }); + + const result = await parseStudyConfig(JSON.stringify(buildContactEmailStudyConfig('researcher@university.edu'))); + + expect(result.warnings.some((warning) => warning.category === 'default-supabase-config')).toBe(false); + }); }); diff --git a/src/parser/types.ts b/src/parser/types.ts index 08077a5507..8c81c80d31 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -1917,7 +1917,7 @@ export interface LibraryConfig { baseComponents?: BaseComponents; } -export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email' | 'default-firebase-config'; +export type ErrorWarningCategory = 'invalid-config' | 'invalid-library-config' | 'undefined-library' | 'undefined-base-component' | 'undefined-component' | 'sequence-validation' | 'skip-validation' | 'unused-component' | 'disabled-sidebar' | 'default-contact-email' | 'default-firebase-config' | 'default-supabase-config'; /** * @ignore diff --git a/src/utils/defaultFirebaseConfig.ts b/src/utils/defaultFirebaseConfig.ts index 9b856ed17d..be143dfb79 100644 --- a/src/utils/defaultFirebaseConfig.ts +++ b/src/utils/defaultFirebaseConfig.ts @@ -3,16 +3,21 @@ import { parse as hjsonParse } from 'hjson'; export const DEFAULT_CONTACT_EMAIL = 'contact@revisit.dev'; export const DEFAULT_FIREBASE_PROJECT_ID = 'revisit-utah'; export const DEFAULT_FIREBASE_AUTH_DOMAIN = 'revisit-utah.firebaseapp.com'; +export const DEFAULT_FIREBASE_STORAGE_BUCKET = 'revisit-utah.appspot.com'; export const DEFAULT_FIREBASE_WARNING_MESSAGE = 'This study is connected to ReVISit\'s default Firebase project. Participant data may not be saved to a backend controlled by the study designer.'; export const DEFAULT_FIREBASE_WARNING_ACTION = 'Set VITE_FIREBASE_CONFIG to a Firebase project controlled by the study designer or choose another storage engine'; +export const DEFAULT_SUPABASE_WARNING_MESSAGE = 'This study is connected to ReVISit\'s default Supabase project. Participant data may not be saved to a backend controlled by the study designer.'; +export const DEFAULT_SUPABASE_WARNING_ACTION = 'Set VITE_SUPABASE_URL to a Supabase project controlled by the study designer or choose another storage engine'; const REVISIT_DOMAINS = ['revisit.dev', 'vdl.sci.utah.edu']; +const REVISIT_DEV_DOMAIN = 'revisit.dev'; const LOCAL_DEVELOPMENT_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']); type FirebaseConfig = { projectId?: unknown; authDomain?: unknown; + storageBucket?: unknown; }; export function getCurrentHostname() { @@ -27,6 +32,10 @@ export function isRevisitControlledHostname(hostname: string) { return REVISIT_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); } +export function isRevisitDevHostname(hostname: string) { + return hostname === REVISIT_DEV_DOMAIN || hostname.endsWith(`.${REVISIT_DEV_DOMAIN}`); +} + export function shouldSuppressDefaultDeploymentWarnings(hostname = getCurrentHostname()) { return isLocalDevelopmentHostname(hostname) || isRevisitControlledHostname(hostname); } @@ -48,8 +57,10 @@ export function isDefaultRevisitFirebaseConfig(firebaseConfig: unknown) { return false; } - const { projectId, authDomain } = firebaseConfig as FirebaseConfig; - return projectId === DEFAULT_FIREBASE_PROJECT_ID || authDomain === DEFAULT_FIREBASE_AUTH_DOMAIN; + const { projectId, authDomain, storageBucket } = firebaseConfig as FirebaseConfig; + return projectId === DEFAULT_FIREBASE_PROJECT_ID + || authDomain === DEFAULT_FIREBASE_AUTH_DOMAIN + || storageBucket === DEFAULT_FIREBASE_STORAGE_BUCKET; } export function shouldWarnForDefaultFirebaseConfig({ @@ -65,3 +76,29 @@ export function shouldWarnForDefaultFirebaseConfig({ && !shouldSuppressDefaultDeploymentWarnings(hostname) && isDefaultRevisitFirebaseConfig(parseFirebaseConfig(firebaseConfigText)); } + +export function isDefaultRevisitSupabaseUrl(supabaseUrl: string | undefined) { + if (!supabaseUrl) { + return false; + } + + try { + return isRevisitDevHostname(new URL(supabaseUrl).hostname); + } catch { + return false; + } +} + +export function shouldWarnForDefaultSupabaseConfig({ + storageEngine = import.meta.env.VITE_STORAGE_ENGINE, + supabaseUrl = import.meta.env.VITE_SUPABASE_URL, + hostname = getCurrentHostname(), +}: { + storageEngine?: string; + supabaseUrl?: string; + hostname?: string; +} = {}) { + return storageEngine === 'supabase' + && !shouldSuppressDefaultDeploymentWarnings(hostname) + && isDefaultRevisitSupabaseUrl(supabaseUrl); +} diff --git a/src/utils/tests/defaultFirebaseConfig.spec.ts b/src/utils/tests/defaultFirebaseConfig.spec.ts index 52e5416989..3f44463460 100644 --- a/src/utils/tests/defaultFirebaseConfig.spec.ts +++ b/src/utils/tests/defaultFirebaseConfig.spec.ts @@ -4,12 +4,16 @@ import { import { DEFAULT_FIREBASE_AUTH_DOMAIN, DEFAULT_FIREBASE_PROJECT_ID, + DEFAULT_FIREBASE_STORAGE_BUCKET, isDefaultRevisitFirebaseConfig, + isDefaultRevisitSupabaseUrl, isLocalDevelopmentHostname, + isRevisitDevHostname, isRevisitControlledHostname, parseFirebaseConfig, shouldSuppressDefaultDeploymentWarnings, shouldWarnForDefaultFirebaseConfig, + shouldWarnForDefaultSupabaseConfig, } from '../defaultFirebaseConfig'; describe('defaultFirebaseConfig', () => { @@ -17,10 +21,11 @@ describe('defaultFirebaseConfig', () => { vi.unstubAllEnvs(); }); - test('detects the default Firebase project by projectId or authDomain', () => { + test('detects the default Firebase project by projectId, authDomain, or storageBucket', () => { expect(isDefaultRevisitFirebaseConfig({ projectId: DEFAULT_FIREBASE_PROJECT_ID })).toBe(true); expect(isDefaultRevisitFirebaseConfig({ authDomain: DEFAULT_FIREBASE_AUTH_DOMAIN })).toBe(true); - expect(isDefaultRevisitFirebaseConfig({ projectId: 'custom-project', authDomain: 'custom.firebaseapp.com' })).toBe(false); + expect(isDefaultRevisitFirebaseConfig({ storageBucket: DEFAULT_FIREBASE_STORAGE_BUCKET })).toBe(true); + expect(isDefaultRevisitFirebaseConfig({ projectId: 'custom-project', authDomain: 'custom.firebaseapp.com', storageBucket: 'custom.appspot.com' })).toBe(false); }); test('parses JSON and HJSON Firebase config text', () => { @@ -40,6 +45,9 @@ describe('defaultFirebaseConfig', () => { expect(isRevisitControlledHostname('study.revisit.dev')).toBe(true); expect(isRevisitControlledHostname('vdl.sci.utah.edu')).toBe(true); expect(isRevisitControlledHostname('study.vdl.sci.utah.edu')).toBe(true); + expect(isRevisitDevHostname('revisit.dev')).toBe(true); + expect(isRevisitDevHostname('supabase.revisit.dev')).toBe(true); + expect(isRevisitDevHostname('vdl.sci.utah.edu')).toBe(false); expect(shouldSuppressDefaultDeploymentWarnings('example.com')).toBe(false); }); @@ -73,4 +81,34 @@ describe('defaultFirebaseConfig', () => { expect(shouldWarnForDefaultFirebaseConfig({ hostname: 'study.example.com' })).toBe(true); }); + + test('detects default Supabase URLs hosted on revisit.dev domains', () => { + expect(isDefaultRevisitSupabaseUrl('https://supabase.revisit.dev')).toBe(true); + expect(isDefaultRevisitSupabaseUrl('https://revisit.dev')).toBe(true); + expect(isDefaultRevisitSupabaseUrl('https://research.supabase.co')).toBe(false); + expect(isDefaultRevisitSupabaseUrl('not-a-url')).toBe(false); + }); + + test('warns only for default Supabase URLs on non-controlled hosts', () => { + expect(shouldWarnForDefaultSupabaseConfig({ + storageEngine: 'supabase', + supabaseUrl: 'https://supabase.revisit.dev', + hostname: 'study.example.com', + })).toBe(true); + expect(shouldWarnForDefaultSupabaseConfig({ + storageEngine: 'supabase', + supabaseUrl: 'https://research.supabase.co', + hostname: 'study.example.com', + })).toBe(false); + expect(shouldWarnForDefaultSupabaseConfig({ + storageEngine: 'firebase', + supabaseUrl: 'https://supabase.revisit.dev', + hostname: 'study.example.com', + })).toBe(false); + expect(shouldWarnForDefaultSupabaseConfig({ + storageEngine: 'supabase', + supabaseUrl: 'https://supabase.revisit.dev', + hostname: 'localhost', + })).toBe(false); + }); }); From 42d1faa9c0c1ffaaa88e3844b962cb9296ebb63c Mon Sep 17 00:00:00 2001 From: Jack Wilburn Date: Sat, 13 Jun 2026 19:43:41 -0600 Subject: [PATCH 3/3] Rename default storage config helper --- src/components/interface/AppHeader.tsx | 2 +- src/parser/parser.ts | 2 +- .../{defaultFirebaseConfig.ts => defaultStorageConfig.ts} | 0 ...ultFirebaseConfig.spec.ts => defaultStorageConfig.spec.ts} | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/utils/{defaultFirebaseConfig.ts => defaultStorageConfig.ts} (100%) rename src/utils/tests/{defaultFirebaseConfig.spec.ts => defaultStorageConfig.spec.ts} (98%) diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index d2e05683b7..9ab3f7998c 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -47,7 +47,7 @@ import { DEFAULT_SUPABASE_WARNING_MESSAGE, shouldWarnForDefaultFirebaseConfig, shouldWarnForDefaultSupabaseConfig, -} from '../../utils/defaultFirebaseConfig'; +} from '../../utils/defaultStorageConfig'; export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { developmentModeEnabled: boolean; dataCollectionEnabled: boolean }) { const studyConfig = useStoreSelector((state) => state.config); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 4965340d34..9dcafb4652 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -18,7 +18,7 @@ import { shouldSuppressDefaultDeploymentWarnings, shouldWarnForDefaultFirebaseConfig, shouldWarnForDefaultSupabaseConfig, -} from '../utils/defaultFirebaseConfig'; +} from '../utils/defaultStorageConfig'; const ajv1 = new Ajv({ allowUnionTypes: true }); ajv1.addSchema(globalSchema); diff --git a/src/utils/defaultFirebaseConfig.ts b/src/utils/defaultStorageConfig.ts similarity index 100% rename from src/utils/defaultFirebaseConfig.ts rename to src/utils/defaultStorageConfig.ts diff --git a/src/utils/tests/defaultFirebaseConfig.spec.ts b/src/utils/tests/defaultStorageConfig.spec.ts similarity index 98% rename from src/utils/tests/defaultFirebaseConfig.spec.ts rename to src/utils/tests/defaultStorageConfig.spec.ts index 3f44463460..ce184decea 100644 --- a/src/utils/tests/defaultFirebaseConfig.spec.ts +++ b/src/utils/tests/defaultStorageConfig.spec.ts @@ -14,9 +14,9 @@ import { shouldSuppressDefaultDeploymentWarnings, shouldWarnForDefaultFirebaseConfig, shouldWarnForDefaultSupabaseConfig, -} from '../defaultFirebaseConfig'; +} from '../defaultStorageConfig'; -describe('defaultFirebaseConfig', () => { +describe('defaultStorageConfig', () => { afterEach(() => { vi.unstubAllEnvs(); });