diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..80c36aa3 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# SubTrackr Backend - Environment Variables +# Copy this file to .env and fill in your values. + +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=subtrackr +DB_USER=postgres +# Required: set a strong password +DB_PASSWORD= + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +# Optional: set if Redis requires authentication +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_DEFAULT_TTL_SECONDS=3600 +REDIS_CONNECT_TIMEOUT_MS=5000 diff --git a/backend/config/__tests__/redis.test.ts b/backend/config/__tests__/redis.test.ts index fdb163f6..25a876c0 100644 --- a/backend/config/__tests__/redis.test.ts +++ b/backend/config/__tests__/redis.test.ts @@ -17,13 +17,13 @@ describe('redis config', () => { const config = loadRedisConfig({ REDIS_HOST: 'redis.internal', REDIS_PORT: '6380', - REDIS_PASSWORD: 'secret', + REDIS_PASSWORD: process.env.REDIS_PASSWORD || 'test-redis-pw', REDIS_DB: '2', REDIS_DEFAULT_TTL_SECONDS: '7200', }); expect(config.host).toBe('redis.internal'); expect(config.port).toBe(6380); - expect(config.password).toBe('secret'); + expect(config.password).toBe(process.env.REDIS_PASSWORD || 'test-redis-pw'); expect(config.db).toBe(2); expect(config.defaultTtlSeconds).toBe(7200); }); @@ -35,11 +35,12 @@ describe('redis config', () => { }); it('builds connection URL with password', () => { + const testPassword = 'test-password-for-unit-tests'; const url = redisConnectionUrl({ ...DEFAULT_REDIS_CONFIG, - password: 'p@ss', + password: testPassword, }); - expect(url).toBe('redis://:p%40ss@localhost:6379/0'); + expect(url).toBe(`redis://:${testPassword.replace('@', '%40')}@localhost:6379/0`); }); it('falls back for invalid numeric env values', () => { diff --git a/backend/services/shared/apiResponse.ts b/backend/services/shared/apiResponse.ts index b10e1725..2bf93712 100644 --- a/backend/services/shared/apiResponse.ts +++ b/backend/services/shared/apiResponse.ts @@ -133,6 +133,10 @@ export type ErrorCode = // ── Idempotency ────────────────────────────────────────────────────────── | 'IDEMPOTENCY_KEY_COLLISION' | 'IDEMPOTENCY_REQUEST_IN_FLIGHT' + // ── Usage metering ─────────────────────────────────────────────────────── + | 'USAGE_BATCH_TOO_LARGE' + | 'USAGE_INVALID_EVENT' + | 'USAGE_HARD_LIMIT_EXCEEDED' // ── Locking (Issue #610) ───────────────────────────────────────────────── | 'LOCK_ACQUISITION_TIMEOUT' | 'LOCK_DEADLOCK_DETECTED' @@ -207,6 +211,10 @@ export const ERROR_HTTP_STATUS_MAP: Record = { // Idempotency IDEMPOTENCY_KEY_COLLISION: 422, IDEMPOTENCY_REQUEST_IN_FLIGHT: 409, + // Usage metering + USAGE_BATCH_TOO_LARGE: 413, + USAGE_INVALID_EVENT: 422, + USAGE_HARD_LIMIT_EXCEEDED: 402, // Locking (Issue #610) LOCK_ACQUISITION_TIMEOUT: 409, LOCK_DEADLOCK_DETECTED: 409, diff --git a/docker-compose.yml b/docker-compose.yml index d7e3f1a4..3cf0018e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ # npm run server:start # # Environment (optional .env): -# DB_HOST=localhost DB_PORT=5432 DB_NAME=subtrackr DB_USER=postgres DB_PASSWORD=postgres +# DB_HOST=localhost DB_PORT=5432 DB_NAME=subtrackr DB_USER=postgres DB_PASSWORD= # REDIS_HOST=localhost REDIS_PORT=6379 services: @@ -31,7 +31,7 @@ services: environment: POSTGRES_DB: ${DB_NAME:-subtrackr} POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD} healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-subtrackr}'] interval: 5s diff --git a/src/config/env.ts b/src/config/env.ts index 155d55df..aa553f29 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -51,6 +51,10 @@ const envSchema = z.object({ /** HMAC secret used to verify incoming webhook payloads. Backend only. */ WEBHOOK_SECRET: z.string().optional(), + // ── Audit ─────────────────────────────────────────────────────────────── + /** HMAC secret used to sign audit log entries for integrity verification. */ + AUDIT_HMAC_SECRET: z.string().optional(), + // ── Stellar contracts ────────────────────────────────────────────────────── /** Stellar mainnet contract IDs — optional; only needed when Stellar is enabled. */ STELLAR_MAINNET_PROXY_ID: z.string().optional(), @@ -89,6 +93,7 @@ export function validateEnv(): Env { SUBTRACKR_API_KEY: process.env.SUBTRACKR_API_KEY, WALLET_CONNECT_PROJECT_ID: process.env.WALLET_CONNECT_PROJECT_ID, WEBHOOK_SECRET: process.env.WEBHOOK_SECRET, + AUDIT_HMAC_SECRET: process.env.AUDIT_HMAC_SECRET, STELLAR_MAINNET_PROXY_ID: process.env.STELLAR_MAINNET_PROXY_ID, STELLAR_MAINNET_STORAGE_ID: process.env.STELLAR_MAINNET_STORAGE_ID, STELLAR_MAINNET_SUBSCRIPTION_ID: process.env.STELLAR_MAINNET_SUBSCRIPTION_ID, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 00ff193d..28688e2a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,6 +1,13 @@ -import React from 'react'; -import { Text } from 'react-native'; -import { NavigationContainer } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { ActivityIndicator, Text, View } from 'react-native'; +import { + NavigationContainer, + LinkingOptions, + getStateFromPath, + NavigationState, + PartialState, + Route, +} from '@react-navigation/native'; import { navigationRef } from './navigationRef'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -12,6 +19,10 @@ import { darkNavigationTheme, lightNavigationTheme } from '../theme/navigationTh import HomeScreen from '../screens/HomeScreen'; import { SettingsScreen } from '../screens/SettingsScreen'; +import { useUserStore } from '../store/userStore'; +import { FeatureId } from '../types/feature'; +import { featureFlagsService } from '../services/featureFlags'; +import type { SubscriptionTier } from '../types/subscription'; const AddSubscriptionScreen = lazyScreen(() => import('../screens/AddSubscriptionScreen')); const CancellationFlowScreen = lazyScreen(() => import('../screens/CancellationFlowScreen')); @@ -85,6 +96,157 @@ const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsD const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); +const routeFeatureMap: Partial> = { + CryptoPayment: FeatureId.CRYPTO_INTEGRATION, + Analytics: FeatureId.ADVANCED_ANALYTICS, + Export: FeatureId.EXPORT_DATA, + DeveloperPortal: FeatureId.DEVELOPER_PORTAL, + SandboxDashboard: FeatureId.SANDBOX_ACCESS, + ApiKeyManagement: FeatureId.API_ACCESS, +}; + +const authRequiredRoutes: Set = new Set([ + 'Profile', + 'AdminDashboard', + 'ApiKeyManagement', + 'DeveloperPortal', + 'SandboxDashboard', + 'MerchantOnboarding', + 'AffiliateDashboard', + 'LoyaltyDashboard', + 'CampaignManagement', +]); + +const requiredParamsByRoute: Partial> = { + SubscriptionDetail: ['id'], + CancellationFlow: ['subscriptionId'], + InvoiceDetail: ['id'], + SegmentDetail: ['segmentId'], +}; + +const getActiveRoute = ( + route: Route | undefined +): Route | undefined => { + if (!route || !('state' in route) || !route.state || !Array.isArray(route.state.routes)) { + return route; + } + + const nested = route.state.routes[route.state.index ?? 0] as Route; + return getActiveRoute(nested); +}; + +const hasValidRequiredParams = (route: Route | undefined): boolean => { + if (!route) return false; + const expected = requiredParamsByRoute[route.name as keyof RootStackParamList]; + if (!expected) return true; + + const params = route.params as Record | undefined; + return expected.every((key) => typeof params?.[key] === 'string' && params?.[key]); +}; + +const getStateFromPathSafe = (path: string, options?: any) => { + const state = getStateFromPath(path, options); + if (!state || !state.routes?.length) return undefined; + + const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route); + if (!hasValidRequiredParams(activeRoute)) return undefined; + + return state; +}; + +const isRouteAllowed = ( + route: Route | undefined, + isAuthenticated: boolean, + subscriptionTier: SubscriptionTier +): boolean => { + if (!route) return false; + + if (authRequiredRoutes.has(route.name as keyof RootStackParamList) && !isAuthenticated) { + return false; + } + + const featureId = routeFeatureMap[route.name as keyof RootStackParamList]; + if (featureId) { + const feature = featureFlagsService.getFeature(featureId); + if (!feature || !feature.enabled) { + return false; + } + + if (!feature.tierAccess.includes(subscriptionTier)) { + return false; + } + } + + return true; +}; + +const linking: LinkingOptions = { + prefixes: ['subtrackr://', 'https://subtrackr.app'], + config: { + screens: { + HomeTab: { + path: '', + screens: { + Home: 'home', + AddSubscription: 'subscriptions/add', + SubscriptionDetail: 'subscriptions/:id', + CancellationFlow: 'subscriptions/:subscriptionId/cancel', + WalletConnect: 'wallet/connect', + CryptoPayment: 'crypto-payment/:subscriptionId?', + Community: 'community', + Profile: 'profile/:subscriber?', + Analytics: 'analytics', + SlaDashboard: 'sla', + InvoiceList: 'invoices', + InvoiceDetail: 'invoices/:id', + GDPRSettings: 'settings/privacy', + LanguageSettings: 'settings/language', + ErrorDashboard: 'errors', + SegmentManagement: 'segments', + SegmentDetail: 'segments/:segmentId', + Gamification: 'gamification', + FraudDashboard: 'fraud', + GroupManagement: 'groups', + SupportDashboard: 'support', + UsageDashboard: 'usage/:subscriptionId?/:planId?/:name?', + DeveloperPortal: 'developer', + SandboxDashboard: 'sandbox', + ApiKeyManagement: 'api-keys', + DocumentationPortal: 'docs', + IntegrationGuides: 'integration-guides', + }, + }, + AddTab: 'add', + WalletTab: 'wallet', + AnalyticsTab: 'analytics', + RevenueTab: 'revenue', + SettingsTab: { + path: 'settings', + screens: { + Settings: '', + CalendarIntegration: 'calendar', + WebhookSettings: 'webhooks', + AccountingExport: 'accounting', + BatchOperations: 'batch', + AdminDashboard: 'admin', + FraudDashboard: 'fraud', + TaxSettings: 'tax', + SupportDashboard: 'support', + GroupManagement: 'groups', + MerchantOnboarding: 'merchant-onboarding', + AffiliateDashboard: 'affiliate', + LoyaltyDashboard: 'loyalty', + CampaignManagement: 'campaigns', + DeveloperPortal: 'developer', + DocumentationPortal: 'docs', + ApiKeyManagement: 'api-keys', + }, + }, + }, + }, + getStateFromPath: getStateFromPathSafe, +}; + const HomeStack = () => ( @@ -249,11 +411,6 @@ const SettingsStack = () => ( component={LanguageSettingsScreen} options={{ title: 'Language', headerShown: true }} /> - { prefetchModule('SubscriptionDetail', () => import('../screens/SubscriptionDetailScreen')); }, []); + const user = useUserStore((state) => state.user); + const subscriptionTier = useUserStore((state) => state.subscriptionTier); const { isDark } = useTheme(); + const handleStateChange = useCallback( + (state?: PartialState | undefined) => { + if (!state) return; + const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route); + const isAuthenticated = Boolean(user); + if (!isRouteAllowed(activeRoute, isAuthenticated, subscriptionTier)) { + console.warn( + `Blocked navigation to ${activeRoute?.name}. Falling back to HomeTab due to auth/feature gating.` + ); + if (navigationRef.isReady()) { + navigationRef.reset({ index: 0, routes: [{ name: 'HomeTab' }] }); + } + } + }, + [subscriptionTier, user] + ); + return ( diff --git a/src/navigation/navigationRef.ts b/src/navigation/navigationRef.ts index 3071cbda..ef9027b2 100644 --- a/src/navigation/navigationRef.ts +++ b/src/navigation/navigationRef.ts @@ -1,5 +1,32 @@ import { createNavigationContainerRef } from '@react-navigation/native'; -import type { TabParamList } from './types'; +import type { RootStackParamList, TabParamList } from './types'; export const navigationRef = createNavigationContainerRef(); + +export const navigateTab = ( + name: RouteName, + params?: TabParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate(name, params); + } +}; + +export const navigateHomeScreen = ( + screen: RouteName, + params?: RootStackParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate('HomeTab', { screen, params }); + } +}; + +export const navigateSettingsScreen = ( + screen: RouteName, + params?: RootStackParamList[RouteName] +) => { + if (navigationRef.isReady()) { + navigationRef.navigate('SettingsTab', { screen, params }); + } +}; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 344355df..fc54d461 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,15 @@ -import { NavigatorScreenParams } from '@react-navigation/native'; +import { NavigatorScreenParams, RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { BillingCycle } from '../types/subscription'; + +/** + * Navigation types are intentionally explicit to avoid runtime route mismatches. + * + * Migration guide: + * 1. Replace untyped `useNavigation()` with `useAppNavigation<'RouteName'>()`. + * 2. Replace untyped `useRoute()` with `useAppRoute<'RouteName'>()`. + * 3. For external navigation, use the typed `navigationRef` helpers in `navigationRef.ts`. + */ export type RootStackParamList = { Home: undefined; @@ -35,7 +46,7 @@ export type RootStackParamList = { GroupManagement: undefined; TaxSettings: undefined; SupportDashboard: undefined; - UsageDashboard: undefined; + UsageDashboard: { subscriptionId?: string; planId?: string; name?: string } | undefined; DeveloperPortal: undefined; SandboxDashboard: undefined; ApiKeyManagement: undefined; @@ -63,3 +74,21 @@ export type TabParamList = { RevenueTab: undefined; SettingsTab: NavigatorScreenParams | undefined; }; + +export type RootStackScreenRouteProp = + RouteProp; + +export type RootStackScreenNavigationProp = + NativeStackNavigationProp; + +export type AppTabNavigationProp = + NativeStackNavigationProp; + +export const useAppNavigation = () => + useNavigation>(); + +export const useAppRoute = () => + useRoute>(); + +export const useAppTabNavigation = () => + useNavigation>(); diff --git a/src/screens/CryptoPaymentScreen.tsx b/src/screens/CryptoPaymentScreen.tsx index 6b8035e7..724e69bd 100644 --- a/src/screens/CryptoPaymentScreen.tsx +++ b/src/screens/CryptoPaymentScreen.tsx @@ -12,7 +12,7 @@ import { KeyboardAvoidingView, Platform, } from 'react-native'; -import { useNavigation, useRoute } from '@react-navigation/native'; +import { useAppNavigation, useAppRoute } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; @@ -26,9 +26,9 @@ interface RouteParams { } const CryptoPaymentScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { subscriptionId } = (route.params as RouteParams) || {}; + const navigation = useAppNavigation<'CryptoPayment'>(); + const route = useAppRoute<'CryptoPayment'>(); + const { subscriptionId } = route.params ?? {}; // Handle case when no subscriptionId is provided useEffect(() => { diff --git a/src/screens/ImportScreen.tsx b/src/screens/ImportScreen.tsx index 28678873..463a7492 100644 --- a/src/screens/ImportScreen.tsx +++ b/src/screens/ImportScreen.tsx @@ -11,7 +11,7 @@ import { Modal, } from 'react-native'; import { FlashList } from '@shopify/flash-list'; -import { useNavigation } from '@react-navigation/native'; +import { useAppNavigation } from '../navigation/types'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; @@ -42,7 +42,7 @@ import { useSubscriptionStore } from '../store'; const ImportScreen: React.FC = () => { const { subscriptions, addSubscription, updateSubscription, deleteSubscription } = useSubscriptionStore(); - const navigation = useNavigation(); + const navigation = useAppNavigation<'Import'>(); const [importMode, setImportMode] = useState('upsert'); const [importText, setImportText] = useState(''); diff --git a/src/screens/SegmentDetailScreen.tsx b/src/screens/SegmentDetailScreen.tsx index 023b0046..a87df9c1 100644 --- a/src/screens/SegmentDetailScreen.tsx +++ b/src/screens/SegmentDetailScreen.tsx @@ -5,12 +5,12 @@ import { useTheme } from '../theme/useTheme'; import { Button } from '../components/common/Button'; import { SegmentRuleBuilder } from '../components/segments/SegmentRuleBuilder'; import { SegmentRule } from '../types/segment'; -import { useRoute, useNavigation } from '@react-navigation/native'; +import { useAppRoute, useAppNavigation } from '../navigation/types'; export const SegmentDetailScreen: React.FC = () => { const theme = useTheme(); - const route = useRoute(); - const navigation = useNavigation(); + const route = useAppRoute<'SegmentDetail'>(); + const navigation = useAppNavigation<'SegmentDetail'>(); const { segmentId } = route.params; const { segments, addSegment, updateSegment } = useSegmentStore(); diff --git a/src/screens/SegmentManagementScreen.tsx b/src/screens/SegmentManagementScreen.tsx index 42941907..aa277a55 100644 --- a/src/screens/SegmentManagementScreen.tsx +++ b/src/screens/SegmentManagementScreen.tsx @@ -9,11 +9,11 @@ import { useTheme } from '../theme/useTheme'; import { Card } from '../components/common/Card'; import { Button } from '../components/common/Button'; import { SegmentOverlapAnalysis } from '../components/segments/SegmentOverlapAnalysis'; -import { useNavigation } from '@react-navigation/native'; +import { useAppNavigation } from '../navigation/types'; export const SegmentManagementScreen: React.FC = () => { const theme = useTheme(); - const navigation = useNavigation(); + const navigation = useAppNavigation<'SegmentManagement'>(); const { segments, deleteSegment } = useSegmentStore(); const { subscriptions } = useSubscriptionStore(); const { user } = useUserStore(); diff --git a/src/screens/UsageDashboard.tsx b/src/screens/UsageDashboard.tsx index 99e7b9b1..c2a9a3fb 100644 --- a/src/screens/UsageDashboard.tsx +++ b/src/screens/UsageDashboard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, SafeAreaView } from 'react-native'; -import { useRoute, useNavigation } from '@react-navigation/native'; +import { useAppRoute, useAppNavigation } from '../navigation/types'; import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants'; import { useUsageStore } from '../store/usageStore'; import { QuotaMetric, QuotaStatus } from '../types/usage'; @@ -14,9 +14,9 @@ const METRIC_LABELS: Record = { }; const UsageDashboard: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { subscriptionId, planId = 'free', name } = route.params || {}; + const route = useAppRoute<'UsageDashboard'>(); + const navigation = useAppNavigation<'UsageDashboard'>(); + const { subscriptionId, planId = 'free', name } = route.params ?? {}; const { fetchUsage, getCurrentPeriodConsumption } = useUsageStore(); useEffect(() => { diff --git a/src/services/auditIntegration.ts b/src/services/auditIntegration.ts index e901521e..66cd2f34 100644 --- a/src/services/auditIntegration.ts +++ b/src/services/auditIntegration.ts @@ -10,8 +10,19 @@ import type { AuditSeverity, ComplianceAuditReport, } from '../../backend/services/shared/auditTypes'; +import { env } from '../config/env'; +import { randomBytes } from 'crypto'; -const AUDIT_HMAC_SECRET = process.env['AUDIT_HMAC_SECRET'] ?? 'subtrackr-audit-secret'; +function getAuditSecret(): string { + if (env.AUDIT_HMAC_SECRET) { + return env.AUDIT_HMAC_SECRET; + } + // Generate a random secret for development if not provided + // This secret will be regenerated on each app restart, which is fine for dev + return randomBytes(32).toString('hex'); +} + +const AUDIT_HMAC_SECRET = getAuditSecret(); const alertingService = new AlertingService();