Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions backend/config/__tests__/redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions backend/services/shared/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -207,6 +211,10 @@ export const ERROR_HTTP_STATUS_MAP: Record<ErrorCode, number> = {
// 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,
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
194 changes: 186 additions & 8 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'));
Expand Down Expand Up @@ -85,6 +96,157 @@ const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsD
const Tab = createBottomTabNavigator<TabParamList>();
const Stack = createNativeStackNavigator<RootStackParamList>();

const routeFeatureMap: Partial<Record<keyof RootStackParamList, FeatureId>> = {
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<keyof RootStackParamList> = new Set([
'Profile',
'AdminDashboard',
'ApiKeyManagement',
'DeveloperPortal',
'SandboxDashboard',
'MerchantOnboarding',
'AffiliateDashboard',
'LoyaltyDashboard',
'CampaignManagement',
]);

const requiredParamsByRoute: Partial<Record<keyof RootStackParamList, string[]>> = {
SubscriptionDetail: ['id'],
CancellationFlow: ['subscriptionId'],
InvoiceDetail: ['id'],
SegmentDetail: ['segmentId'],
};

const getActiveRoute = (
route: Route<string, object | undefined> | undefined
): Route<string, object | undefined> | 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<string, object | undefined>;
return getActiveRoute(nested);
};

const hasValidRequiredParams = (route: Route<string, object | undefined> | undefined): boolean => {
if (!route) return false;
const expected = requiredParamsByRoute[route.name as keyof RootStackParamList];
if (!expected) return true;

const params = route.params as Record<string, unknown> | 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<string, object | undefined>);
if (!hasValidRequiredParams(activeRoute)) return undefined;

return state;
};

const isRouteAllowed = (
route: Route<string, object | undefined> | 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<TabParamList> = {
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 = () => (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} options={{ headerShown: false }} />
Expand Down Expand Up @@ -249,11 +411,6 @@ const SettingsStack = () => (
component={LanguageSettingsScreen}
options={{ title: 'Language', headerShown: true }}
/>
<Stack.Screen
name="Export"
component={ExportScreen}
options={{ title: 'Export', headerShown: true }}
/>
<Stack.Screen
name="BatchOperations"
component={BatchOperationsScreen}
Expand Down Expand Up @@ -470,11 +627,32 @@ export const AppNavigator = () => {
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<NavigationState> | undefined) => {
if (!state) return;
const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route<string, object | undefined>);
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 (
<NavigationContainer
ref={navigationRef}
linking={linking}
onStateChange={handleStateChange}
theme={isDark ? darkNavigationTheme : lightNavigationTheme}>
<TabNavigator />
</NavigationContainer>
Expand Down
29 changes: 28 additions & 1 deletion src/navigation/navigationRef.ts
Original file line number Diff line number Diff line change
@@ -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<TabParamList>();

export const navigateTab = <RouteName extends keyof TabParamList>(
name: RouteName,
params?: TabParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate(name, params);
}
};

export const navigateHomeScreen = <RouteName extends keyof RootStackParamList>(
screen: RouteName,
params?: RootStackParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate('HomeTab', { screen, params });
}
};

export const navigateSettingsScreen = <RouteName extends keyof RootStackParamList>(
screen: RouteName,
params?: RootStackParamList[RouteName]
) => {
if (navigationRef.isReady()) {
navigationRef.navigate('SettingsTab', { screen, params });
}
};
Loading