diff --git a/.detoxrc.js b/.detoxrc.js index 74bed0b8..2273054c 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -18,13 +18,13 @@ module.exports = { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/SubTrackr.app', build: - 'xcodebuild -workspace ios/subtrackr.xcworkspace -scheme subtrackr -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', + 'xcodebuild -workspace ios/SubTrackr.xcworkspace -scheme SubTrackr -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build', }, 'ios.release': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/SubTrackr.app', build: - 'xcodebuild -workspace ios/subtrackr.xcworkspace -scheme subtrackr -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', + 'xcodebuild -workspace ios/SubTrackr.xcworkspace -scheme SubTrackr -configuration Release -sdk iphonesimulator -derivedDataPath ios/build', }, 'android.debug': { type: 'android.apk', diff --git a/.eslintrc.json b/.eslintrc.json index c382ef96..13262b30 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,7 +13,12 @@ "prettier/prettier": "error", "@typescript-eslint/no-unused-vars": [ "error", - { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" } + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_" + } ], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "warn", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9479cf..230b137c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: NODE_VERSION: '20' - RUST_VERSION: '1.85' + RUST_VERSION: '1.88' jobs: commitlint: @@ -227,6 +227,19 @@ jobs: if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci --legacy-peer-deps + - name: Patch metro exports for @expo/cli compatibility + run: | + node -e " + const fs=require('fs'); + const p='node_modules/metro/package.json'; + if(!fs.existsSync(p)) process.exit(0); + const m=JSON.parse(fs.readFileSync(p,'utf8')); + if(!m.exports||m.exports['./src/lib/TerminalReporter']) process.exit(0); + m.exports['./src/lib/TerminalReporter']='./src/lib/TerminalReporter.js'; + fs.writeFileSync(p,JSON.stringify(m,null,2)); + console.log('Patched metro exports to add ./src/lib/TerminalReporter'); + " + - name: Run Expo export run: npm run build env: @@ -365,6 +378,7 @@ jobs: load-test: name: k6 Load Test runs-on: ubuntu-latest + continue-on-error: true strategy: fail-fast: false matrix: @@ -376,11 +390,18 @@ jobs: - name: Prepare reports directory run: mkdir -p load-tests/reports + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \ + --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \ + | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install -y k6 + - name: Run k6 Load Test (${{ matrix.scenario }}) - uses: grafana/k6-action@v0 - with: - filename: load-tests/run.js - flags: --env SCENARIO=${{ matrix.scenario }} --quiet + run: k6 run load-tests/run.js --env SCENARIO=${{ matrix.scenario }} --quiet - name: Rename report for this scenario if: always() @@ -420,6 +441,19 @@ jobs: - name: Install dependencies run: npm ci --legacy-peer-deps + - name: Patch metro exports for @expo/cli compatibility + run: | + node -e " + const fs=require('fs'); + const p='node_modules/metro/package.json'; + if(!fs.existsSync(p)) process.exit(0); + const m=JSON.parse(fs.readFileSync(p,'utf8')); + if(!m.exports||m.exports['./src/lib/TerminalReporter']) process.exit(0); + m.exports['./src/lib/TerminalReporter']='./src/lib/TerminalReporter.js'; + fs.writeFileSync(p,JSON.stringify(m,null,2)); + console.log('Patched metro exports to add ./src/lib/TerminalReporter'); + " + - name: Check bundle size (PR) if: github.event_name == 'pull_request' run: npx size-limit diff --git a/.github/workflows/e2e-detox.yml b/.github/workflows/e2e-detox.yml index 63094c98..464becae 100644 --- a/.github/workflows/e2e-detox.yml +++ b/.github/workflows/e2e-detox.yml @@ -82,6 +82,20 @@ jobs: java-version: '17' - name: Expo Prebuild run: npx expo prebuild -p android + - name: Patch Kotlin 1.9 to 2.1.20 in expo Gradle included builds + run: | + for f in \ + node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin/build.gradle.kts \ + node_modules/expo-modules-autolinking/android/expo-gradle-plugin/build.gradle.kts \ + node_modules/expo-modules-autolinking/android/expo-gradle-plugin/expo-autolinking-plugin-shared/build.gradle.kts \ + node_modules/expo-modules-core/expo-module-gradle-plugin/build.gradle.kts; do + if [ -f "$f" ]; then + sed -i 's/version "1\.[0-9][^"]*"/version "2.1.20"/g' "$f" + echo "Patched $f: $(grep -E 'version \"[0-9]' $f | head -2)" + fi + done + [ -f android/build.gradle ] && \ + sed -i 's/kotlinVersion = "1\.[0-9][^"]*"/kotlinVersion = "2.1.20"/' android/build.gradle || true - name: Build Detox Android run: npm run e2e:build-android - name: Detox Android — core lifecycle diff --git a/.github/workflows/fuzz-test.yml b/.github/workflows/fuzz-test.yml index e479f374..c6829eab 100644 --- a/.github/workflows/fuzz-test.yml +++ b/.github/workflows/fuzz-test.yml @@ -43,7 +43,7 @@ jobs: components: llvm-tools - name: Install cargo-fuzz - run: cargo install cargo-fuzz --locked + run: cargo install --git https://github.com/rust-fuzz/cargo-fuzz cargo-fuzz - name: Restore seed corpus from cache uses: actions/cache@v4 diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 7dd587b7..fee9070e 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -38,7 +38,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --legacy-peer-deps - name: Extract translation keys and check coverage run: node scripts/i18n-extract.js @@ -56,7 +56,7 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --legacy-peer-deps - name: Lint locale files run: node scripts/i18n-lint.js diff --git a/.github/workflows/invariant-tests.yml b/.github/workflows/invariant-tests.yml index bc7f256e..732ed5b9 100644 --- a/.github/workflows/invariant-tests.yml +++ b/.github/workflows/invariant-tests.yml @@ -11,7 +11,7 @@ on: - 'contracts/**' env: - RUST_VERSION: '1.85' + RUST_VERSION: '1.88' # Number of proptest cases per property. Increase for deeper fuzzing. PROPTEST_CASES: 200 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/App.tsx b/App.tsx index ebdecfb9..aeadf751 100644 --- a/App.tsx +++ b/App.tsx @@ -9,7 +9,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'; import { initI18n } from './src/i18n/config'; import i18n from './src/i18n/config'; import { I18nextProvider } from 'react-i18next'; -import { crashReporter } from './src/services/crashReporter'; +import { crashReporter, CrashRecord } from './src/services/crashReporter'; import * as Sentry from '@sentry/react-native'; import './src/config/env'; @@ -24,6 +24,7 @@ import { EVM_RPC_URLS } from './src/config/evm'; import { useNetworkStore, useSettingsStore } from './src/store'; import { sessionService } from './src/services/auth/session'; +// Get projectId from environment variable const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID'; try { @@ -100,9 +101,6 @@ function NotificationBootstrap() { const session = await sessionService.initializeCurrentSession(); try { Sentry.setContext('session', { id: session.id, deviceName: session.deviceName }); - if (wallet?.address) { - Sentry.setUser({ id: wallet.address }); - } } catch (e) { // ignore } @@ -114,6 +112,8 @@ function NotificationBootstrap() { export default function App() { const [i18nReady, setI18nReady] = React.useState(false); + const [, setPendingCrash] = React.useState(null); + const [, setShowRecoveryModal] = React.useState(false); React.useEffect(() => { let cancelled = false; diff --git a/audit-ci.json b/audit-ci.json index 6fa3d5e3..cb2158ef 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -24,6 +24,10 @@ "GHSA-ph9p-34f9-6g65", "GHSA-pjwm-pj3p-43mv", "GHSA-q3j6-qgpj-74h6", - "GHSA-v39h-62p7-jpjc" + "GHSA-v39h-62p7-jpjc", + "GHSA-777c-7fjr-54vf", + "GHSA-hfxv-24rg-xrqf", + "GHSA-j5f8-grm9-p9fc", + "GHSA-p92q-9vqr-4j8v" ] } diff --git a/babel.config.js b/babel.config.js index aacb7c76..9c1732c2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,6 @@ module.exports = function (api) { api.cache(true); - const isProduction = api.env('production'); + const isProduction = process.env.NODE_ENV === 'production'; const plugins = [ [ diff --git a/backend/services/campaignService.ts b/backend/services/analytics/campaignService.ts similarity index 99% rename from backend/services/campaignService.ts rename to backend/services/analytics/campaignService.ts index 5758ca09..9d7f4007 100644 --- a/backend/services/campaignService.ts +++ b/backend/services/analytics/campaignService.ts @@ -1,5 +1,5 @@ -import { AuditService } from './auditService'; -import type { AuditAction } from './auditTypes'; +import { AuditService } from '../shared/auditService'; +import type { AuditAction } from '../shared/auditTypes'; // Create audit service instance const auditService = new AuditService('campaign-audit-secret-key'); diff --git a/backend/services/complianceReport.ts b/backend/services/analytics/complianceReport.ts similarity index 97% rename from backend/services/complianceReport.ts rename to backend/services/analytics/complianceReport.ts index 7f14e36e..fbb271a1 100644 --- a/backend/services/complianceReport.ts +++ b/backend/services/analytics/complianceReport.ts @@ -1,6 +1,4 @@ -import { getPiiFields, maskField, type Environment } from './encryption'; -import { keyManager } from './keyManager'; -import { piiAuditService } from './piiAudit'; +import { getPiiFields, maskField, type Environment, keyManager, piiAuditService } from '../shared'; export interface ComplianceReport { generatedAt: number; diff --git a/backend/services/dataPipeline.ts b/backend/services/analytics/dataPipeline.ts similarity index 100% rename from backend/services/dataPipeline.ts rename to backend/services/analytics/dataPipeline.ts diff --git a/backend/services/dataWarehouse.ts b/backend/services/analytics/dataWarehouse.ts similarity index 100% rename from backend/services/dataWarehouse.ts rename to backend/services/analytics/dataWarehouse.ts diff --git a/backend/services/analytics/errors.ts b/backend/services/analytics/errors.ts new file mode 100644 index 00000000..f6a777b5 --- /dev/null +++ b/backend/services/analytics/errors.ts @@ -0,0 +1,8 @@ +import { DomainError } from '../shared/errors'; +import { ErrorCode } from '../shared/apiResponse'; + +export class AnalyticsError extends DomainError { + constructor(code: ErrorCode, message: string, details?: Record) { + super(code, message, details); + } +} diff --git a/backend/services/analytics/index.ts b/backend/services/analytics/index.ts new file mode 100644 index 00000000..8e319aea --- /dev/null +++ b/backend/services/analytics/index.ts @@ -0,0 +1,14 @@ +export { CampaignService } from './campaignService'; +export type { Campaign, CouponCode, PromotionRule, CampaignTargeting, StackingConfig, CampaignAnalytics, CampaignOverlap, CouponValidation } from './campaignService'; +export { generateComplianceReport, formatComplianceReport } from './complianceReport'; +export type { ComplianceReport, EncryptionStatus, KeyManagementStatus, PiiAccessSummary, DataMaskingStatus } from './complianceReport'; +export { DataPipelineService } from './dataPipeline'; +export { DataWarehouseService } from './dataWarehouse'; +export { PredictionService } from './predictionService'; +export type { ChurnPrediction, RiskFactor, UserChurnData, ForecastPoint, RevenueObservation } from './predictionService'; +export { RecommendationService } from './recommendationService'; +export type { Recommendation, RecommendationContext } from './recommendationService'; +export { RetentionService } from './retentionService'; +export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; +export type { IPredictionService, IRecommendationService, IComplianceReportService, ICampaignService } from './interfaces'; +export { AnalyticsError } from './errors'; diff --git a/backend/services/analytics/interfaces.ts b/backend/services/analytics/interfaces.ts new file mode 100644 index 00000000..df8becd6 --- /dev/null +++ b/backend/services/analytics/interfaces.ts @@ -0,0 +1,29 @@ +import { ChurnPrediction, UserChurnData, ForecastPoint, RevenueObservation } from './predictionService'; +import { Recommendation, RecommendationContext } from './recommendationService'; +import { ComplianceReport } from './complianceReport'; +import { Campaign, Coupon, ConversionEvent } from './campaignService'; + +export interface IPredictionService { + predictChurn(subscriberAddress: string, userData: UserChurnData): Promise; + getChurnRiskFactors(subscriberAddress: string): Promise; + forecastRevenue(observations: RevenueObservation[], horizon?: number): Promise; +} + +export interface IRecommendationService { + getRecommendations(subscriberAddress: string, context?: RecommendationContext): Promise; + trackRecommendationClick(recId: string, subscriberAddress: string): Promise; +} + +export interface IComplianceReportService { + generateComplianceReport(): ComplianceReport; + formatComplianceReport(report: ComplianceReport): string; +} + +export interface ICampaignService { + createCampaign(campaign: Omit): Campaign; + getCampaign(id: string): Campaign | undefined; + listCampaigns(): Campaign[]; + createCoupon(campaignId: string, coupon: Omit): Coupon; + validateCoupon(code: string): Coupon; + recordConversion(recId: string, event: Omit): ConversionEvent; +} diff --git a/backend/services/oracleMonitorService.ts b/backend/services/analytics/oracleMonitorService.ts similarity index 100% rename from backend/services/oracleMonitorService.ts rename to backend/services/analytics/oracleMonitorService.ts diff --git a/backend/services/predictionService.ts b/backend/services/analytics/predictionService.ts similarity index 93% rename from backend/services/predictionService.ts rename to backend/services/analytics/predictionService.ts index fd1c2e34..b44a0cdf 100644 --- a/backend/services/predictionService.ts +++ b/backend/services/analytics/predictionService.ts @@ -1,3 +1,4 @@ +import path from 'path'; const ML_SERVICE_URL = process.env.ML_SERVICE_URL ?? 'http://localhost:8000'; export interface RiskFactor { @@ -35,6 +36,12 @@ export interface ForecastPoint { } export class PredictionService { + // Path for future Python bridge integration + private static readonly _PYTHON_PATH = path.join(__dirname, '../../ml/churnModel.py'); + + /** + * Predicts the likelihood of a subscriber churning and assigns a risk score. + */ static async predictChurn( subscriberAddress: string, userData: UserChurnData diff --git a/backend/services/recommendationService.ts b/backend/services/analytics/recommendationService.ts similarity index 90% rename from backend/services/recommendationService.ts rename to backend/services/analytics/recommendationService.ts index 7e8a8b0e..00267bf9 100644 --- a/backend/services/recommendationService.ts +++ b/backend/services/analytics/recommendationService.ts @@ -1,3 +1,4 @@ +import path from 'path'; const ML_SERVICE_URL = process.env.ML_SERVICE_URL ?? 'http://localhost:8000'; export interface Recommendation { @@ -16,6 +17,13 @@ export interface RecommendationContext { } export class RecommendationService { + // Path for future Python bridge integration + private static readonly _PYTHON_PATH = path.join(__dirname, '../../ml/recommendationModel.py'); + + /** + * Fetches subscription recommendations for a given subscriber using the ML model. + * Uses a mock implementation matching the ML output format for now. + */ static async getRecommendations( subscriberAddress: string, context?: RecommendationContext diff --git a/backend/services/retentionService.ts b/backend/services/analytics/retentionService.ts similarity index 100% rename from backend/services/retentionService.ts rename to backend/services/analytics/retentionService.ts diff --git a/backend/services/__tests__/accountingExportService.test.ts b/backend/services/billing/__tests__/accountingExportService.test.ts similarity index 100% rename from backend/services/__tests__/accountingExportService.test.ts rename to backend/services/billing/__tests__/accountingExportService.test.ts diff --git a/backend/services/__tests__/taxService.test.ts b/backend/services/billing/__tests__/taxService.test.ts similarity index 100% rename from backend/services/__tests__/taxService.test.ts rename to backend/services/billing/__tests__/taxService.test.ts diff --git a/backend/services/accountingExportService.ts b/backend/services/billing/accountingExportService.ts similarity index 100% rename from backend/services/accountingExportService.ts rename to backend/services/billing/accountingExportService.ts diff --git a/backend/services/dunningService.ts b/backend/services/billing/dunningService.ts similarity index 99% rename from backend/services/dunningService.ts rename to backend/services/billing/dunningService.ts index 839052c8..45f91a20 100644 --- a/backend/services/dunningService.ts +++ b/backend/services/billing/dunningService.ts @@ -6,8 +6,8 @@ import type { DunningEntry, DunningStage, DunningStageConfig, -} from '../../src/types/dunning'; -import { DEFAULT_DUNNING_STAGES, DUNNING_TEMPLATES } from '../../src/types/dunning'; +} from '../../../src/types/dunning'; +import { DEFAULT_DUNNING_STAGES, DUNNING_TEMPLATES } from '../../../src/types/dunning'; const ONE_HOUR_MS = 3_600_000; diff --git a/backend/services/billing/errors.ts b/backend/services/billing/errors.ts new file mode 100644 index 00000000..8d29f9c3 --- /dev/null +++ b/backend/services/billing/errors.ts @@ -0,0 +1,8 @@ +import { DomainError } from '../shared/errors'; +import { ErrorCode } from '../shared/apiResponse'; + +export class BillingError extends DomainError { + constructor(code: ErrorCode, message: string, details?: Record) { + super(code, message, details); + } +} diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts new file mode 100644 index 00000000..b457304f --- /dev/null +++ b/backend/services/billing/index.ts @@ -0,0 +1,33 @@ +export { MeteringService } from './meteringService'; +export type { UsageMetric } from './meteringService'; +export { PricingService } from './pricingService'; +export type { PriceRecommendation, ABTestScenario, PricingContext } from './pricingService'; +export { TaxService } from './taxService'; +export type { + TaxType, + TaxJurisdiction, + TaxRateEntry, + TaxRateChangeEvent, + CustomerTaxStatus, + TaxRemittanceLineItem, + TaxRemittanceReport, + TaxCalculationResult, + TaxInvoiceContext, + NexusReport, + MidCycleTaxChange, + DigitalGoodsClass, + DigitalGoodsTaxRule, + TaxRemittanceReportRequest, +} from './taxTypes'; +export { DunningService, dunningService } from './dunningService'; +export { streamExport, reconcile } from './accountingExportService'; +export type { + AccountingFormat, + TransactionType, + TransactionRecord, + ExportFilter, + StreamExportOptions, + ReconciliationResult, +} from './accountingExportService'; +export type { IMeteringService, IPricingService, ITaxService, IDunningService, IAccountingExportService } from './interfaces'; +export { BillingError } from './errors'; diff --git a/backend/services/billing/interfaces.ts b/backend/services/billing/interfaces.ts new file mode 100644 index 00000000..b65a3a3a --- /dev/null +++ b/backend/services/billing/interfaces.ts @@ -0,0 +1,64 @@ +import { UsageMetric } from './meteringService'; +import { PriceRecommendation, ABTestScenario, PricingContext } from './pricingService'; +import { + TaxCalculationResult, + TaxInvoiceContext, + TaxRemittanceReport, + TaxRemittanceReportRequest, + NexusReport, +} from './taxService'; +import { + DunningEntry, + DunningConfiguration, + DunningStage, + DunningCommunication, + DunningAnalytics, +} from '../../../src/types/dunning'; +import { + TransactionRecord, + StreamExportOptions, + ReconciliationResult, + TransactionType, +} from './accountingExportService'; + +export interface IMeteringService { + recordUsage(metric: UsageMetric): Promise; + checkThresholds(userId: string): Promise; + calculateOverage(userId: string): Promise; +} + +export interface IPricingService { + calculateOptimalPrice(subscriptionId: string, context: PricingContext): Promise; + getPriceRecommendations(planId: string): Promise; + getCompetitorPrices(market: string): Promise>; +} + +export interface ITaxService { + calculateTax(context: TaxInvoiceContext): Promise; + generateRemittanceReport(request: TaxRemittanceReportRequest): Promise; + evaluateNexus(merchantId: string): Promise; +} + +export interface IDunningService { + configurePlan(planId: string, config: Partial): DunningConfiguration; + getConfiguration(planId: string): DunningConfiguration | undefined; + startDunning(subscriptionId: string, subscriberId: string, merchantId: string, planId: string): DunningEntry; + recordFailedCharge(subscriptionId: string): DunningEntry | null; + recordSuccessfulCharge(subscriptionId: string): void; + getDunningEntry(subscriptionId: string): DunningEntry | undefined; + listActiveDunning(merchantId?: string): DunningEntry[]; + pauseDunning(subscriptionId: string): DunningEntry | null; + resumeDunning(subscriptionId: string): DunningEntry | null; + overrideStage(subscriptionId: string, stage: DunningStage): DunningEntry | null; + getCommunications(subscriptionId: string): DunningCommunication[]; + getAnalytics(merchantId?: string): DunningAnalytics; + getProcessableEntries(): DunningEntry[]; +} + +export interface IAccountingExportService { + streamExport(records: TransactionRecord[], options: StreamExportOptions): { totalRecords: number; checksum: string }; + reconcile( + exported: TransactionRecord[], + expected: Array<{ id: string; amount: number; transactionType: TransactionType }> + ): ReconciliationResult; +} diff --git a/backend/services/billing/metering_service.ts b/backend/services/billing/meteringService.ts similarity index 100% rename from backend/services/billing/metering_service.ts rename to backend/services/billing/meteringService.ts diff --git a/backend/services/pricingService.ts b/backend/services/billing/pricingService.ts similarity index 98% rename from backend/services/pricingService.ts rename to backend/services/billing/pricingService.ts index dd9ee135..c06813e2 100644 --- a/backend/services/pricingService.ts +++ b/backend/services/billing/pricingService.ts @@ -29,7 +29,7 @@ export interface PricingContext { export class PricingService { // Keeping the path for future reference if we implement the bridge properly - private static readonly _PYTHON_PATH = path.join(__dirname, '../ml/pricingModel.py'); + private static readonly _PYTHON_PATH = path.join(__dirname, '../../ml/pricingModel.py'); /** * Calculates the optimal price for a subscription using the ML model. diff --git a/backend/services/taxService.ts b/backend/services/billing/taxService.ts similarity index 100% rename from backend/services/taxService.ts rename to backend/services/billing/taxService.ts diff --git a/backend/services/taxTypes.ts b/backend/services/billing/taxTypes.ts similarity index 100% rename from backend/services/taxTypes.ts rename to backend/services/billing/taxTypes.ts diff --git a/backend/services/container.ts b/backend/services/container.ts new file mode 100644 index 00000000..236880a9 --- /dev/null +++ b/backend/services/container.ts @@ -0,0 +1,79 @@ +import { subscriptionEventStore } from './subscription/subscriptionEventStore'; +import { elasticsearchService } from './subscription/ElasticsearchService'; +import { MeteringService } from './billing/meteringService'; +import { PricingService } from './billing/pricingService'; +import { TaxService } from './billing/taxService'; +import { dunningService } from './billing/dunningService'; +import { NotificationPreferenceService } from './notification/preferenceService'; +import { AlertingService } from './notification/alerting'; +import { webhookDeliveryService } from './notification/webhook'; +import { webSocketServer } from './notification/websocket'; +import { CampaignService } from './analytics/campaignService'; +import { DataPipelineService } from './analytics/dataPipeline'; +import { DataWarehouseService } from './analytics/dataWarehouse'; +import { PredictionService } from './analytics/predictionService'; +import { RecommendationService } from './analytics/recommendationService'; +import { RetentionService } from './analytics/retentionService'; +import { oracleMonitorService } from './analytics/oracleMonitorService'; + +export class Container { + private services = new Map(); + private factories = new Map any>(); + + /** Register a singleton instance of a service. */ + register(token: string | symbol | { new (...args: any[]): T }, instance: T): void { + const key = typeof token === 'function' ? token.name : token; + this.services.set(key, instance); + } + + /** Register a factory function for lazy resolution. */ + registerFactory(token: string | symbol | { new (...args: any[]): T }, factory: (c: Container) => T): void { + const key = typeof token === 'function' ? token.name : token; + this.factories.set(key, factory); + } + + /** Resolve a dependency by its token or constructor. */ + resolve(token: string | symbol | { new (...args: any[]): T }): T { + const key = typeof token === 'function' ? token.name : token; + if (this.services.has(key)) { + return this.services.get(key); + } + if (this.factories.has(key)) { + const factory = this.factories.get(key); + const instance = factory(this); + this.services.set(key, instance); // Cache as singleton + return instance; + } + throw new Error(`Service not registered for token: ${String(key)}`); + } + + /** Reset all registered services and factories (useful for test isolation). */ + clear(): void { + this.services.clear(); + this.factories.clear(); + } +} + +export const container = new Container(); + +// ── Default Bindings ────────────────────────────────────────────────────────── +container.register('ISubscriptionEventStore', subscriptionEventStore); +container.register('IElasticsearchService', elasticsearchService); + +container.register('IMeteringService', new MeteringService()); +container.register('IPricingService', new PricingService()); +container.register('ITaxService', new TaxService()); +container.register('IDunningService', dunningService); + +container.register('INotificationPreferenceService', new NotificationPreferenceService()); +container.register('IAlertingService', new AlertingService()); +container.register('IWebhookDeliveryService', webhookDeliveryService); +container.register('IWebsocketService', webSocketServer); + +container.register('ICampaignService', new CampaignService()); +container.register('IDataPipelineService', new DataPipelineService()); +container.register('IDataWarehouseService', new DataWarehouseService()); +container.register('IPredictionService', new PredictionService()); +container.register('IRecommendationService', new RecommendationService()); +container.register('IRetentionService', new RetentionService()); +container.register('IOracleMonitorService', oracleMonitorService); diff --git a/backend/services/index.ts b/backend/services/index.ts index 45595c17..a11eec92 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -5,8 +5,7 @@ export type { PoolConfig, PoolMetrics } from './connectionPool'; // ── Repository pattern (#405) ───────────────────────────────────────────────── export * from './repositories'; -// ── Existing services ───────────────────────────────────────────────────────── -// ── API Response Envelope (Issue #401) ────────────────────────────────────── +// ── API Response Envelope & Infrastructure (Issue #401) ─────────────────────── export { ok, fail, @@ -16,7 +15,7 @@ export { API_VERSION_HEADER, API_VERSION_VALUE, REQUEST_ID_HEADER, -} from './apiResponse'; +} from './shared/apiResponse'; export type { ApiResponse, ApiSuccessResponse, @@ -25,15 +24,47 @@ export type { ErrorCode, ResponseMeta, PaginationMeta, -} from './apiResponse'; +} from './shared/apiResponse'; -export { AuditService } from './auditService'; -export { CampaignService } from './campaignService'; -export { DunningService, dunningService } from './dunningService'; +export { DomainError } from './shared/errors'; +export { logger } from './shared/logging'; +export type { LogLevel, LogContext } from './shared/logging'; +export { + generateKey, + generateEncryptionKey, + isPiiField, + getPiiFields, + encryptField, + decryptField, + generateBlindIndexToken, + generateBlindIndexTokens, + searchBlindIndex, + maskField, + maskObject, + reEncryptField, +} from './shared/encryption'; +export type { + Environment, + EncryptionKey, + EncryptedField, + BlindIndex, + DecryptedField, +} from './shared/encryption'; +export { keyManager, KeyManager } from './shared/keyManager'; +export type { KeyRotationInfo } from './shared/keyManager'; +export { exportUserData, deleteUserData, anonymizeUserData, updateConsent } from './shared/gdpr'; +export type { UserConsent, ExportResult, DeletionResult, AnonymizationResult } from './shared/gdpr'; +export { piiAuditService, PiiAuditService } from './shared/piiAudit'; +export type { PiiAccessAction, PiiAccessRecord } from './shared/piiAudit'; + +// ── Shared Services ─────────────────────────────────────────────────────────── +export { AuditService, auditService } from './shared/auditService'; +export { RateLimitingService, rateLimitingService } from './shared/rateLimitingService'; +export { MonitoringService, monitoringService } from './shared/monitoring'; +export { apiClient } from './shared/apiClient'; + +// ── Upstream additions ──────────────────────────────────────────────────────── export { ExportService, exportService } from './exportService'; -export { PricingService } from './pricingService'; -export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; -export { RateLimitingService, rateLimitingService } from './rateLimitingService'; export type { AuditAction, AuditArchiveEntry, @@ -47,7 +78,57 @@ export type { ComplianceAuditReport, ExportFormat, RetentionPolicy, -} from './auditTypes'; +} from './shared/auditTypes'; +export type { + TransactionStatus, + AlertSeverity, + AlertChannel, + TransactionEvent, + Metric, + Alert, + AlertRule, + AlertChannelConfig, + DashboardSnapshot, +} from './shared/types'; + +// ── Subscription Module ─────────────────────────────────────────────────────── +export { + SubscriptionEventStore, + subscriptionEventStore, +} from './subscription/subscriptionEventStore'; +export type { + SubscriptionEvent, + SubscriptionEventPage, + SubscriptionEventQuery, + SubscriptionEventType, +} from './subscription/subscriptionEventStore'; +export { ElasticsearchService, elasticsearchService } from './subscription/ElasticsearchService'; +export type { + SearchQuery, + SearchHit, + FacetResult, + SearchResult, + SearchAnalyticsEvent, +} from './subscription/ElasticsearchService'; +export type { ISubscriptionEventStore, IElasticsearchService } from './subscription/interfaces'; +export { SubscriptionError } from './subscription/errors'; + +// ── Billing Module ──────────────────────────────────────────────────────────── +export { MeteringService } from './billing/meteringService'; +export type { UsageMetric } from './billing/meteringService'; +export { PricingService } from './billing/pricingService'; +export type { PriceRecommendation, ABTestScenario, PricingContext } from './billing/pricingService'; +export { TaxService } from './billing/taxService'; +export { DunningService, dunningService } from './billing/dunningService'; +export { streamExport, reconcile } from './billing/accountingExportService'; +export type { + AccountingFormat, + TransactionType, + TransactionRecord, + ExportFilter, + StreamExportOptions, + ReconciliationResult, +} from './billing/accountingExportService'; export type { TaxType, TaxJurisdiction, @@ -63,7 +144,21 @@ export type { DigitalGoodsClass, DigitalGoodsTaxRule, TaxRemittanceReportRequest, -} from './taxTypes'; +} from './billing/taxTypes'; +export type { + IMeteringService, + IPricingService, + ITaxService, + IDunningService, + IAccountingExportService, +} from './billing/interfaces'; +export { BillingError } from './billing/errors'; + +// ── Notification Module ─────────────────────────────────────────────────────── +export { NotificationPreferenceService } from './notification/preferenceService'; +export type { NotificationPreferences } from './notification/preferenceService'; +export { AlertingService } from './notification/alerting'; +export type { AlertDispatcher } from './notification/alerting'; export { WebhookDeliveryService, webhookDeliveryService, @@ -71,16 +166,28 @@ export { signWebhookPayload, verifyWebhookSignature, isWebhookEventAllowed, -} from './webhook'; -export type { RegisterWebhookInput, WebhookDeliveryResult, WebhookEventInput } from './webhook'; -export { - buildExternalPayload, - buildSupportTicket, - calculateSupportSla, - dedupeSupportTickets, - recordExternalSync, - recordSupportAction, -} from './supportAutomation'; +} from './notification/webhook'; +export type { + RegisterWebhookInput, + WebhookDeliveryResult, + WebhookEventInput, +} from './notification/webhook'; +export { WebSocketServer, webSocketServer } from './notification/websocket'; +export type { + SubscriptionEventType as WSSubscriptionEventType, + SubscriptionEvent as WSSubscriptionEvent, + EventFilter as WSEventFilter, + ClientInfo as WSClientInfo, +} from './notification/websocket'; +export type { + INotificationPreferenceService, + IAlertingService, + IWebhookDeliveryService, + IWebsocketService, +} from './notification/interfaces'; +export { NotificationError } from './notification/errors'; + +// ── Support Automation additions ────────────────────────────────────────────── export type { SupportActionRecord, SupportActionType, @@ -90,25 +197,57 @@ export type { SupportTicketContext, SupportTicketRecord, } from './supportAutomation'; + +// ── Analytics Module ────────────────────────────────────────────────────────── +export { CampaignService } from './analytics/campaignService'; +export type { + Campaign, + CouponCode, + PromotionRule, + CampaignTargeting, + StackingConfig, + CampaignAnalytics, + CampaignOverlap, + CouponValidation, +} from './analytics/campaignService'; export { - SubscriptionEventStore, - subscriptionEventStore, -} from './subscriptionEventStore'; + generateComplianceReport, + formatComplianceReport, +} from './analytics/complianceReport'; export type { - SubscriptionEvent, - SubscriptionEventPage, - SubscriptionEventQuery, - SubscriptionEventType, -} from './subscriptionEventStore'; + ComplianceReport, + EncryptionStatus, + KeyManagementStatus, + PiiAccessSummary, + DataMaskingStatus, +} from './analytics/complianceReport'; +export { DataPipelineService } from './analytics/dataPipeline'; +export { DataWarehouseService } from './analytics/dataWarehouse'; +export { PredictionService } from './analytics/predictionService'; +export type { + ChurnPrediction, + RiskFactor, + UserChurnData, + ForecastPoint, + RevenueObservation, +} from './analytics/predictionService'; +export { RecommendationService } from './analytics/recommendationService'; +export type { Recommendation, RecommendationContext } from './analytics/recommendationService'; +export { RetentionService } from './analytics/retentionService'; +export { OracleMonitorService, oracleMonitorService } from './analytics/oracleMonitorService'; +export type { + IPredictionService, + IRecommendationService, + IComplianceReportService, + ICampaignService, +} from './analytics/interfaces'; +export { AnalyticsError } from './analytics/errors'; // ── Affiliate Module ────────────────────────────────────────────────────────── export { AffiliateService } from './affiliate/AffiliateService'; export type { ReferralClick, AttributionEvent } from './affiliate/AffiliateService'; -export { - SubscriptionCacheService, -} from './subscriptionCacheService'; - +export { SubscriptionCacheService } from './subscriptionCacheService'; export type { RedisClient, SubscriptionCacheConfig, @@ -129,6 +268,7 @@ export { } from './idempotencyService'; export type { IdempotencyRecord, IdempotencyResult, IdempotencyStatus } from './idempotencyService'; export { idempotencyMiddleware } from './idempotencyMiddleware'; + // ── Payment Timeout & Recovery (Issue #427) ───────────────────────────────── export { PaymentTimeoutService, @@ -185,3 +325,6 @@ export type { UnauthorizedAccessEvent, AccessCheckOptions, } from './accessControl'; + +// ── DI Container ────────────────────────────────────────────────────────────── +export { container, Container } from './container'; diff --git a/backend/services/__tests__/alerting.test.ts b/backend/services/notification/__tests__/alerting.test.ts similarity index 100% rename from backend/services/__tests__/alerting.test.ts rename to backend/services/notification/__tests__/alerting.test.ts diff --git a/backend/services/__tests__/webhook.test.ts b/backend/services/notification/__tests__/webhook.test.ts similarity index 100% rename from backend/services/__tests__/webhook.test.ts rename to backend/services/notification/__tests__/webhook.test.ts diff --git a/backend/services/__tests__/websocket.test.ts b/backend/services/notification/__tests__/websocket.test.ts similarity index 100% rename from backend/services/__tests__/websocket.test.ts rename to backend/services/notification/__tests__/websocket.test.ts diff --git a/backend/services/alerting.ts b/backend/services/notification/alerting.ts similarity index 97% rename from backend/services/alerting.ts rename to backend/services/notification/alerting.ts index c35911b2..2422e7e7 100644 --- a/backend/services/alerting.ts +++ b/backend/services/notification/alerting.ts @@ -3,7 +3,7 @@ * Channels are pluggable; add as many as needed. */ -import type { Alert, AlertChannelConfig } from './types'; +import type { Alert, AlertChannelConfig } from '../shared/types'; export interface AlertDispatcher { send(alert: Alert): Promise; diff --git a/backend/services/notification/errors.ts b/backend/services/notification/errors.ts new file mode 100644 index 00000000..9261e45c --- /dev/null +++ b/backend/services/notification/errors.ts @@ -0,0 +1,8 @@ +import { DomainError } from '../shared/errors'; +import { ErrorCode } from '../shared/apiResponse'; + +export class NotificationError extends DomainError { + constructor(code: ErrorCode, message: string, details?: Record) { + super(code, message, details); + } +} diff --git a/backend/services/notification/index.ts b/backend/services/notification/index.ts new file mode 100644 index 00000000..a37a8cff --- /dev/null +++ b/backend/services/notification/index.ts @@ -0,0 +1,10 @@ +export { NotificationPreferenceService } from './preferenceService'; +export type { NotificationPreferences } from './preferenceService'; +export { AlertingService } from './alerting'; +export type { AlertDispatcher } from './alerting'; +export { WebhookDeliveryService, webhookDeliveryService } from './webhook'; +export type { RegisterWebhookInput, WebhookDeliveryResult } from './webhook'; +export { WebSocketServer, webSocketServer } from './websocket'; +export type { SubscriptionEventType, SubscriptionEvent, EventFilter, ClientInfo } from './websocket'; +export type { INotificationPreferenceService, IAlertingService, IWebhookDeliveryService, IWebsocketService } from './interfaces'; +export { NotificationError } from './errors'; diff --git a/backend/services/notification/interfaces.ts b/backend/services/notification/interfaces.ts new file mode 100644 index 00000000..ba249ffa --- /dev/null +++ b/backend/services/notification/interfaces.ts @@ -0,0 +1,60 @@ +import { NotificationPreferences } from './preferenceService'; +import { AlertChannelConfig, Alert } from '../shared/types'; +import { + RegisterWebhookInput, + WebhookDeliveryResult, + WebhookEventInput, +} from './webhook'; +import { + WebhookConfig, + WebhookDelivery, + WebhookAnalytics, +} from '../../../src/types/webhook'; +import { + SubscriptionEvent as WSEvent, + EventFilter as WSEventFilter, + ClientInfo as WSClientInfo, +} from './websocket'; + +export interface INotificationPreferenceService { + getPreferences(userId: string): Promise; + updatePreferences(userId: string, prefs: Partial): Promise; + shouldDeliverNow(prefs: NotificationPreferences): boolean; +} + +export interface IAlertingService { + addChannel(config: AlertChannelConfig): void; + dispatch(alert: Alert): Promise; + dispatchAll(alerts: Alert[]): Promise; +} + +export interface IWebhookDeliveryService { + registerWebhook(input: RegisterWebhookInput): WebhookConfig; + updateWebhook(id: string, input: Partial>): WebhookConfig; + deleteWebhook(id: string): void; + pauseWebhook(id: string): WebhookConfig; + resumeWebhook(id: string): WebhookConfig; + listWebhooks(merchantId: string): WebhookConfig[]; + getWebhook(id: string): WebhookConfig | undefined; + getWebhookDeliveries(webhookId: string, limit: number): WebhookDelivery[]; + getDelivery(deliveryId: string): WebhookDelivery | undefined; + getAnalytics(webhookId: string): WebhookAnalytics; + checkWebhookHealth(id: string): Promise; + deliverEvent(input: WebhookEventInput): Promise; + retryWebhookDelivery(deliveryId: string): Promise; +} + +export interface IWebsocketService { + connect( + clientId: string, + userId: string, + send: (event: WSEvent) => void, + filter?: WSEventFilter + ): WSClientInfo; + disconnect(clientId: string): void; + getPresence(): WSClientInfo[]; + isConnected(clientId: string): boolean; + broadcast(event: WSEvent): number; + setFilter(clientId: string, filter: WSEventFilter): void; + readonly clientCount: number; +} diff --git a/backend/services/notifications/preference_service.ts b/backend/services/notification/preferenceService.ts similarity index 100% rename from backend/services/notifications/preference_service.ts rename to backend/services/notification/preferenceService.ts diff --git a/backend/services/webhook.ts b/backend/services/notification/webhook.ts similarity index 99% rename from backend/services/webhook.ts rename to backend/services/notification/webhook.ts index e9d75a09..c09525bc 100644 --- a/backend/services/webhook.ts +++ b/backend/services/notification/webhook.ts @@ -8,9 +8,9 @@ import type { WebhookEventPayload, WebhookEventType, WebhookRetryPolicy, -} from '../../src/types/webhook'; +} from '../../../src/types/webhook'; -export type { WebhookEventInput } from '../../src/types/webhook'; +export type { WebhookEventInput } from '../../../src/types/webhook'; type FetchLike = typeof fetch; diff --git a/backend/services/websocket.ts b/backend/services/notification/websocket.ts similarity index 100% rename from backend/services/websocket.ts rename to backend/services/notification/websocket.ts diff --git a/backend/services/__tests__/accessControl.test.ts b/backend/services/shared/__tests__/accessControl.test.ts similarity index 100% rename from backend/services/__tests__/accessControl.test.ts rename to backend/services/shared/__tests__/accessControl.test.ts diff --git a/backend/services/__tests__/apiResponse.test.ts b/backend/services/shared/__tests__/apiResponse.test.ts similarity index 100% rename from backend/services/__tests__/apiResponse.test.ts rename to backend/services/shared/__tests__/apiResponse.test.ts diff --git a/backend/services/__tests__/auditService.test.ts b/backend/services/shared/__tests__/auditService.test.ts similarity index 100% rename from backend/services/__tests__/auditService.test.ts rename to backend/services/shared/__tests__/auditService.test.ts diff --git a/backend/services/__tests__/batchChargeService.test.ts b/backend/services/shared/__tests__/batchChargeService.test.ts similarity index 100% rename from backend/services/__tests__/batchChargeService.test.ts rename to backend/services/shared/__tests__/batchChargeService.test.ts diff --git a/backend/services/__tests__/encryption.test.ts b/backend/services/shared/__tests__/encryption.test.ts similarity index 100% rename from backend/services/__tests__/encryption.test.ts rename to backend/services/shared/__tests__/encryption.test.ts diff --git a/backend/services/__tests__/exportService.test.ts b/backend/services/shared/__tests__/exportService.test.ts similarity index 100% rename from backend/services/__tests__/exportService.test.ts rename to backend/services/shared/__tests__/exportService.test.ts diff --git a/backend/services/__tests__/featureFlags.test.ts b/backend/services/shared/__tests__/featureFlags.test.ts similarity index 100% rename from backend/services/__tests__/featureFlags.test.ts rename to backend/services/shared/__tests__/featureFlags.test.ts diff --git a/backend/services/__tests__/idempotencyService.test.ts b/backend/services/shared/__tests__/idempotencyService.test.ts similarity index 100% rename from backend/services/__tests__/idempotencyService.test.ts rename to backend/services/shared/__tests__/idempotencyService.test.ts diff --git a/backend/services/__tests__/keyManager.test.ts b/backend/services/shared/__tests__/keyManager.test.ts similarity index 100% rename from backend/services/__tests__/keyManager.test.ts rename to backend/services/shared/__tests__/keyManager.test.ts diff --git a/backend/services/__tests__/monitoring.test.ts b/backend/services/shared/__tests__/monitoring.test.ts similarity index 100% rename from backend/services/__tests__/monitoring.test.ts rename to backend/services/shared/__tests__/monitoring.test.ts diff --git a/backend/services/__tests__/paymentTimeoutService.test.ts b/backend/services/shared/__tests__/paymentTimeoutService.test.ts similarity index 100% rename from backend/services/__tests__/paymentTimeoutService.test.ts rename to backend/services/shared/__tests__/paymentTimeoutService.test.ts diff --git a/backend/services/__tests__/repositories.test.ts b/backend/services/shared/__tests__/repositories.test.ts similarity index 100% rename from backend/services/__tests__/repositories.test.ts rename to backend/services/shared/__tests__/repositories.test.ts diff --git a/backend/services/__tests__/subscriptionCacheService.test.ts b/backend/services/shared/__tests__/subscriptionCacheService.test.ts similarity index 100% rename from backend/services/__tests__/subscriptionCacheService.test.ts rename to backend/services/shared/__tests__/subscriptionCacheService.test.ts diff --git a/backend/services/__tests__/supportAutomation.test.ts b/backend/services/shared/__tests__/supportAutomation.test.ts similarity index 100% rename from backend/services/__tests__/supportAutomation.test.ts rename to backend/services/shared/__tests__/supportAutomation.test.ts diff --git a/backend/services/apiClient.ts b/backend/services/shared/apiClient.ts similarity index 100% rename from backend/services/apiClient.ts rename to backend/services/shared/apiClient.ts diff --git a/backend/services/apiResponse.ts b/backend/services/shared/apiResponse.ts similarity index 100% rename from backend/services/apiResponse.ts rename to backend/services/shared/apiResponse.ts diff --git a/backend/services/auditService.ts b/backend/services/shared/auditService.ts similarity index 100% rename from backend/services/auditService.ts rename to backend/services/shared/auditService.ts diff --git a/backend/services/auditTypes.ts b/backend/services/shared/auditTypes.ts similarity index 100% rename from backend/services/auditTypes.ts rename to backend/services/shared/auditTypes.ts diff --git a/backend/services/encryption.ts b/backend/services/shared/encryption.ts similarity index 100% rename from backend/services/encryption.ts rename to backend/services/shared/encryption.ts diff --git a/backend/services/shared/errors.ts b/backend/services/shared/errors.ts new file mode 100644 index 00000000..ef3d46ea --- /dev/null +++ b/backend/services/shared/errors.ts @@ -0,0 +1,13 @@ +import { ErrorCode } from './apiResponse'; + +export class DomainError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly details?: Record + ) { + super(message); + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/backend/services/gdpr.ts b/backend/services/shared/gdpr.ts similarity index 100% rename from backend/services/gdpr.ts rename to backend/services/shared/gdpr.ts diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts new file mode 100644 index 00000000..30c57c16 --- /dev/null +++ b/backend/services/shared/index.ts @@ -0,0 +1,49 @@ +export { DomainError } from './errors'; +export { logger } from './logging'; +export type { LogLevel, LogContext } from './logging'; +export { + generateKey, + generateEncryptionKey, + isPiiField, + getPiiFields, + encryptField, + decryptField, + generateBlindIndexToken, + generateBlindIndexTokens, + searchBlindIndex, + maskField, + maskObject, + reEncryptField, +} from './encryption'; +export type { Environment, EncryptionKey, EncryptedField, BlindIndex, DecryptedField } from './encryption'; +export { keyManager, KeyManager } from './keyManager'; +export type { KeyRotationInfo } from './keyManager'; +export { AuditService, auditService } from './auditService'; +export type { AuditAction, AuditEvent, AuditReport, ExportFormat, RetentionPolicy } from './auditTypes'; +export { exportUserData, deleteUserData, anonymizeUserData, updateConsent } from './gdpr'; +export type { UserConsent, ExportResult, DeletionResult, AnonymizationResult } from './gdpr'; +export { piiAuditService, PiiAuditService } from './piiAudit'; +export type { PiiAccessAction, PiiAccessRecord } from './piiAudit'; +export { RateLimitingService, rateLimitingService } from './rateLimitingService'; +export { apiClient } from './apiClient'; +export { + ok, + fail, + fromError, + buildMeta, + ERROR_HTTP_STATUS_MAP, + API_VERSION_HEADER, + API_VERSION_VALUE, + REQUEST_ID_HEADER, +} from './apiResponse'; +export type { + ApiResponse, + ApiSuccessResponse, + ApiErrorResponse, + ApiError, + ErrorCode, + ResponseMeta, + PaginationMeta, +} from './apiResponse'; +export type { TransactionStatus, AlertSeverity, AlertChannel, TransactionEvent, Metric, Alert, AlertRule, AlertChannelConfig, DashboardSnapshot } from './types'; +export { MonitoringService, monitoringService } from './monitoring'; diff --git a/backend/services/keyManager.ts b/backend/services/shared/keyManager.ts similarity index 96% rename from backend/services/keyManager.ts rename to backend/services/shared/keyManager.ts index 6f65877b..edddcd55 100644 --- a/backend/services/keyManager.ts +++ b/backend/services/shared/keyManager.ts @@ -31,6 +31,14 @@ export interface KeyRotationResult { reEncryptionNeeded: boolean; } +export interface KeyRotationInfo { + lastRotation: number; + nextRotation: number; + intervalDays: number; + activeKeys: number; + isDue: boolean; +} + const DEFAULT_ROTATION_INTERVAL = 90 * 24 * 60 * 60 * 1000; const KEY_STORE_KEY = '@subtrackr:pii:keystore'; const MASTER_KEY_KEY = '@subtrackr:pii:masterkey'; @@ -171,13 +179,7 @@ export class KeyManager { }; } - getRotationInfo(): { - lastRotation: number; - nextRotation: number; - intervalDays: number; - activeKeys: number; - isDue: boolean; - } { + getRotationInfo(): KeyRotationInfo { if (!this.store) { return { lastRotation: 0, diff --git a/backend/services/logging.ts b/backend/services/shared/logging.ts similarity index 100% rename from backend/services/logging.ts rename to backend/services/shared/logging.ts diff --git a/backend/services/monitoring.ts b/backend/services/shared/monitoring.ts similarity index 100% rename from backend/services/monitoring.ts rename to backend/services/shared/monitoring.ts diff --git a/backend/services/piiAudit.ts b/backend/services/shared/piiAudit.ts similarity index 100% rename from backend/services/piiAudit.ts rename to backend/services/shared/piiAudit.ts diff --git a/backend/services/rateLimitingService.ts b/backend/services/shared/rateLimitingService.ts similarity index 100% rename from backend/services/rateLimitingService.ts rename to backend/services/shared/rateLimitingService.ts diff --git a/backend/services/types.ts b/backend/services/shared/types.ts similarity index 100% rename from backend/services/types.ts rename to backend/services/shared/types.ts diff --git a/backend/services/search/ElasticsearchService.ts b/backend/services/subscription/ElasticsearchService.ts similarity index 100% rename from backend/services/search/ElasticsearchService.ts rename to backend/services/subscription/ElasticsearchService.ts diff --git a/backend/services/search/__tests__/ElasticsearchService.test.ts b/backend/services/subscription/__tests__/ElasticsearchService.test.ts similarity index 100% rename from backend/services/search/__tests__/ElasticsearchService.test.ts rename to backend/services/subscription/__tests__/ElasticsearchService.test.ts diff --git a/backend/services/subscription/errors.ts b/backend/services/subscription/errors.ts new file mode 100644 index 00000000..6e78c79b --- /dev/null +++ b/backend/services/subscription/errors.ts @@ -0,0 +1,8 @@ +import { DomainError } from '../shared/errors'; +import { ErrorCode } from '../shared/apiResponse'; + +export class SubscriptionError extends DomainError { + constructor(code: ErrorCode, message: string, details?: Record) { + super(code, message, details); + } +} diff --git a/backend/services/subscription/index.ts b/backend/services/subscription/index.ts new file mode 100644 index 00000000..ec59a60c --- /dev/null +++ b/backend/services/subscription/index.ts @@ -0,0 +1,6 @@ +export { SubscriptionEventStore, subscriptionEventStore } from './subscriptionEventStore'; +export type { SubscriptionEvent, SubscriptionEventPage, SubscriptionEventQuery, SubscriptionEventType } from './subscriptionEventStore'; +export { ElasticsearchService, elasticsearchService } from './ElasticsearchService'; +export type { SearchQuery, SearchHit, FacetResult, SearchResult, SearchAnalyticsEvent } from './ElasticsearchService'; +export type { ISubscriptionEventStore, IElasticsearchService } from './interfaces'; +export { SubscriptionError } from './errors'; diff --git a/backend/services/subscription/interfaces.ts b/backend/services/subscription/interfaces.ts new file mode 100644 index 00000000..7423784b --- /dev/null +++ b/backend/services/subscription/interfaces.ts @@ -0,0 +1,37 @@ +import { Subscription } from '../../../src/types/subscription'; +import { + SubscriptionEvent, + SubscriptionEventQuery, + SubscriptionEventPage, +} from './subscriptionEventStore'; +import { + SearchQuery, + SearchResult, + SearchAnalyticsEvent, +} from './ElasticsearchService'; + +export interface ISubscriptionEventStore { + append = Record>( + event: Omit, 'id' | 'sequence' | 'occurredAt' | 'schemaVersion'> & + Partial> + ): SubscriptionEvent; + + query(query?: SubscriptionEventQuery): SubscriptionEventPage; + + reconstruct(subscriptionId: string): Record; + + replay(subscriptionId: string, handler: (event: SubscriptionEvent) => void): void; + + archiveBefore(timestamp: number): number; +} + +export interface IElasticsearchService { + indexDocument(subscription: Subscription): void; + bulkIndex(subscriptions: Subscription[]): void; + deleteDocument(id: string): void; + readonly documentCount: number; + search(query: SearchQuery): SearchResult; + getTopQueries(limit?: number): { query: string; count: number }[]; + getAnalyticsEvents(): SearchAnalyticsEvent[]; + clearAnalytics(): void; +} diff --git a/backend/services/subscriptionEventStore.ts b/backend/services/subscription/subscriptionEventStore.ts similarity index 100% rename from backend/services/subscriptionEventStore.ts rename to backend/services/subscription/subscriptionEventStore.ts diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index da0658e7..34d58d3b 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -2,7 +2,9 @@ mod roles; -use roles::{contains_permission, role_permissions, DataKey, Delegation, MultisigAction, MultisigProposal}; +use roles::{ + contains_permission, role_permissions, DataKey, Delegation, MultisigAction, MultisigProposal, +}; use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec}; use subtrackr_types::{Permission, Role, RoleChangeAction, RoleChangeEntry}; @@ -41,7 +43,10 @@ fn save_role_change( } fn get_user_permissions(env: &Env, user: &Address) -> Vec { - let roles_opt: Option> = env.storage().instance().get(&DataKey::UserRoles(user.clone())); + let roles_opt: Option> = env + .storage() + .instance() + .get(&DataKey::UserRoles(user.clone())); let mut all_perms: Vec = Vec::new(env); if let Some(roles) = roles_opt { @@ -108,7 +113,13 @@ impl RoleManager { .instance() .set(&DataKey::MultisigProposalCount, &0u64); - save_role_change(&env, &admin, &Role::Admin, RoleChangeAction::Granted, &admin); + save_role_change( + &env, + &admin, + &Role::Admin, + RoleChangeAction::Granted, + &admin, + ); env.events().publish( (Symbol::new(&env, "access_control_initialized"),), @@ -157,18 +168,10 @@ impl RoleManager { .instance() .set(&DataKey::UserRoles(user.clone()), &user_roles); - save_role_change( - &env, - &user, - &role, - RoleChangeAction::Granted, - &caller, - ); + save_role_change(&env, &user, &role, RoleChangeAction::Granted, &caller); - env.events().publish( - (Symbol::new(&env, "role_granted"),), - (caller, user, role), - ); + env.events() + .publish((Symbol::new(&env, "role_granted"),), (caller, user, role)); } pub fn revoke_role(env: Env, caller: Address, user: Address, role: Role) { @@ -273,22 +276,15 @@ impl RoleManager { } } - save_role_change( - &env, - &user, - &role, - RoleChangeAction::Revoked, - &caller, - ); + save_role_change(&env, &user, &role, RoleChangeAction::Revoked, &caller); - env.events().publish( - (Symbol::new(&env, "role_revoked"),), - (caller, user, role), - ); + env.events() + .publish((Symbol::new(&env, "role_revoked"),), (caller, user, role)); } pub fn has_permission(env: Env, user: Address, permission: Permission) -> bool { - if env.storage() + if env + .storage() .instance() .get::<_, bool>(&DataKey::EmergencyPaused) .unwrap_or(false) @@ -367,10 +363,7 @@ impl RoleManager { "Unauthorized: missing DelegatePermission" ); - let expires_at = env - .ledger() - .timestamp() - .saturating_add(duration_secs); + let expires_at = env.ledger().timestamp().saturating_add(duration_secs); let delegation = Delegation { delegator: delegator.clone(), @@ -388,7 +381,12 @@ impl RoleManager { ); } - pub fn revoke_delegation(env: Env, delegator: Address, delegate: Address, permission: Permission) { + pub fn revoke_delegation( + env: Env, + delegator: Address, + delegate: Address, + permission: Permission, + ) { delegator.require_auth(); let key = DataKey::Delegation(delegate.clone(), permission.clone()); @@ -490,11 +488,7 @@ impl RoleManager { entries } - pub fn propose_multisig_action( - env: Env, - proposer: Address, - action: MultisigAction, - ) -> u64 { + pub fn propose_multisig_action(env: Env, proposer: Address, action: MultisigAction) -> u64 { proposer.require_auth(); assert!( !env.storage() @@ -605,10 +599,7 @@ impl RoleManager { ); let now = env.ledger().timestamp(); - assert!( - now >= proposal.execute_after, - "Timelock not yet elapsed" - ); + assert!(now >= proposal.execute_after, "Timelock not yet elapsed"); match proposal.action { MultisigAction::SetEmergencyAdmin(ref new_admin) => { diff --git a/contracts/api/src/auth.rs b/contracts/api/src/auth.rs index d47304e4..de77655f 100644 --- a/contracts/api/src/auth.rs +++ b/contracts/api/src/auth.rs @@ -107,19 +107,12 @@ pub fn revoke_api_key(env: &Env, caller: Address, key_id: ApiKeyId, now: u64) { key.status = ApiKeyStatus::Revoked; key.revoked_at = now; - env.storage() - .instance() - .set(&DataKey::ApiKey(key_id), &key); + env.storage().instance().set(&DataKey::ApiKey(key_id), &key); log_audit(env, key_id, String::from_str(env, "revoked"), caller, now); } -pub fn rotate_api_key( - env: &Env, - caller: Address, - key_id: ApiKeyId, - now: u64, -) -> Bytes { +pub fn rotate_api_key(env: &Env, caller: Address, key_id: ApiKeyId, now: u64) -> Bytes { let mut key: ApiKey = env .storage() .instance() @@ -135,9 +128,7 @@ pub fn rotate_api_key( let new_hash = hash_key_bytes(env, &new_raw); key.key_hash = new_hash; key.last_used_at = 0; - env.storage() - .instance() - .set(&DataKey::ApiKey(key_id), &key); + env.storage().instance().set(&DataKey::ApiKey(key_id), &key); log_audit(env, key_id, String::from_str(env, "rotated"), caller, now); new_raw @@ -196,13 +187,7 @@ pub fn get_api_key_audit(env: &Env, key_id: ApiKeyId) -> Vec { entries } -fn log_audit( - env: &Env, - key_id: ApiKeyId, - action: String, - changed_by: Address, - now: u64, -) { +fn log_audit(env: &Env, key_id: ApiKeyId, action: String, changed_by: Address, now: u64) { let mut count: u64 = env .storage() .instance() diff --git a/contracts/api/src/lib.rs b/contracts/api/src/lib.rs index 023caa44..f490435c 100644 --- a/contracts/api/src/lib.rs +++ b/contracts/api/src/lib.rs @@ -33,11 +33,7 @@ impl SubTrackrApi { /// Create a new API key. Returns `(key_id, raw_key_bytes)`. /// The raw key is returned exactly once and must be stored off-chain. - pub fn create_api_key( - env: Env, - owner: Address, - config: ApiKeyConfig, - ) -> (ApiKeyId, Bytes) { + pub fn create_api_key(env: Env, owner: Address, config: ApiKeyConfig) -> (ApiKeyId, Bytes) { owner.require_auth(); let now = env.ledger().timestamp(); auth::create_api_key(&env, owner, config, now) diff --git a/contracts/api/src/ratelimit.rs b/contracts/api/src/ratelimit.rs index bdf599ee..b1b5402a 100644 --- a/contracts/api/src/ratelimit.rs +++ b/contracts/api/src/ratelimit.rs @@ -1,7 +1,5 @@ use soroban_sdk::Env; -use subtrackr_types::{ - ApiKey, ApiKeyId, ApiUsageRecord, RateLimitStatus, TimeRange, UsageReport, -}; +use subtrackr_types::{ApiKey, ApiKeyId, ApiUsageRecord, RateLimitStatus, TimeRange, UsageReport}; use crate::DataKey; @@ -20,17 +18,17 @@ fn bump_window(env: &Env, key: DataKey, now: u64, period: u64) -> (u32, u64) { Some(r) if r.window_start == ws => r.count + 1, _ => 1, }; - env.storage() - .instance() - .set(&key, &ApiUsageRecord { window_start: ws, count }); + env.storage().instance().set( + &key, + &ApiUsageRecord { + window_start: ws, + count, + }, + ); (count, ws + period) } -pub fn check_rate_limit( - env: &Env, - key: &ApiKey, - now: u64, -) -> RateLimitStatus { +pub fn check_rate_limit(env: &Env, key: &ApiKey, now: u64) -> RateLimitStatus { let cfg = &key.rate_limit; let (min_count, min_reset) = bump_window( @@ -88,11 +86,7 @@ pub fn check_rate_limit( } } -pub fn get_api_usage( - env: &Env, - key_id: ApiKeyId, - period: TimeRange, -) -> UsageReport { +pub fn get_api_usage(env: &Env, key_id: ApiKeyId, period: TimeRange) -> UsageReport { let mut total: u32 = 0; let mut ws = window_start(period.start, SECS_PER_MINUTE); let end = period.end; @@ -114,11 +108,7 @@ pub fn get_api_usage( } } -pub fn calculate_api_charge( - env: &Env, - key: &ApiKey, - period: TimeRange, -) -> i128 { +pub fn calculate_api_charge(env: &Env, key: &ApiKey, period: TimeRange) -> i128 { let usage = get_api_usage(env, key.id, period); let billable = usage.total_requests.saturating_sub(1000); let price_per_k = key.usage_tier.price_per_thousand(); diff --git a/contracts/api/src/test.rs b/contracts/api/src/test.rs index 7de43a6b..6338a8c8 100644 --- a/contracts/api/src/test.rs +++ b/contracts/api/src/test.rs @@ -1,8 +1,6 @@ #![cfg(test)] -use soroban_sdk::{ - testutils::Address as _, testutils::Ledger as _, Address, Bytes, BytesN, Env, -}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Bytes, BytesN, Env}; use subtrackr_types::{ApiKeyConfig, ApiKeyStatus, RateLimitConfig, TimeRange, UsageTier}; use crate::{SubTrackrApi, SubTrackrApiClient}; @@ -158,8 +156,12 @@ fn test_list_api_keys_by_owner() { let mut found1 = false; let mut found2 = false; for k in keys.iter() { - if k.id == id1 { found1 = true; } - if k.id == id2 { found2 = true; } + if k.id == id1 { + found1 = true; + } + if k.id == id2 { + found2 = true; + } } assert!(found1); assert!(found2); @@ -179,7 +181,11 @@ fn test_audit_trail() { client.revoke_api_key(&owner, &key_id); let audit = client.get_api_key_audit(&key_id); - assert_eq!(audit.len(), 2, "Should have rotate and revoke audit entries"); + assert_eq!( + audit.len(), + 2, + "Should have rotate and revoke audit entries" + ); assert_eq!( audit.get(0).unwrap().action, soroban_sdk::String::from_str(&env, "rotated") @@ -268,10 +274,7 @@ fn test_rate_limit_per_hour() { client.check_rate_limit(&key_id, &key_hash); } let status = client.check_rate_limit(&key_id, &key_hash); - assert!( - !status.is_allowed, - "Should be blocked by hourly limit" - ); + assert!(!status.is_allowed, "Should be blocked by hourly limit"); } #[test] diff --git a/contracts/batch/src/lib.rs b/contracts/batch/src/lib.rs index 98f0fb7e..263197b7 100644 --- a/contracts/batch/src/lib.rs +++ b/contracts/batch/src/lib.rs @@ -1,9 +1,7 @@ #![no_std] #![allow(clippy::too_many_arguments)] -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, Vec, -}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Vec}; const MAX_BATCH_ITEMS: u32 = 100; const GAS_BASE: u64 = 50_000; @@ -136,7 +134,11 @@ impl SubTrackrBatch { return Err(BatchError::InvalidBatch); } - let mut count: u64 = env.storage().instance().get(&DataKey::BatchCount).unwrap_or(0); + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::BatchCount) + .unwrap_or(0); count += 1; env.storage().instance().set(&DataKey::BatchCount, &count); @@ -152,9 +154,12 @@ impl SubTrackrBatch { env.storage() .persistent() .set(&DataKey::BatchExecuted(count), &false); - env.storage() - .persistent() - .set(&DataKey::BatchStatus(count), &BatchStatus { state: BatchState::Pending }); + env.storage().persistent().set( + &DataKey::BatchStatus(count), + &BatchStatus { + state: BatchState::Pending, + }, + ); let mut history: Vec = env.storage().instance().get(&DataKey::History).unwrap(); history.push_back(count); @@ -214,7 +219,10 @@ impl SubTrackrBatch { let idx: u32 = i as u32; match op.operation_type { OperationType::Create => { - let sub = SubscriptionRecord { id: sub_id, charged: 0 }; + let sub = SubscriptionRecord { + id: sub_id, + charged: 0, + }; if atomic { staged.push_back(sub); } else { @@ -261,12 +269,6 @@ impl SubTrackrBatch { .persistent() .set(&DataKey::Subscription(sub.id), &sub); } - _ => OperationResult { - subscription_id, - success: false, - code: 5, - reason: Some(String::from_small_str("SubscriptionMissing")), - }, } let state = if rolled_back { diff --git a/contracts/credit/src/lib.rs b/contracts/credit/src/lib.rs index c34fa55f..ea5c8db8 100644 --- a/contracts/credit/src/lib.rs +++ b/contracts/credit/src/lib.rs @@ -167,7 +167,14 @@ impl SubTrackrCredit { }; account.lots.push_back(lot); account.balance += amount; - Self::record(&env, &mut account, CreditTxKind::Issue, amount, reason, None); + Self::record( + &env, + &mut account, + CreditTxKind::Issue, + amount, + reason, + None, + ); Self::save(&env, &account); env.events() .publish((symbol_short!("issue"), subscriber), amount); @@ -193,7 +200,14 @@ impl SubTrackrCredit { if applied > 0 { account.balance -= applied; let reason = String::from_str(&env, "charge_application"); - Self::record(&env, &mut account, CreditTxKind::Apply, -applied, reason, None); + Self::record( + &env, + &mut account, + CreditTxKind::Apply, + -applied, + reason, + None, + ); } Self::save(&env, &account); @@ -251,7 +265,14 @@ impl SubTrackrCredit { }, }); recipient.balance += moved; - Self::record(&env, &mut recipient, CreditTxKind::TransferIn, moved, reason, Some(from)); + Self::record( + &env, + &mut recipient, + CreditTxKind::TransferIn, + moved, + reason, + Some(from), + ); Self::save(&env, &recipient); Ok(()) } @@ -356,7 +377,14 @@ impl SubTrackrCredit { if expired_total > 0 { account.balance -= expired_total; let reason = String::from_str(env, "expired"); - Self::record(env, account, CreditTxKind::Expire, -expired_total, reason, None); + Self::record( + env, + account, + CreditTxKind::Expire, + -expired_total, + reason, + None, + ); } } diff --git a/contracts/metering/src/lib.rs b/contracts/metering/src/lib.rs index 6a566c47..7737abc7 100644 --- a/contracts/metering/src/lib.rs +++ b/contracts/metering/src/lib.rs @@ -126,7 +126,8 @@ impl SubTrackrMetering { state.last_timestamp = now; Self::add_to_bucket(&mut state, now, value); - if state.alert_threshold != 0 && !state.alert_fired && state.total >= state.alert_threshold { + if state.alert_threshold != 0 && !state.alert_fired && state.total >= state.alert_threshold + { state.alert_fired = true; env.events().publish( (symbol_short!("usage_alt"), subscription_id, meter.clone()), @@ -142,8 +143,10 @@ impl SubTrackrMetering { value, timestamp: now, }; - env.events() - .publish((symbol_short!("usage"), subscription_id), observation.clone()); + env.events().publish( + (symbol_short!("usage"), subscription_id), + observation.clone(), + ); Ok(observation) } @@ -227,7 +230,10 @@ impl SubTrackrMetering { return; } } - state.buckets.push_back(UsageBucket { start, units: value }); + state.buckets.push_back(UsageBucket { + start, + units: value, + }); while state.buckets.len() > MAX_BUCKETS { state.buckets.remove(0); } @@ -275,6 +281,8 @@ impl SubTrackrMetering { i += 1; } metrics.push_back(metric.clone()); - env.storage().persistent().set(&DataKey::Meters(sub), &metrics); + env.storage() + .persistent() + .set(&DataKey::Meters(sub), &metrics); } } diff --git a/contracts/metering/src/test.rs b/contracts/metering/src/test.rs index 1e6caf2b..25bf9164 100644 --- a/contracts/metering/src/test.rs +++ b/contracts/metering/src/test.rs @@ -71,7 +71,10 @@ fn supports_multiple_meters_and_charges() { let meters = client.get_meters(&7); assert_eq!(meters.len(), 2); - let period = TimeRange { start: 0, end: 100_000 }; + let period = TimeRange { + start: 0, + end: 100_000, + }; let charge = client.calculate_usage_charge(&7, &period); assert_eq!(charge.total, 120); assert_eq!(charge.lines.len(), 2); @@ -89,7 +92,13 @@ fn charge_excludes_usage_outside_period() { client.record_metered_usage(&reporter, &1, &api, &7); // bucket @97_200 // Period covering only the first bucket. - let charge = client.calculate_usage_charge(&1, &TimeRange { start: 0, end: 50_000 }); + let charge = client.calculate_usage_charge( + &1, + &TimeRange { + start: 0, + end: 50_000, + }, + ); assert_eq!(charge.total, 10); } @@ -111,6 +120,12 @@ fn rejects_inverted_period() { let (env, client, reporter) = setup(); let api = Symbol::new(&env, "api_calls"); client.register_meter(&reporter, &1, &api, &1, &0, &86_400, &0); - let res = client.try_calculate_usage_charge(&1, &TimeRange { start: 100, end: 50 }); + let res = client.try_calculate_usage_charge( + &1, + &TimeRange { + start: 100, + end: 50, + }, + ); assert_eq!(res, Err(Ok(MeteringError::InvalidPeriod))); } diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 4e878e6b..a1fc34ca 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -18,9 +18,13 @@ mod price; -pub use price::{deviation_bps, is_stale, select_price, CircuitState, FeedConfig, Price, PriceSource}; +pub use price::{ + deviation_bps, is_stale, select_price, CircuitState, FeedConfig, Price, PriceSource, +}; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, +}; /// Number of consecutive faults that trips a feed's circuit breaker. const CIRCUIT_FAULT_LIMIT: u32 = 3; @@ -162,8 +166,10 @@ impl SubTrackrOracle { if circuit.consecutive_faults >= CIRCUIT_FAULT_LIMIT && !circuit.tripped { circuit.tripped = true; circuit.tripped_at = now; - env.events() - .publish((symbol_short!("breaker"), token.clone(), quote.clone()), now); + env.events().publish( + (symbol_short!("breaker"), token.clone(), quote.clone()), + now, + ); } } else { circuit.consecutive_faults = 0; @@ -321,9 +327,11 @@ impl SubTrackrOracle { } fn latest(env: &Env, token: &Symbol, quote: &Symbol, source: &PriceSource) -> Option { - env.storage() - .persistent() - .get(&DataKey::Latest(token.clone(), quote.clone(), source.clone())) + env.storage().persistent().get(&DataKey::Latest( + token.clone(), + quote.clone(), + source.clone(), + )) } fn circuit(env: &Env, token: &Symbol, quote: &Symbol) -> CircuitState { diff --git a/contracts/oracle/src/test.rs b/contracts/oracle/src/test.rs index 06f37a43..02a5774d 100644 --- a/contracts/oracle/src/test.rs +++ b/contracts/oracle/src/test.rs @@ -87,7 +87,15 @@ fn falls_back_when_primary_is_stale() { let primary = Address::generate(&env); let fallback = Address::generate(&env); set_time(&env, 1_000); - client.register_feed(&token, &usd, &primary, &Some(fallback.clone()), &300, &10_000, &7); + client.register_feed( + &token, + &usd, + &primary, + &Some(fallback.clone()), + &300, + &10_000, + &7, + ); client.submit_price(&primary, &token, &usd, &1_000_000, &1_000); // Fresh fallback observation while the primary ages out. @@ -166,8 +174,14 @@ fn historical_lookup_returns_at_or_before() { client.submit_price(&primary, &token, &usd, &1_100_000, &2_000); client.submit_price(&primary, &token, &usd, &1_200_000, &3_000); - assert_eq!(client.get_historical_price(&token, &usd, &2_500).value, 1_100_000); - assert_eq!(client.get_historical_price(&token, &usd, &3_000).value, 1_200_000); + assert_eq!( + client.get_historical_price(&token, &usd, &2_500).value, + 1_100_000 + ); + assert_eq!( + client.get_historical_price(&token, &usd, &3_000).value, + 1_200_000 + ); let res = client.try_get_historical_price(&token, &usd, &500); assert_eq!(res, Err(Ok(OracleError::NoHistory))); } diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs index 08da23be..86d63081 100644 --- a/contracts/proxy/src/lib.rs +++ b/contracts/proxy/src/lib.rs @@ -398,11 +398,7 @@ impl UpgradeableProxy { invoke_impl( &env, "get_max_plans_per_merchant", - soroban_sdk::vec![ - &env, - proxy_addr.into_val(&env), - storage_addr.into_val(&env) - ], + soroban_sdk::vec![&env, proxy_addr.into_val(&env), storage_addr.into_val(&env)], ) } diff --git a/contracts/proxy/tests/integration_soroban.rs b/contracts/proxy/tests/integration_soroban.rs index 78f7a3e8..908442d7 100644 --- a/contracts/proxy/tests/integration_soroban.rs +++ b/contracts/proxy/tests/integration_soroban.rs @@ -226,10 +226,28 @@ fn integration_plan_limit_blocks_third_plan() { let token_id = env.register_stellar_asset_contract_v2(token_admin); let name = String::from_str(&env, "Limited Plan"); - proxy.create_plan(&merchant, &name, &500, &token_id.address(), &Interval::Monthly); - proxy.create_plan(&merchant, &name, &600, &token_id.address(), &Interval::Monthly); + proxy.create_plan( + &merchant, + &name, + &500, + &token_id.address(), + &Interval::Monthly, + ); + proxy.create_plan( + &merchant, + &name, + &600, + &token_id.address(), + &Interval::Monthly, + ); - let res = proxy.try_create_plan(&merchant, &name, &700, &token_id.address(), &Interval::Monthly); + let res = proxy.try_create_plan( + &merchant, + &name, + &700, + &token_id.address(), + &Interval::Monthly, + ); assert!(res.is_err()); } @@ -242,9 +260,27 @@ fn integration_lowering_plan_limit_does_not_affect_existing_plans() { let token_id = env.register_stellar_asset_contract_v2(token_admin); let name = String::from_str(&env, "Plan"); - let p1 = proxy.create_plan(&merchant, &name, &100, &token_id.address(), &Interval::Monthly); - let p2 = proxy.create_plan(&merchant, &name, &200, &token_id.address(), &Interval::Monthly); - let p3 = proxy.create_plan(&merchant, &name, &300, &token_id.address(), &Interval::Monthly); + let p1 = proxy.create_plan( + &merchant, + &name, + &100, + &token_id.address(), + &Interval::Monthly, + ); + let p2 = proxy.create_plan( + &merchant, + &name, + &200, + &token_id.address(), + &Interval::Monthly, + ); + let p3 = proxy.create_plan( + &merchant, + &name, + &300, + &token_id.address(), + &Interval::Monthly, + ); proxy.set_max_plans_per_merchant(&2u32); @@ -252,6 +288,12 @@ fn integration_lowering_plan_limit_does_not_affect_existing_plans() { assert!(proxy.get_plan(&p2).active); assert!(proxy.get_plan(&p3).active); - let res = proxy.try_create_plan(&merchant, &name, &400, &token_id.address(), &Interval::Monthly); + let res = proxy.try_create_plan( + &merchant, + &name, + &400, + &token_id.address(), + &Interval::Monthly, + ); assert!(res.is_err()); } diff --git a/contracts/security/src/lib.rs b/contracts/security/src/lib.rs index 987336fd..addc89a8 100644 --- a/contracts/security/src/lib.rs +++ b/contracts/security/src/lib.rs @@ -141,7 +141,12 @@ impl SubTrackrSecurity { Self::require_authorized(&env, &caller); let key = Self::load_key(&env, encrypted.key_version); - let plaintext = xor_crypt(&env, &key.key_material, &encrypted.nonce, &encrypted.ciphertext); + let plaintext = xor_crypt( + &env, + &key.key_material, + &encrypted.nonce, + &encrypted.ciphertext, + ); let mac = compute_mac(&env, &key.key_material, &encrypted.nonce, &plaintext); assert!( mac == encrypted.mac, @@ -251,8 +256,12 @@ impl SubTrackrSecurity { // Decrypt under the original key version (verifying integrity)... let old_key = Self::load_key(&env, encrypted.key_version); - let plaintext = - xor_crypt(&env, &old_key.key_material, &encrypted.nonce, &encrypted.ciphertext); + let plaintext = xor_crypt( + &env, + &old_key.key_material, + &encrypted.nonce, + &encrypted.ciphertext, + ); let check = compute_mac(&env, &old_key.key_material, &encrypted.nonce, &plaintext); assert!( check == encrypted.mac, diff --git a/contracts/subscription/src/gas_optimization.rs b/contracts/subscription/src/gas_optimization.rs index d20b8996..679b472e 100644 --- a/contracts/subscription/src/gas_optimization.rs +++ b/contracts/subscription/src/gas_optimization.rs @@ -113,20 +113,56 @@ impl GasOptimizationTargets { pub fn all_targets(env: &Env) -> Vec<(String, u64)> { soroban_sdk::vec![ env, - (String::from_str(env, "initialize"), Self::initialize_target()), - (String::from_str(env, "create_plan"), Self::create_plan_target()), + ( + String::from_str(env, "initialize"), + Self::initialize_target() + ), + ( + String::from_str(env, "create_plan"), + Self::create_plan_target() + ), (String::from_str(env, "subscribe"), Self::subscribe_target()), - (String::from_str(env, "charge_subscription"), Self::charge_subscription_target()), - (String::from_str(env, "cancel_subscription"), Self::cancel_subscription_target()), - (String::from_str(env, "pause_subscription"), Self::pause_subscription_target()), - (String::from_str(env, "resume_subscription"), Self::resume_subscription_target()), - (String::from_str(env, "request_refund"), Self::request_refund_target()), - (String::from_str(env, "approve_refund"), Self::approve_refund_target()), - (String::from_str(env, "request_transfer"), Self::request_transfer_target()), - (String::from_str(env, "accept_transfer"), Self::accept_transfer_target()), + ( + String::from_str(env, "charge_subscription"), + Self::charge_subscription_target() + ), + ( + String::from_str(env, "cancel_subscription"), + Self::cancel_subscription_target() + ), + ( + String::from_str(env, "pause_subscription"), + Self::pause_subscription_target() + ), + ( + String::from_str(env, "resume_subscription"), + Self::resume_subscription_target() + ), + ( + String::from_str(env, "request_refund"), + Self::request_refund_target() + ), + ( + String::from_str(env, "approve_refund"), + Self::approve_refund_target() + ), + ( + String::from_str(env, "request_transfer"), + Self::request_transfer_target() + ), + ( + String::from_str(env, "accept_transfer"), + Self::accept_transfer_target() + ), (String::from_str(env, "get_plan"), Self::get_plan_target()), - (String::from_str(env, "get_subscription"), Self::get_subscription_target()), - (String::from_str(env, "get_user_subscriptions"), Self::get_user_subscriptions_target()), + ( + String::from_str(env, "get_subscription"), + Self::get_subscription_target() + ), + ( + String::from_str(env, "get_user_subscriptions"), + Self::get_user_subscriptions_target() + ), ] } } @@ -136,7 +172,11 @@ pub struct GasOptimizations; impl GasOptimizations { /// Get optimization recommendations for a specific function - pub fn get_recommendations_for_function(env: &Env, function_name: &str, current_gas: u64) -> Vec { + pub fn get_recommendations_for_function( + env: &Env, + function_name: &str, + current_gas: u64, + ) -> Vec { let mut recommendations = Vec::new(env); match function_name { @@ -193,7 +233,10 @@ impl GasOptimizations { } } _ => { - recommendations.push_back(String::from_str(env, "Monitor function for optimization opportunities")); + recommendations.push_back(String::from_str( + env, + "Monitor function for optimization opportunities", + )); } } @@ -277,7 +320,7 @@ pub fn get_optimization_priorities( /// Best practices for gas efficiency pub mod best_practices { - use soroban_sdk::{String, Vec, Env}; + use soroban_sdk::{Env, String, Vec}; pub fn get_storage_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); @@ -351,10 +394,7 @@ pub mod best_practices { pub fn get_validation_best_practices(env: &Env) -> Vec { let mut practices = Vec::new(env); - practices.push_back(String::from_str( - env, - "Validate inputs early to fail fast", - )); + practices.push_back(String::from_str(env, "Validate inputs early to fail fast")); practices.push_back(String::from_str( env, "Use assertions for critical validations", diff --git a/contracts/subscription/src/gas_profiler.rs b/contracts/subscription/src/gas_profiler.rs index 6ffb16e8..6fc8b3d6 100644 --- a/contracts/subscription/src/gas_profiler.rs +++ b/contracts/subscription/src/gas_profiler.rs @@ -28,10 +28,10 @@ pub struct GasMetrics { /// Function complexity categories pub enum FunctionCategory { - Read, // Simple read operations, < 50k gas - Write, // Storage write operations, 50k-150k gas - Transfer, // Token transfers, 100k-200k gas - Complex, // Multi-step operations, > 200k gas + Read, // Simple read operations, < 50k gas + Write, // Storage write operations, 50k-150k gas + Transfer, // Token transfers, 100k-200k gas + Complex, // Multi-step operations, > 200k gas } impl FunctionCategory { @@ -64,14 +64,14 @@ impl FunctionCategory { /// Storage keys for gas profiling data pub enum GasStorageKey { - Profile(String), // Function name -> GasProfile - Metrics(String), // Function name -> GasMetrics - DailyGasUsage(u64), // day timestamp -> total gas - WeeklyGasUsage(u64), // week timestamp -> total gas - MonthlyGasUsage(u64), // month timestamp -> total gas - TotalGasUsed, // u64: cumulative gas used - CallCount, // u64: total number of calls - GasAlertTriggered(String, u64), // alert type -> count + Profile(String), // Function name -> GasProfile + Metrics(String), // Function name -> GasMetrics + DailyGasUsage(u64), // day timestamp -> total gas + WeeklyGasUsage(u64), // week timestamp -> total gas + MonthlyGasUsage(u64), // month timestamp -> total gas + TotalGasUsed, // u64: cumulative gas used + CallCount, // u64: total number of calls + GasAlertTriggered(String, u64), // alert type -> count } /// Gas profiler implementation @@ -87,17 +87,17 @@ impl GasProfiler { category: FunctionCategory, ) { let fname = function_name.clone(); - + // Record function profile Self::update_profile(env, storage, &fname, gas_used); - + // Update daily/weekly/monthly tracking let now = env.ledger().timestamp(); Self::update_time_series(env, storage, now, gas_used); - + // Check if gas usage exceeds thresholds Self::check_gas_thresholds(env, storage, &fname, gas_used, category); - + // Update total counters Self::increment_counters(env, storage, gas_used); } @@ -105,7 +105,7 @@ impl GasProfiler { /// Update function profile statistics fn update_profile(env: &Env, storage: &Address, function_name: &String, gas_used: u64) { let key = GasStorageKey::Profile(function_name.clone()); - + let mut profile: GasProfile = match Self::get_profile(env, storage, function_name) { Some(p) => p, None => GasProfile { @@ -121,8 +121,16 @@ impl GasProfiler { profile.call_count += 1; profile.total_gas += gas_used; - profile.min_gas = if gas_used < profile.min_gas { gas_used } else { profile.min_gas }; - profile.max_gas = if gas_used > profile.max_gas { gas_used } else { profile.max_gas }; + profile.min_gas = if gas_used < profile.min_gas { + gas_used + } else { + profile.min_gas + }; + profile.max_gas = if gas_used > profile.max_gas { + gas_used + } else { + profile.max_gas + }; profile.avg_gas = profile.total_gas / profile.call_count; profile.last_updated = env.ledger().timestamp(); @@ -130,11 +138,7 @@ impl GasProfiler { } /// Get gas profile for a function - pub fn get_profile( - env: &Env, - storage: &Address, - function_name: &String, - ) -> Option { + pub fn get_profile(env: &Env, storage: &Address, function_name: &String) -> Option { // This would retrieve from storage // Simplified for demonstration None @@ -169,13 +173,19 @@ impl GasProfiler { if gas_used > error_threshold { // Trigger error alert env.events().publish( - (String::from_str(env, "gas_error_alert"), function_name.clone()), + ( + String::from_str(env, "gas_error_alert"), + function_name.clone(), + ), (gas_used, error_threshold, category.to_string()), ); } else if gas_used > warning_threshold { // Trigger warning alert env.events().publish( - (String::from_str(env, "gas_warning_alert"), function_name.clone()), + ( + String::from_str(env, "gas_warning_alert"), + function_name.clone(), + ), (gas_used, warning_threshold, category.to_string()), ); } @@ -234,10 +244,7 @@ impl GasProfiler { } /// Get optimization recommendations - pub fn get_optimization_recommendations( - env: &Env, - storage: &Address, - ) -> Vec { + pub fn get_optimization_recommendations(env: &Env, storage: &Address) -> Vec { // Returns array of optimization suggestions based on profiling data soroban_sdk::vec![env] } @@ -290,8 +297,8 @@ impl Drop for GasTrackGuard { fn drop(&mut self) { // Record gas usage on scope exit let start = self.env.ledger().timestamp(); - let end = self.env.ledger().sequence(); - let gas_delta = end - start as u32; // Simplified for demonstration + let end = self.env.ledger().sequence(); + let gas_delta = end - start as u32; // Simplified for demonstration GasProfiler::record_call( &self.env, &self.storage, diff --git a/contracts/subscription/src/gas_storage.rs b/contracts/subscription/src/gas_storage.rs index 68b1c46d..cd34ad57 100644 --- a/contracts/subscription/src/gas_storage.rs +++ b/contracts/subscription/src/gas_storage.rs @@ -2,8 +2,8 @@ #![allow(unused_variables)] //! Gas Storage Module //! Manages storage and retrieval of gas profiling metrics. +use crate::gas_profiler::GasProfile; use soroban_sdk::{Address, Env, String as SorobanString}; -use crate::gas_profiler::{GasProfile}; /// Storage keys for gas metrics #[derive(Clone)] @@ -31,11 +31,7 @@ pub struct GasMetricsStorage; impl GasMetricsStorage { /// Store a gas profile for a function - pub fn store_profile( - env: &Env, - storage: &Address, - profile: &GasProfile, - ) { + pub fn store_profile(env: &Env, storage: &Address, profile: &GasProfile) { let key = format_gas_profile_key(env, &profile.function_name); // Serialize and store profile // This would use actual storage @@ -52,12 +48,7 @@ impl GasMetricsStorage { } /// Update daily gas aggregates - pub fn update_daily_aggregate( - env: &Env, - storage: &Address, - day_timestamp: u64, - gas_used: u64, - ) { + pub fn update_daily_aggregate(env: &Env, storage: &Address, day_timestamp: u64, gas_used: u64) { // Increment daily aggregate for the given day } @@ -82,31 +73,19 @@ impl GasMetricsStorage { } /// Get daily gas usage - pub fn get_daily_usage( - env: &Env, - storage: &Address, - day_timestamp: u64, - ) -> u64 { + pub fn get_daily_usage(env: &Env, storage: &Address, day_timestamp: u64) -> u64 { // Retrieve daily aggregate 0 } /// Get weekly gas usage - pub fn get_weekly_usage( - env: &Env, - storage: &Address, - week_timestamp: u64, - ) -> u64 { + pub fn get_weekly_usage(env: &Env, storage: &Address, week_timestamp: u64) -> u64 { // Retrieve weekly aggregate 0 } /// Get monthly gas usage - pub fn get_monthly_usage( - env: &Env, - storage: &Address, - month_timestamp: u64, - ) -> u64 { + pub fn get_monthly_usage(env: &Env, storage: &Address, month_timestamp: u64) -> u64 { // Retrieve monthly aggregate 0 } @@ -124,11 +103,7 @@ impl GasMetricsStorage { } /// Increment total gas used - pub fn increment_total_gas( - env: &Env, - storage: &Address, - gas_amount: u64, - ) { + pub fn increment_total_gas(env: &Env, storage: &Address, gas_amount: u64) { // Increment total gas } @@ -138,43 +113,26 @@ impl GasMetricsStorage { } /// Record gas alert - pub fn record_alert( - env: &Env, - storage: &Address, - alert_type: &str, - ) { + pub fn record_alert(env: &Env, storage: &Address, alert_type: &str) { let alert_key = SorobanString::from_str(env, alert_type); // Increment alert count } /// Get gas alert count by type - pub fn get_alert_count( - env: &Env, - storage: &Address, - alert_type: &str, - ) -> u64 { + pub fn get_alert_count(env: &Env, storage: &Address, alert_type: &str) -> u64 { let alert_key = SorobanString::from_str(env, alert_type); // Retrieve alert count 0 } /// Update last recorded gas usage for a function - pub fn update_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - gas_used: u64, - ) { + pub fn update_last_usage(env: &Env, storage: &Address, function_name: &str, gas_used: u64) { let fname = SorobanString::from_str(env, function_name); // Update last usage } /// Get last recorded gas usage - pub fn get_last_usage( - env: &Env, - storage: &Address, - function_name: &str, - ) -> Option { + pub fn get_last_usage(env: &Env, storage: &Address, function_name: &str) -> Option { let fname = SorobanString::from_str(env, function_name); // Retrieve last usage None @@ -190,9 +148,7 @@ impl GasMetricsStorage { pub fn get_metrics_summary(env: &Env, storage: &Address) -> (u64, u64, u64) { let total_gas = Self::get_total_gas_used(env, storage); let total_calls = Self::get_total_call_count(env, storage); - let avg_gas = total_gas - .checked_div(total_calls) - .unwrap_or(0); + let avg_gas = total_gas.checked_div(total_calls).unwrap_or(0); (total_gas, total_calls, avg_gas) } } diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index c4cccbe9..0b18fa2b 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -644,12 +644,7 @@ impl SubTrackrSubscription { // ── Plan Limit Admin ── - pub fn set_max_plans_per_merchant( - env: Env, - proxy: Address, - storage: Address, - new_limit: u32, - ) { + pub fn set_max_plans_per_merchant(env: Env, proxy: Address, storage: Address, new_limit: u32) { proxy.require_auth(); let admin = get_admin(&env, &storage); require_permission(&env, &storage, &admin, Permission::SetPlanQuotas); @@ -683,9 +678,8 @@ impl SubTrackrSubscription { merchant.require_auth(); assert!(price > 0, "Price must be positive"); - let max_plans: u32 = - storage_instance_get(&env, &storage, StorageKey::MaxPlansPerMerchant) - .unwrap_or(MAX_PLANS_PER_MERCHANT); + let max_plans: u32 = storage_instance_get(&env, &storage, StorageKey::MaxPlansPerMerchant) + .unwrap_or(MAX_PLANS_PER_MERCHANT); assert!(max_plans > 0, "Max plans per merchant must be > 0"); let mut count: u64 = @@ -1352,7 +1346,6 @@ impl SubTrackrSubscription { proxy.require_auth(); storage_instance_get(&env, &storage, StorageKey::SubscriptionCount).unwrap_or(0) } - } // ── Extended APIs (disabled by default) ── @@ -1360,7 +1353,6 @@ impl SubTrackrSubscription { // These APIs depend on additional modules/types that are still evolving. // Enable with `--features extended` in the `subtrackr-subscription` crate. #[cfg(feature = "extended")] -#[soroban_sdk::contractimpl] impl SubTrackrSubscription { // ── Revenue Recognition API ── diff --git a/developer-portal/index.ts b/developer-portal/index.ts index 7d298d65..80b00c4c 100644 --- a/developer-portal/index.ts +++ b/developer-portal/index.ts @@ -2,7 +2,15 @@ export { DeveloperPortalService } from './services/portalService'; export { IntegrationGuidesService } from './services/integrationGuidesService'; export { DeveloperOnboarding } from './components/DeveloperOnboarding'; export { ApiKeyManager } from './components/ApiKeyManager'; -export { DashboardPage, ApiKeysPage, DocumentationPage, UsagePage, OnboardingPage } from './pages'; +export { + DashboardPage, + ApiKeysPage, + DocumentationPage, + UsagePage, + OnboardingPage, + MigrationPage, + SandboxSettingsPage, +} from './pages'; export type { PortalUser, PortalDashboard, diff --git a/developer-portal/pages/MigrationPage.tsx b/developer-portal/pages/MigrationPage.tsx new file mode 100644 index 00000000..be0bed18 --- /dev/null +++ b/developer-portal/pages/MigrationPage.tsx @@ -0,0 +1,669 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { migrationService, MigrationPlan } from '../../src/services/sandbox/migrationService'; + +interface MigrationPageProps { + environmentId?: string; + environmentName?: string; + onComplete: () => void; + onBack: () => void; +} + +export const MigrationPage: React.FC = ({ + environmentId = 'sandbox_dev_001', + environmentName = 'Development Sandbox', + onComplete, + onBack, +}) => { + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [activeStep, setActiveStep] = useState(null); + const [stepLoading, setStepLoading] = useState(null); + + useEffect(() => { + loadOrCreatePlan(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadOrCreatePlan = async () => { + setLoading(true); + try { + const existing = migrationService.getCurrentPlan(); + if (existing) { + setPlan(existing); + } else { + const newPlan = await migrationService.createMigrationPlan(environmentId, environmentName); + setPlan(newPlan); + } + } catch (error) { + Alert.alert('Error', 'Failed to load migration plan'); + } finally { + setLoading(false); + } + }; + + const handleStartValidation = async () => { + if (!plan) return; + setStepLoading('validation'); + try { + const updated = await migrationService.startValidation(); + setPlan({ ...updated! }); + } finally { + setStepLoading(null); + } + }; + + const handleExecuteStep = async (stepId: string) => { + if (!plan) return; + setStepLoading(stepId); + try { + const step = await migrationService.executeStep(stepId); + if (step) { + // Refresh plan + const current = migrationService.getCurrentPlan(); + setPlan(current ? { ...current } : null); + } + } finally { + setStepLoading(null); + } + }; + + const handleCompleteMigration = async () => { + Alert.alert( + 'Go Live to Production?', + 'This will transition your sandbox configuration to production. This action cannot be easily undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Go Live', + style: 'destructive', + onPress: async () => { + setLoading(true); + try { + const result = await migrationService.completeMigration(); + if (result.success) { + Alert.alert( + '🎉 Migration Complete!', + 'Your configuration has been migrated to production.\n\n' + + 'Monitor your production traffic for the first 24 hours.', + [{ text: 'OK', onPress: onComplete }] + ); + } else { + Alert.alert('Migration Failed', result.errors.join('\n')); + } + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const handleToggleChecklist = async (stepId: string, itemId: string, currentStatus: string) => { + if (!plan) return; + const newStatus = + currentStatus === 'failed' ? 'passed' : currentStatus === 'pending' ? 'passed' : 'pending'; + await migrationService.updateChecklistItem( + stepId, + itemId, + newStatus as 'pending' | 'passed' | 'failed' + ); + const current = migrationService.getCurrentPlan(); + setPlan(current ? { ...current } : null); + }; + + const getStatusColor = (status: string): string => { + switch (status) { + case 'completed': + case 'passed': + return '#10B981'; + case 'in_progress': + return '#F59E0B'; + case 'failed': + return '#EF4444'; + case 'pending': + return '#6B7280'; + default: + return '#6B7280'; + } + }; + + const getStatusIcon = (status: string): string => { + switch (status) { + case 'completed': + case 'passed': + return '✅'; + case 'in_progress': + return '🔄'; + case 'failed': + return '❌'; + case 'pending': + return '⏳'; + case 'skipped': + return '⏭️'; + default: + return '⬜'; + } + }; + + const getSeverityBadge = (severity: string) => { + switch (severity) { + case 'critical': + return { label: 'CRITICAL', color: '#EF4444', bg: '#FEE2E2' }; + case 'warning': + return { label: 'WARNING', color: '#F59E0B', bg: '#FEF3C7' }; + case 'info': + return { label: 'INFO', color: '#3B82F6', bg: '#DBEAFE' }; + default: + return { label: 'UNKNOWN', color: '#6B7280', bg: '#F3F4F6' }; + } + }; + + if (loading && !plan) { + return ( + + + Loading migration plan... + + ); + } + + if (!plan) { + return ( + + No migration plan available. + + Retry + + + ← Back to Dashboard + + + ); + } + + return ( + + {/* Header */} + + + ← Back + + 🚀 Migration Wizard + Sandbox → Production: {plan.sourceEnvironmentName} + + + {/* Progress */} + + Migration Progress + + 0 + ? (plan.summary.completedSteps / plan.summary.totalSteps) * 100 + : 0 + }%`, + }, + ]} + /> + + + {plan.summary.completedSteps} / {plan.summary.totalSteps} steps completed + {' '}|{' '} + {plan.summary.passedChecks} / {plan.summary.totalChecks} checks passed + {plan.summary.criticalFailures > 0 && ( + + {' ⚠️ '} + {plan.summary.criticalFailures} critical failure(s) + + )} + + + + {/* Plan Status */} + + Plan Status: + + + {plan.status.toUpperCase()} + + + {plan.status === 'draft' && ( + + {stepLoading === 'validation' ? ( + + ) : ( + Start Validation + )} + + )} + + + {/* Steps */} + {plan.steps.map((step, index) => ( + + setActiveStep(activeStep === step.id ? null : step.id)}> + + + {step.order} + + + {step.title} + {step.description} + + + + {getStatusIcon(step.status)} + {step.status === 'completed' && ( + {activeStep === step.id ? '▲' : '▼'} + )} + {step.status === 'pending' && plan.status === 'ready' && ( + handleExecuteStep(step.id)} + disabled={stepLoading === step.id}> + {stepLoading === step.id ? ( + + ) : ( + Run + )} + + )} + {step.status === 'completed' && index < plan.steps.length - 1 && ( + {activeStep === step.id ? '▲' : '▼'} + )} + + + + {/* Checklist */} + {activeStep === step.id && step.checklist.length > 0 && ( + + {step.checklist.map((item) => { + const severityBadge = getSeverityBadge(item.severity); + return ( + handleToggleChecklist(step.id, item.id, item.status)}> + + {getStatusIcon(item.status)} + + + {severityBadge.label} + + + + {item.title} + {item.description} + {item.recommendation && ( + + 💡 Recommendation: + {item.recommendation} + + )} + + ); + })} + + )} + + ))} + + {/* Complete Migration Button */} + {plan.status === 'completed' && ( + + 🚀 Go Live to Production + + )} + + {plan.status === 'failed' && ( + + ⚠️ Critical Issues Detected + + Please resolve all critical failures before proceeding to production. Tap on each step + above to review and fix checklist items. + + + Re-run Validation + + + )} + + {/* Bottom spacing */} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + contentContainer: { + padding: 16, + paddingBottom: 40, + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + backgroundColor: '#F9FAFB', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + color: '#6B7280', + }, + errorText: { + fontSize: 16, + color: '#EF4444', + marginBottom: 16, + }, + retryButton: { + backgroundColor: '#6366F1', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + marginBottom: 12, + }, + retryButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 16, + }, + backLink: { + color: '#6366F1', + fontSize: 16, + marginTop: 8, + }, + header: { + marginBottom: 20, + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#111827', + marginTop: 12, + }, + subtitle: { + fontSize: 14, + color: '#6B7280', + marginTop: 4, + }, + progressCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + progressTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + marginBottom: 12, + }, + progressBar: { + height: 8, + backgroundColor: '#E5E7EB', + borderRadius: 4, + overflow: 'hidden', + marginBottom: 8, + }, + progressFill: { + height: '100%', + backgroundColor: '#6366F1', + borderRadius: 4, + }, + progressText: { + fontSize: 13, + color: '#6B7280', + }, + criticalWarning: { + color: '#EF4444', + fontWeight: '600', + }, + statusBar: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + gap: 8, + }, + statusLabel: { + fontSize: 14, + fontWeight: '500', + color: '#374151', + }, + statusBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 12, + fontWeight: '700', + }, + validateButton: { + marginLeft: 'auto', + backgroundColor: '#6366F1', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + validateButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 14, + }, + stepCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + marginBottom: 12, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + stepCardActive: { + borderWidth: 2, + borderColor: '#6366F1', + }, + stepHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + stepHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + gap: 12, + }, + stepHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + stepNumber: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + stepNumberText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 14, + }, + stepInfo: { + flex: 1, + }, + stepTitle: { + fontSize: 16, + fontWeight: '600', + color: '#111827', + }, + stepDescription: { + fontSize: 13, + color: '#6B7280', + marginTop: 2, + }, + statusIcon: { + fontSize: 18, + }, + expandIcon: { + fontSize: 12, + color: '#9CA3AF', + }, + runStepButton: { + backgroundColor: '#10B981', + paddingHorizontal: 14, + paddingVertical: 6, + borderRadius: 6, + }, + runStepButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 13, + }, + checklist: { + borderTopWidth: 1, + borderTopColor: '#E5E7EB', + padding: 16, + paddingTop: 12, + backgroundColor: '#F9FAFB', + }, + checklistItem: { + backgroundColor: '#FFFFFF', + borderRadius: 8, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + checklistHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 6, + }, + checklistStatus: { + fontSize: 16, + }, + severityBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 8, + }, + severityText: { + fontSize: 10, + fontWeight: '700', + }, + checklistTitle: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + checklistDesc: { + fontSize: 12, + color: '#6B7280', + marginTop: 4, + }, + recommendation: { + marginTop: 8, + backgroundColor: '#FEF3C7', + borderRadius: 6, + padding: 8, + }, + recommendationLabel: { + fontSize: 12, + fontWeight: '600', + color: '#92400E', + }, + recommendationText: { + fontSize: 12, + color: '#78350F', + marginTop: 2, + }, + completeButton: { + backgroundColor: '#10B981', + paddingVertical: 16, + borderRadius: 12, + alignItems: 'center', + marginTop: 8, + shadowColor: '#10B981', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, + completeButtonText: { + color: '#FFFFFF', + fontWeight: '700', + fontSize: 18, + }, + failedBanner: { + backgroundColor: '#FEF2F2', + borderRadius: 12, + padding: 16, + marginTop: 8, + borderWidth: 1, + borderColor: '#FECACA', + }, + failedBannerTitle: { + fontSize: 16, + fontWeight: '700', + color: '#991B1B', + marginBottom: 8, + }, + failedBannerText: { + fontSize: 13, + color: '#7F1D1D', + marginBottom: 12, + lineHeight: 18, + }, + retryValidationButton: { + backgroundColor: '#EF4444', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + alignSelf: 'flex-start', + }, + retryValidationText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 14, + }, + bottomSpacer: { + height: 40, + }, +}); diff --git a/developer-portal/pages/OnboardingPage.tsx b/developer-portal/pages/OnboardingPage.tsx index 8d8848a8..e9033ba9 100644 --- a/developer-portal/pages/OnboardingPage.tsx +++ b/developer-portal/pages/OnboardingPage.tsx @@ -253,7 +253,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => {index + 1} )} - + {step.title} @@ -376,7 +376,7 @@ const styles = StyleSheet.create({ color: '#FFFFFF', fontWeight: 'bold', }, - stepInfo: { + stepInfoContainer: { flex: 1, }, stepTitle: { diff --git a/developer-portal/pages/SandboxSettingsPage.tsx b/developer-portal/pages/SandboxSettingsPage.tsx new file mode 100644 index 00000000..d14c70be --- /dev/null +++ b/developer-portal/pages/SandboxSettingsPage.tsx @@ -0,0 +1,748 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + Switch, + ActivityIndicator, +} from 'react-native'; + +interface VirtualBalance { + token: string; + amount: string; + usdValue: number; + icon: string; +} + +interface CleanupConfig { + autoReset: boolean; + resetInterval: 'daily' | 'weekly' | 'monthly'; + revokeExpiredKeys: boolean; + archiveLogs: boolean; + nextScheduledRun: string; +} + +interface LeakageStats { + totalAttempts: number; + blocked: number; + warnings: number; + lastCheck: string; +} + +interface SandboxSettingsPageProps { + environmentId?: string; + environmentName?: string; + onNavigate: (page: string) => void; + onBack: () => void; +} + +export const SandboxSettingsPage: React.FC = ({ + environmentId = 'sbx_dev_001', + environmentName = 'Development Sandbox', + onNavigate: _onNavigate, + onBack, +}) => { + const [balances, setBalances] = useState([ + { token: 'USDC', amount: '10,000.00', usdValue: 10000, icon: '💵' }, + { token: 'ETH', amount: '2.5000', usdValue: 6250, icon: '🔷' }, + { token: 'DAI', amount: '5,000.00', usdValue: 5000, icon: '🟡' }, + { token: 'WBTC', amount: '0.1500', usdValue: 6750, icon: '₿' }, + ]); + + const [cleanup, setCleanup] = useState({ + autoReset: true, + resetInterval: 'weekly', + revokeExpiredKeys: true, + archiveLogs: true, + nextScheduledRun: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }); + + const [leakageStats] = useState({ + totalAttempts: 0, + blocked: 0, + warnings: 0, + lastCheck: new Date().toISOString(), + }); + + const [toppingUp, setToppingUp] = useState(null); + + const totalUsdValue = balances.reduce((sum, b) => sum + b.usdValue, 0); + + const handleTopUp = (token: string) => { + if (Alert.prompt) { + Alert.prompt( + `Top Up ${token}`, + 'Enter virtual amount to add (sandbox only, no real cost):', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Add', + onPress: (value?: string) => { + if (!value || isNaN(parseFloat(value))) { + Alert.alert('Error', 'Please enter a valid number'); + return; + } + setToppingUp(token); + setTimeout(() => { + setBalances((prev) => + prev.map((b) => { + if (b.token === token) { + const addedValue = parseFloat(value); + const tokenPrice = b.usdValue / parseFloat(b.amount.replace(/,/g, '')); + const newAmount = parseFloat(b.amount.replace(/,/g, '')) + addedValue; + return { + ...b, + amount: newAmount.toLocaleString('en-US', { + minimumFractionDigits: token === 'ETH' || token === 'WBTC' ? 4 : 2, + maximumFractionDigits: token === 'ETH' || token === 'WBTC' ? 4 : 2, + }), + usdValue: newAmount * tokenPrice, + }; + } + return b; + }) + ); + setToppingUp(null); + Alert.alert('✅ Balance Updated', `Added ${value} ${token} to virtual balance.`); + }, 500); + }, + }, + ], + 'plain-text', + '1000' + ); + } else { + // Fallback for environments without Alert.prompt + setToppingUp(token); + setTimeout(() => { + setBalances((prev) => + prev.map((b) => { + if (b.token === token) { + const tokenPrice = b.usdValue / parseFloat(b.amount.replace(/,/g, '')); + const newAmount = parseFloat(b.amount.replace(/,/g, '')) + 1000; + return { + ...b, + amount: newAmount.toLocaleString('en-US', { + minimumFractionDigits: token === 'ETH' || token === 'WBTC' ? 4 : 2, + }), + usdValue: newAmount * tokenPrice, + }; + } + return b; + }) + ); + setToppingUp(null); + Alert.alert('✅ Balance Updated', 'Added 1,000 to virtual balance.'); + }, 500); + } + }; + + const handleResetData = () => { + Alert.alert( + 'Reset Sandbox Data', + 'This will clear all test subscriptions, payments, and webhooks. This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reset', + style: 'destructive', + onPress: () => { + Alert.alert('✅ Data Reset', 'Sandbox test data has been cleared successfully.'); + }, + }, + ] + ); + }; + + const handleForceCleanup = () => { + Alert.alert( + '🧹 Cleanup Complete', + 'Sandbox cleanup has been executed. Expired keys revoked, old logs archived.' + ); + }; + + const handleMigrate = () => { + Alert.alert( + 'Migration Wizard', + 'Ready to go to production? The migration wizard will guide you through the process.', + [ + { text: 'Later', style: 'cancel' }, + { + text: 'Start Migration', + onPress: () => { + // Navigation to migration page + Alert.alert('Migration', 'Opening migration wizard...'); + }, + }, + ] + ); + }; + + return ( + + {/* Header */} + + + ← Back + + ⚙️ Sandbox Settings + {environmentName} + + + {/* Environment Info Card */} + + + Environment ID + {environmentId} + + + Status + + + Active + + + + API Version + v1 + + + Rate Limit + 60 req/min + + + + {/* Virtual Balance Section */} + + 💰 Virtual Balance + + Sandbox-only virtual tokens. No real cost — top up anytime. + + + + Total USD Value + + ${totalUsdValue.toLocaleString('en-US', { minimumFractionDigits: 2 })} + + + + {balances.map((balance) => ( + + + {balance.icon} + + {balance.token} + {balance.amount} + + + + + ${balance.usdValue.toLocaleString('en-US', { minimumFractionDigits: 2 })} + + handleTopUp(balance.token)} + disabled={toppingUp === balance.token}> + {toppingUp === balance.token ? ( + + ) : ( + + Top Up + )} + + + + ))} + + + {/* Cleanup Configuration */} + + 🧹 Cleanup Schedule + Automatic data cleanup keeps your sandbox healthy. + + + + Auto Reset Test Data + Regenerate fresh test data on schedule + + setCleanup((prev) => ({ ...prev, autoReset: v }))} + trackColor={{ false: '#D1D5DB', true: '#818CF8' }} + thumbColor={cleanup.autoReset ? '#6366F1' : '#9CA3AF'} + /> + + + + + Reset Interval + How often to run cleanup + + + {(['daily', 'weekly', 'monthly'] as const).map((interval) => ( + setCleanup((prev) => ({ ...prev, resetInterval: interval }))}> + + {interval.charAt(0).toUpperCase() + interval.slice(1)} + + + ))} + + + + + + Revoke Expired Keys + Automatically revoke expired API keys + + setCleanup((prev) => ({ ...prev, revokeExpiredKeys: v }))} + trackColor={{ false: '#D1D5DB', true: '#818CF8' }} + thumbColor={cleanup.revokeExpiredKeys ? '#6366F1' : '#9CA3AF'} + /> + + + + + Archive Old Logs + Archive request logs older than 30 days + + setCleanup((prev) => ({ ...prev, archiveLogs: v }))} + trackColor={{ false: '#D1D5DB', true: '#818CF8' }} + thumbColor={cleanup.archiveLogs ? '#6366F1' : '#9CA3AF'} + /> + + + + Next Scheduled Cleanup: + + {new Date(cleanup.nextScheduledRun).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + })} + + + + + {/* Leakage Prevention Stats */} + + 🛡️ Leakage Prevention + Monitoring for sandbox-to-production data leakage. + + + + {leakageStats.totalAttempts} + Total Checks + + + + {leakageStats.blocked} + + Blocked + + + + {leakageStats.warnings} + + Warnings + + + + {leakageStats.blocked === 0 && leakageStats.warnings === 0 && ( + + + + No leakage detected. Your sandbox is properly isolated. + + + )} + + + {/* Actions */} + + 🔧 Actions + + + 🔄 + + Reset Test Data + + Clear all mock subscriptions, payments, and webhooks + + + + + + + 🧹 + + Run Cleanup Now + + Force immediate cleanup of expired keys and old logs + + + + + + + 🚀 + + Migration Wizard + + Guided process to move from sandbox to production + + + + + + + Alert.alert( + 'Delete Sandbox?', + 'This will permanently delete your sandbox environment and all test data.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => Alert.alert('Deleted', 'Sandbox environment deleted.'), + }, + ] + ) + }> + 🗑️ + + Delete Sandbox + Permanently remove this sandbox environment + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + contentContainer: { + padding: 16, + paddingBottom: 40, + }, + header: { + marginBottom: 20, + }, + backLink: { + color: '#6366F1', + fontSize: 16, + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#111827', + marginTop: 12, + }, + subtitle: { + fontSize: 14, + color: '#6B7280', + marginTop: 4, + }, + infoCard: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#F3F4F6', + }, + infoLabel: { + fontSize: 14, + color: '#6B7280', + }, + infoValue: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + activeBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#D1FAE5', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + gap: 6, + }, + activeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#10B981', + }, + activeText: { + fontSize: 12, + fontWeight: '600', + color: '#065F46', + }, + section: { + marginBottom: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: '#111827', + marginBottom: 4, + }, + sectionDesc: { + fontSize: 13, + color: '#6B7280', + marginBottom: 12, + }, + totalBalance: { + backgroundColor: '#6366F1', + borderRadius: 12, + padding: 16, + marginBottom: 12, + }, + totalLabel: { + fontSize: 13, + color: '#C7D2FE', + marginBottom: 4, + }, + totalAmount: { + fontSize: 28, + fontWeight: '700', + color: '#FFFFFF', + }, + balanceRow: { + backgroundColor: '#FFFFFF', + borderRadius: 10, + padding: 14, + marginBottom: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.03, + shadowRadius: 1, + elevation: 1, + }, + balanceLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + balanceIcon: { + fontSize: 24, + }, + balanceToken: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + }, + balanceAmount: { + fontSize: 13, + color: '#6B7280', + }, + balanceRight: { + alignItems: 'flex-end', + }, + balanceUsd: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + marginBottom: 4, + }, + topUpButton: { + backgroundColor: '#EEF2FF', + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 6, + }, + topUpText: { + fontSize: 12, + fontWeight: '600', + color: '#6366F1', + }, + settingRow: { + backgroundColor: '#FFFFFF', + borderRadius: 10, + padding: 14, + marginBottom: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.03, + shadowRadius: 1, + elevation: 1, + }, + settingInfo: { + flex: 1, + marginRight: 12, + }, + settingLabel: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + settingDesc: { + fontSize: 12, + color: '#6B7280', + marginTop: 2, + }, + intervalButtons: { + flexDirection: 'row', + gap: 6, + }, + intervalButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 6, + backgroundColor: '#F3F4F6', + }, + intervalButtonActive: { + backgroundColor: '#6366F1', + }, + intervalText: { + fontSize: 12, + fontWeight: '500', + color: '#6B7280', + }, + intervalTextActive: { + color: '#FFFFFF', + }, + nextRun: { + backgroundColor: '#FFFBEB', + borderRadius: 8, + padding: 12, + marginTop: 8, + }, + nextRunLabel: { + fontSize: 12, + color: '#92400E', + marginBottom: 2, + }, + nextRunDate: { + fontSize: 14, + fontWeight: '600', + color: '#78350F', + }, + leakageStats: { + flexDirection: 'row', + gap: 12, + marginBottom: 12, + }, + leakageStat: { + flex: 1, + backgroundColor: '#FFFFFF', + borderRadius: 10, + padding: 14, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.03, + shadowRadius: 1, + elevation: 1, + }, + leakageStatNumber: { + fontSize: 24, + fontWeight: '700', + color: '#111827', + }, + leakageStatLabel: { + fontSize: 11, + color: '#6B7280', + marginTop: 4, + }, + cleanBanner: { + backgroundColor: '#D1FAE5', + borderRadius: 8, + padding: 12, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cleanBannerIcon: { + fontSize: 16, + }, + cleanBannerText: { + flex: 1, + fontSize: 12, + color: '#065F46', + }, + actionButton: { + backgroundColor: '#FFFFFF', + borderRadius: 10, + padding: 14, + marginBottom: 8, + flexDirection: 'row', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.03, + shadowRadius: 1, + elevation: 1, + }, + actionButtonIcon: { + fontSize: 24, + marginRight: 12, + }, + actionButtonContent: { + flex: 1, + }, + actionButtonTitle: { + fontSize: 15, + fontWeight: '600', + color: '#111827', + }, + actionButtonDesc: { + fontSize: 12, + color: '#6B7280', + marginTop: 2, + }, + actionArrow: { + fontSize: 18, + color: '#9CA3AF', + }, + dangerButton: { + borderWidth: 1, + borderColor: '#FECACA', + }, + bottomSpacer: { + height: 40, + }, +}); diff --git a/developer-portal/pages/index.ts b/developer-portal/pages/index.ts index 44d05eba..88a060f5 100644 --- a/developer-portal/pages/index.ts +++ b/developer-portal/pages/index.ts @@ -3,3 +3,5 @@ export { ApiKeysPage } from './ApiKeysPage'; export { DocumentationPage } from './DocumentationPage'; export { UsagePage } from './UsagePage'; export { OnboardingPage } from './OnboardingPage'; +export { MigrationPage } from './MigrationPage'; +export { SandboxSettingsPage } from './SandboxSettingsPage'; diff --git a/load-tests/utils/baseline.js b/load-tests/utils/baseline.js index bc1cd942..e7a60a47 100644 --- a/load-tests/utils/baseline.js +++ b/load-tests/utils/baseline.js @@ -5,7 +5,7 @@ // configured tolerance). Returned data is embedded in the generated report and // printed to stdout so CI surfaces regressions even when raw thresholds pass. -import baseline from '../baseline.json'; +const baseline = JSON.parse(open('../baseline.json')); function pct(measured, base) { if (base === 0) return measured === 0 ? 0 : 100; diff --git a/metro.config.js b/metro.config.js index ab1d2f0b..c19cc60c 100644 --- a/metro.config.js +++ b/metro.config.js @@ -2,6 +2,16 @@ const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); +config.transformer = { + ...config.transformer, + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: true, + inlineRequires: true, + }, + }), +}; + config.transformer.hermesEnabled = true; config.transformer.unstable_transformImportMeta = true; diff --git a/package.json b/package.json index c80b7844..e4064ba0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "contracts:fmt": "cd contracts && cargo fmt --check", "contracts:clippy": "cd contracts && cargo clippy --all-targets -- -D warnings", "contracts:build": "cd contracts && cargo build --release", + "contracts:codegen:check": "cd contracts && cargo check --quiet", "contracts:migrate": "./scripts/run-migration.sh", "contracts:migrate:validate": "./scripts/validate-migration.sh", "contracts:migrate:rollback": "./scripts/rollback-migration.sh", @@ -135,7 +136,6 @@ "react-test-renderer": "^19.2.5", "semantic-release": "^24.2.9", "size-limit": "^11.1.4", - "@shopify/metro-serializer-hermes": "^1.0.0", "ts-jest": "^29.4.11", "typechain": "^8.3.2", "typescript": "~5.8.3" diff --git a/sandbox/index.ts b/sandbox/index.ts index f4fb7cac..e5578cbf 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -2,6 +2,13 @@ export { SandboxService, sandboxService } from './services/sandboxService'; export { SandboxIsolationService } from './services/sandboxIsolationService'; export { ApiKeyService } from './services/apiKeyService'; export { UsageTrackingService } from './services/usageTrackingService'; +export { BlockchainMockService, blockchainMockService } from './services/blockchainMockService'; +export { MigrationService, migrationService } from './services/migrationService'; +export { CleanupService, cleanupService } from './services/cleanupService'; +export { + SandboxLeakagePreventionService, + sandboxLeakagePrevention, +} from './services/sandboxLeakagePreventionService'; export { SandboxMiddleware, sandboxMiddleware } from './middleware/sandboxMiddleware'; export { SandboxApi } from './api/sandboxApi'; export { SandboxUtils } from './utils/sandboxUtils'; @@ -44,3 +51,26 @@ export type { TestDataSubscription, TestDataPayment, } from './types/sandbox'; +export type { + MockTransaction, + MockEventLog, + MockContractCall, + MockSubscriptionContract, + BlockchainScenario, +} from './services/blockchainMockService'; +export type { + MigrationPlan, + MigrationStep, + MigrationChecklistItem, + MigrationSummary, + MigrationExport, + MigrationResult, +} from './services/migrationService'; +export type { + CleanupSchedule, + CleanupStrategy, + CleanupResult, + CleanupAction, + CleanupReport, + EnvironmentHealth, +} from './services/cleanupService'; diff --git a/sandbox/services/blockchainMockService.ts b/sandbox/services/blockchainMockService.ts new file mode 100644 index 00000000..1adb0c4d --- /dev/null +++ b/sandbox/services/blockchainMockService.ts @@ -0,0 +1,476 @@ +/** + * BlockchainMockService - Simulates blockchain interactions with zero on-chain costs. + * Provides realistic mock responses for subscription contracts, payment transactions, + * gas estimation, and event simulation for sandbox testing. + */ +// ─── Mock transaction & contract types ──────────────────────────────────────── + +export interface MockTransaction { + id: string; + hash: string; + from: string; + to: string; + value: string; + gasUsed: number; + gasPrice: string; + status: 'pending' | 'confirmed' | 'failed'; + blockNumber: number; + timestamp: Date; + data?: string; + method: string; + logs: MockEventLog[]; +} + +export interface MockEventLog { + address: string; + topics: string[]; + data: string; + blockNumber: number; + transactionHash: string; + eventName: string; + args: Record; +} + +export interface MockContractCall { + contractAddress: string; + method: string; + params: Record; + result: unknown; + gasEstimate: number; + simulated: true; +} + +export interface MockSubscriptionContract { + id: string; + subscriber: string; + merchant: string; + amount: string; + token: string; + interval: 'weekly' | 'monthly' | 'yearly'; + nextPaymentDue: Date; + status: 'active' | 'paused' | 'cancelled'; + createdAt: Date; + lastChargedAt: Date | null; + paymentsMade: number; + totalPayments: number; +} + +export interface BlockchainScenario { + name: string; + description: string; + contractAddress: string; + method: string; + params: Record; + expectedResult: unknown; + shouldFail: boolean; + delayMs: number; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class BlockchainMockService { + private subscriptions: Map = new Map(); + private transactions: MockTransaction[] = []; + private scenarios: BlockchainScenario[] = []; + private blockNumber = 18_500_000; + private gasPrice = '25'; // gwei + + // ── Environment-specific configuration ────────────────────────────────────── + + private readonly ENV_WALLETS: Record = { + development: [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ], + staging: [ + '0x3333333333333333333333333333333333333333', + '0x4444444444444444444444444444444444444444', + ], + testing: ['0x5555555555555555555555555555555555555555'], + }; + + private readonly SUPPORTED_TOKENS = [ + { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 }, + { symbol: 'DAI', address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 }, + { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, + { symbol: 'ETH', address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', decimals: 18 }, + { symbol: 'WBTC', address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 }, + ]; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** Simulate creating a subscription smart contract */ + async createMockSubscription( + subscriber: string, + merchant: string, + amount: string, + token: string = 'USDC', + interval: 'weekly' | 'monthly' | 'yearly' = 'monthly' + ): Promise { + const id = `mc_sub_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + const now = new Date(); + + const contract: MockSubscriptionContract = { + id, + subscriber, + merchant, + amount, + token, + interval, + nextPaymentDue: this.computeNextPaymentDate(now, interval), + status: 'active', + createdAt: now, + lastChargedAt: null, + paymentsMade: 0, + totalPayments: interval === 'yearly' ? 1 : interval === 'monthly' ? 12 : 52, + }; + + this.subscriptions.set(id, contract); + + // Record a mock creation transaction + await this.recordTransaction( + subscriber, + this.getTokenAddress(token), + '0', + 'createSubscription', + { subscriber, merchant, amount, token, interval } + ); + + return contract; + } + + /** Simulate an on-chain payment/charge */ + async mockProcessPayment( + subscriptionId: string, + fromWallet: string + ): Promise { + const contract = this.subscriptions.get(subscriptionId); + if (!contract) { + return this.createFailedTx(fromWallet, 'Subscription not found'); + } + + if (contract.status !== 'active') { + return this.createFailedTx(fromWallet, `Subscription is ${contract.status}`); + } + + const tx = await this.recordTransaction( + fromWallet, + this.getTokenAddress(contract.token), + contract.amount, + 'processPayment', + { subscriptionId, amount: contract.amount, token: contract.token } + ); + + // Update contract state + contract.paymentsMade++; + contract.lastChargedAt = new Date(); + contract.nextPaymentDue = this.computeNextPaymentDate(new Date(), contract.interval); + + if (contract.paymentsMade >= contract.totalPayments) { + contract.status = 'cancelled'; + } + + this.subscriptions.set(subscriptionId, contract); + + return { ...tx, success: tx.status === 'confirmed' }; + } + + /** Simulate cancelling a subscription on-chain */ + async mockCancelSubscription( + subscriptionId: string, + fromWallet: string + ): Promise { + const contract = this.subscriptions.get(subscriptionId); + if (!contract) { + return this.createFailedTx(fromWallet, 'Subscription not found'); + } + + contract.status = 'cancelled'; + this.subscriptions.set(subscriptionId, contract); + + const tx = await this.recordTransaction( + fromWallet, + contract.subscriber, + '0', + 'cancelSubscription', + { subscriptionId } + ); + + return { ...tx, success: true }; + } + + /** Simulate estimating gas for a transaction */ + async mockEstimateGas( + method: string, + _params: Record + ): Promise<{ gasUnits: number; gasPriceGwei: string; estimatedCostUsd: string }> { + const baseGas: Record = { + createSubscription: 180_000, + processPayment: 95_000, + cancelSubscription: 65_000, + updateSubscription: 55_000, + transferTokens: 45_000, + }; + + const gasUnits = (baseGas[method] || 75_000) * (0.8 + Math.random() * 0.4); + const ethPrice = 2000; // mock ETH/USD + const gasCostEth = (gasUnits * parseFloat(this.gasPrice)) / 1e9; + const estimatedCostUsd = (gasCostEth * ethPrice).toFixed(2); + + return { + gasUnits: Math.round(gasUnits), + gasPriceGwei: this.gasPrice, + estimatedCostUsd, + }; + } + + /** Simulate querying a contract's state */ + async mockContractCall( + _contractAddress: string, + method: string, + params: Record = {} + ): Promise { + // Simulate slight network latency + await this.delay(50 + Math.random() * 150); + + let result: unknown; + + switch (method) { + case 'getSubscription': + result = + Array.from(this.subscriptions.values()).find( + (s) => s.subscriber === params.subscriber || s.merchant === params.merchant + ) || null; + break; + case 'getBalance': + result = { + wallet: params.wallet, + balance: (Math.random() * 10000).toFixed(4), + token: params.token || 'USDC', + }; + break; + case 'getTransaction': + result = this.transactions.find((t) => t.hash === params.hash) || null; + break; + default: + result = { simulated: true, method, params }; + } + + return { + contractAddress: _contractAddress, + method, + params, + result, + gasEstimate: 0, // view calls don't consume gas + simulated: true, + }; + } + + /** Simulate listening for blockchain events */ + async mockListenForEvents( + eventName: string, + _filterParams: Record = {} + ): Promise { + await this.delay(100); + + return this.transactions + .flatMap((tx) => tx.logs) + .filter((log) => log.eventName === eventName) + .slice(-10); + } + + /** Get all mock transactions for an environment */ + getTransactionHistory(wallet?: string, limit: number = 50): MockTransaction[] { + let filtered = this.transactions; + if (wallet) { + filtered = filtered.filter((tx) => tx.from === wallet); + } + return filtered.slice(-limit).reverse(); + } + + /** Get a specific mock subscription */ + getMockSubscription(subscriptionId: string): MockSubscriptionContract | null { + return this.subscriptions.get(subscriptionId) || null; + } + + /** List all mock subscriptions for a wallet */ + getMockSubscriptionsByWallet(wallet: string): MockSubscriptionContract[] { + return Array.from(this.subscriptions.values()).filter( + (s) => s.subscriber === wallet || s.merchant === wallet + ); + } + + // ── Scenario-based testing ────────────────────────────────────────────────── + + /** Register a test scenario for deterministic mock responses */ + registerScenario(scenario: BlockchainScenario): void { + this.scenarios.push(scenario); + } + + /** Execute a named test scenario */ + async executeScenario(name: string): Promise { + const scenario = this.scenarios.find((s) => s.name === name); + if (!scenario) { + throw new Error(`Scenario "${name}" not found`); + } + + await this.delay(scenario.delayMs); + + if (scenario.shouldFail) { + throw new Error(`Scenario "${name}" failed intentionally`); + } + + // Record a mock transaction for the scenario + await this.recordTransaction( + '0xScenarioCaller', + scenario.contractAddress, + '0', + scenario.method, + scenario.params + ); + + return scenario.expectedResult; + } + + /** Clear all scenarios */ + clearScenarios(): void { + this.scenarios = []; + } + + // ── Virtual balance management ────────────────────────────────────────────── + + /** Set up a virtual balance for a sandbox wallet */ + async setVirtualBalance( + wallet: string, + token: string, + amount: string + ): Promise<{ wallet: string; token: string; balance: string }> { + await this.delay(30); + return { wallet, token, balance: amount }; + } + + /** Simulate a token transfer between wallets */ + async mockTransferTokens( + from: string, + to: string, + amount: string, + token: string = 'USDC' + ): Promise { + return this.recordTransaction(from, to, amount, 'transferTokens', { token }); + } + + // ── Reset ─────────────────────────────────────────────────────────────────── + + /** Reset all mock blockchain state */ + reset(): void { + this.subscriptions.clear(); + this.transactions = []; + this.scenarios = []; + this.blockNumber = 18_500_000; + } + + /** Get supported tokens list for UI display */ + getSupportedTokens() { + return this.SUPPORTED_TOKENS.map(({ symbol, address, decimals }) => ({ + symbol, + address, + decimals, + })); + } + + /** Get current mock block number */ + getBlockNumber(): number { + return this.blockNumber; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async recordTransaction( + from: string, + to: string, + value: string, + method: string, + params: Record + ): Promise { + await this.delay(20 + Math.random() * 80); + + this.blockNumber++; + const hash = `0x${Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`; + + const tx: MockTransaction = { + id: `mtx_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, + hash, + from, + to, + value, + gasUsed: Math.floor(60_000 + Math.random() * 140_000), + gasPrice: this.gasPrice, + status: 'confirmed', + blockNumber: this.blockNumber, + timestamp: new Date(), + data: JSON.stringify(params), + method, + logs: [ + { + address: to, + topics: [hash, from, method], + data: JSON.stringify(params), + blockNumber: this.blockNumber, + transactionHash: hash, + eventName: method, + args: params, + }, + ], + }; + + this.transactions.push(tx); + return tx; + } + + private createFailedTx(from: string, _reason: string): MockTransaction & { success: false } { + return { + id: `mtx_fail_${Date.now()}`, + hash: `0x${'f'.repeat(64)}`, + from, + to: '0x0000000000000000000000000000000000000000', + value: '0', + gasUsed: 45_000, + gasPrice: this.gasPrice, + status: 'failed', + blockNumber: this.blockNumber, + timestamp: new Date(), + method: 'processPayment', + logs: [], + success: false, + }; + } + + private computeNextPaymentDate(from: Date, interval: 'weekly' | 'monthly' | 'yearly'): Date { + const next = new Date(from); + switch (interval) { + case 'weekly': + next.setDate(next.getDate() + 7); + break; + case 'monthly': + next.setMonth(next.getMonth() + 1); + break; + case 'yearly': + next.setFullYear(next.getFullYear() + 1); + break; + } + return next; + } + + private getTokenAddress(symbol: string): string { + const token = this.SUPPORTED_TOKENS.find((t) => t.symbol === symbol); + return token?.address || this.SUPPORTED_TOKENS[0].address; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const blockchainMockService = new BlockchainMockService(); diff --git a/sandbox/services/cleanupService.ts b/sandbox/services/cleanupService.ts new file mode 100644 index 00000000..d53722f6 --- /dev/null +++ b/sandbox/services/cleanupService.ts @@ -0,0 +1,426 @@ +/** + * CleanupService - Manages periodic sandbox cleanup, data reset, + * and environment lifecycle management. Prevents data leakage and + * keeps sandbox environments healthy. + */ +import { SandboxEnvironment, SandboxTestData } from '../types/sandbox'; + +// ─── Cleanup types ──────────────────────────────────────────────────────────── + +export interface CleanupSchedule { + environmentId: string; + interval: 'hourly' | 'daily' | 'weekly' | 'monthly'; + lastRunAt: Date | null; + nextRunAt: Date; + strategy: CleanupStrategy; + isActive: boolean; +} + +export interface CleanupStrategy { + resetTestData: boolean; + revokeExpiredKeys: boolean; + clearUsageMetrics: boolean; + archiveOldLogs: boolean; + deleteExpiredEnvironments: boolean; + retentionDays: number; +} + +export interface CleanupResult { + environmentId: string; + success: boolean; + actions: CleanupAction[]; + timestamp: Date; + errors: string[]; +} + +export interface CleanupAction { + type: + | 'test_data_reset' + | 'keys_revoked' + | 'metrics_cleared' + | 'logs_archived' + | 'environment_suspended' + | 'environment_deleted' + | 'environment_expired'; + description: string; + details?: Record; +} + +export interface CleanupReport { + generatedAt: Date; + environmentsScanned: number; + environmentsCleaned: number; + environmentsDeleted: number; + keysRevoked: number; + dataResets: number; + errors: string[]; + nextScheduledRun: Date; +} + +export interface EnvironmentHealth { + environmentId: string; + name: string; + status: 'healthy' | 'warning' | 'critical'; + issues: string[]; + daysUntilExpiry: number; + storageUsedMB: number; + requestCount: number; + errorRate: number; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class CleanupService { + private schedules: Map = new Map(); + private results: CleanupResult[] = []; + private readonly MAX_RESULTS_HISTORY = 100; + + // ── Default cleanup strategies ────────────────────────────────────────────── + + private readonly DEFAULT_STRATEGY: CleanupStrategy = { + resetTestData: true, + revokeExpiredKeys: true, + clearUsageMetrics: false, + archiveOldLogs: true, + deleteExpiredEnvironments: true, + retentionDays: 90, + }; + + private readonly AGGRESSIVE_STRATEGY: CleanupStrategy = { + resetTestData: true, + revokeExpiredKeys: true, + clearUsageMetrics: true, + archiveOldLogs: true, + deleteExpiredEnvironments: true, + retentionDays: 30, + }; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** Schedule periodic cleanup for an environment */ + async scheduleCleanup( + environmentId: string, + interval: CleanupSchedule['interval'] = 'weekly', + strategy?: Partial + ): Promise { + const schedule: CleanupSchedule = { + environmentId, + interval, + lastRunAt: null, + nextRunAt: this.computeNextRun(interval), + strategy: { ...this.DEFAULT_STRATEGY, ...strategy }, + isActive: true, + }; + + this.schedules.set(environmentId, schedule); + return schedule; + } + + /** Get cleanup schedule for an environment */ + getSchedule(environmentId: string): CleanupSchedule | null { + return this.schedules.get(environmentId) || null; + } + + /** Update cleanup schedule */ + async updateSchedule( + environmentId: string, + updates: Partial> + ): Promise { + const schedule = this.schedules.get(environmentId); + if (!schedule) return null; + + if (updates.interval) { + schedule.interval = updates.interval; + schedule.nextRunAt = this.computeNextRun(updates.interval); + } + if (updates.strategy) { + schedule.strategy = { ...schedule.strategy, ...updates.strategy }; + } + if (updates.isActive !== undefined) { + schedule.isActive = updates.isActive; + } + + this.schedules.set(environmentId, schedule); + return schedule; + } + + /** Cancel cleanup schedule */ + cancelSchedule(environmentId: string): boolean { + return this.schedules.delete(environmentId); + } + + /** Execute cleanup for a single environment */ + async cleanupEnvironment(environment: SandboxEnvironment): Promise { + const schedule = this.schedules.get(environment.id); + const strategy = schedule?.strategy || this.DEFAULT_STRATEGY; + + const result: CleanupResult = { + environmentId: environment.id, + success: true, + actions: [], + timestamp: new Date(), + errors: [], + }; + + try { + // 1. Reset test data if configured + if (strategy.resetTestData) { + result.actions.push({ + type: 'test_data_reset', + description: 'Test data regenerated with fresh mock data', + details: { + subscriptionsBefore: environment.testData.subscriptions.length, + paymentsBefore: environment.testData.payments.length, + webhooksBefore: environment.testData.webhooks.length, + }, + }); + environment.testData = this.generateFreshTestData(); + } + + // 2. Revoke expired API keys + if (strategy.revokeExpiredKeys) { + let revokedCount = 0; + for (const key of environment.apiKeys) { + if (key.expiresAt && key.expiresAt < new Date() && key.status === 'active') { + key.status = 'expired'; + revokedCount++; + } + } + if (revokedCount > 0) { + result.actions.push({ + type: 'keys_revoked', + description: `${revokedCount} expired API key(s) revoked`, + details: { revokedCount }, + }); + } + } + + // 3. Archive/clear usage metrics + if (strategy.clearUsageMetrics) { + result.actions.push({ + type: 'metrics_cleared', + description: 'Usage metrics have been cleared', + details: { + totalRequests: environment.usage.totalRequests, + }, + }); + environment.usage = this.getFreshUsage(); + } + + // 4. Handle expired environments + if (strategy.deleteExpiredEnvironments && environment.expiresAt) { + const isExpired = environment.expiresAt < new Date(); + if (isExpired && environment.status === 'active') { + environment.status = 'suspended'; + result.actions.push({ + type: 'environment_expired', + description: `Environment expired on ${environment.expiresAt.toISOString()}`, + details: { expiredAt: environment.expiresAt }, + }); + } + } + + // 5. Archive old logs + if (strategy.archiveOldLogs) { + result.actions.push({ + type: 'logs_archived', + description: 'Old request logs have been archived', + details: { retentionDays: strategy.retentionDays }, + }); + } + + // Update schedule + if (schedule?.isActive) { + schedule.lastRunAt = new Date(); + schedule.nextRunAt = this.computeNextRun(schedule.interval); + this.schedules.set(environment.id, schedule); + } + } catch (error) { + result.success = false; + result.errors.push(error instanceof Error ? error.message : 'Cleanup failed'); + } + + this.results.push(result); + // Trim results history + if (this.results.length > this.MAX_RESULTS_HISTORY) { + this.results = this.results.slice(-this.MAX_RESULTS_HISTORY); + } + + return result; + } + + /** Run scheduled cleanups for all environments that are due */ + async runScheduledCleanups(environments: SandboxEnvironment[]): Promise { + const now = new Date(); + const report: CleanupReport = { + generatedAt: now, + environmentsScanned: environments.length, + environmentsCleaned: 0, + environmentsDeleted: 0, + keysRevoked: 0, + dataResets: 0, + errors: [], + nextScheduledRun: new Date(now.getTime() + 24 * 60 * 60 * 1000), // next day + }; + + for (const env of environments) { + const schedule = this.schedules.get(env.id); + + // Skip if no schedule or not active + if (!schedule?.isActive) continue; + + // Skip if not yet due + if (schedule.nextRunAt > now) { + if (schedule.nextRunAt < report.nextScheduledRun) { + report.nextScheduledRun = schedule.nextRunAt; + } + continue; + } + + // Run cleanup + const result = await this.cleanupEnvironment(env); + report.environmentsCleaned++; + + if (!result.success) { + report.errors.push(...result.errors); + } + + // Aggregate action counts + for (const action of result.actions) { + switch (action.type) { + case 'environment_deleted': + report.environmentsDeleted++; + break; + case 'keys_revoked': + report.keysRevoked += (action.details?.revokedCount as number) || 0; + break; + case 'test_data_reset': + report.dataResets++; + break; + } + } + } + + return report; + } + + /** Force-reset an environment's test data immediately */ + async forceResetData(environment: SandboxEnvironment): Promise { + environment.testData = this.generateFreshTestData(); + return environment.testData; + } + + /** Get health status for an environment */ + async getHealthCheck(environment: SandboxEnvironment): Promise { + const issues: string[] = []; + let status: EnvironmentHealth['status'] = 'healthy'; + + // Check expiration + const daysUntilExpiry = environment.expiresAt + ? Math.ceil((environment.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : 999; + + if (daysUntilExpiry < 0) { + issues.push('Environment has expired'); + status = 'critical'; + } else if (daysUntilExpiry < 7) { + issues.push(`Environment expires in ${daysUntilExpiry} day(s)`); + status = 'warning'; + } else if (daysUntilExpiry < 30) { + issues.push(`Environment expires in ${daysUntilExpiry} days`); + if (status === 'healthy') status = 'warning'; + } + + // Check status + if (environment.status === 'suspended') { + issues.push('Environment is suspended'); + status = 'critical'; + } + + // Check error rate + const errorRate = + environment.usage.totalRequests > 0 + ? (environment.usage.failedRequests / environment.usage.totalRequests) * 100 + : 0; + + if (errorRate > 10) { + issues.push(`High error rate: ${errorRate.toFixed(1)}%`); + status = status === 'healthy' ? 'warning' : status; + } + + // Check storage + const storageMB = JSON.stringify(environment.testData).length / (1024 * 1024); + if (storageMB > 80) { + issues.push(`Storage usage high: ${storageMB.toFixed(1)}MB`); + if (status === 'healthy') status = 'warning'; + } + + return { + environmentId: environment.id, + name: environment.name, + status, + issues, + daysUntilExpiry, + storageUsedMB: parseFloat(storageMB.toFixed(2)), + requestCount: environment.usage.totalRequests, + errorRate: parseFloat(errorRate.toFixed(2)), + }; + } + + /** Get cleanup history for an environment */ + getCleanupHistory(environmentId: string, limit: number = 20): CleanupResult[] { + return this.results + .filter((r) => r.environmentId === environmentId) + .slice(-limit) + .reverse(); + } + + /** Get all current schedules */ + getAllSchedules(): CleanupSchedule[] { + return Array.from(this.schedules.values()); + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private computeNextRun(interval: CleanupSchedule['interval']): Date { + const now = new Date(); + switch (interval) { + case 'hourly': + return new Date(now.getTime() + 60 * 60 * 1000); + case 'daily': + now.setDate(now.getDate() + 1); + now.setHours(0, 0, 0, 0); + return now; + case 'weekly': + now.setDate(now.getDate() + 7); + now.setHours(0, 0, 0, 0); + return now; + case 'monthly': + now.setMonth(now.getMonth() + 1); + now.setHours(0, 0, 0, 0); + return now; + } + } + + private generateFreshTestData(): SandboxTestData { + return { + subscriptions: [], + payments: [], + webhooks: [], + users: [], + }; + } + + private getFreshUsage() { + return { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + averageResponseTime: 0, + last24Hours: [], + last7Days: [], + }; + } +} + +export const cleanupService = new CleanupService(); diff --git a/sandbox/services/migrationService.ts b/sandbox/services/migrationService.ts new file mode 100644 index 00000000..78f973ae --- /dev/null +++ b/sandbox/services/migrationService.ts @@ -0,0 +1,498 @@ +/** + * MigrationService - Manages the sandbox-to-production migration wizard. + * Handles configuration export, validation checks, data migration, + * and step-by-step guided migration flow. + */ +import { SandboxEnvironment, SandboxConfig, ApiKey } from '../types/sandbox'; + +// ─── Migration types ────────────────────────────────────────────────────────── + +export interface MigrationChecklistItem { + id: string; + category: 'security' | 'configuration' | 'data' | 'integration' | 'compliance'; + title: string; + description: string; + status: 'pending' | 'passed' | 'failed' | 'skipped'; + severity: 'critical' | 'warning' | 'info'; + recommendation?: string; + checkedAt?: Date; +} + +export interface MigrationStep { + id: string; + order: number; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + checklist: MigrationChecklistItem[]; + startedAt?: Date; + completedAt?: Date; +} + +export interface MigrationPlan { + id: string; + sourceEnvironmentId: string; + sourceEnvironmentName: string; + status: 'draft' | 'validating' | 'ready' | 'in_progress' | 'completed' | 'failed' | 'rolled_back'; + steps: MigrationStep[]; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + summary: MigrationSummary; +} + +export interface MigrationSummary { + totalSteps: number; + completedSteps: number; + totalChecks: number; + passedChecks: number; + failedChecks: number; + criticalFailures: number; + estimatedTimeMinutes: number; + canProceed: boolean; +} + +export interface MigrationExport { + version: string; + exportedAt: Date; + sourceEnvironment: { + id: string; + name: string; + config: Partial; + }; + apiKeys: Omit[]; + testConfigurations: Record; + webhookConfigs: Record[]; +} + +export interface MigrationResult { + success: boolean; + productionEnvironmentId?: string; + errors: string[]; + warnings: string[]; + rollbackAvailable: boolean; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class MigrationService { + private plans: Map = new Map(); + private exports: Map = new Map(); + private results: Map = new Map(); + + // ── Checklist templates ───────────────────────────────────────────────────── + + private readonly DEFAULT_CHECKLIST: MigrationChecklistItem[] = [ + { + id: 'sec_api_keys_rotated', + category: 'security', + title: 'API Keys Rotated', + description: 'Ensure sandbox API keys are rotated and new production keys are generated.', + status: 'pending', + severity: 'critical', + recommendation: 'Generate new production-scoped API keys before migration.', + }, + { + id: 'sec_rate_limits_verified', + category: 'security', + title: 'Rate Limits Verified', + description: 'Confirm production rate limits are properly configured.', + status: 'pending', + severity: 'warning', + recommendation: 'Review and adjust production rate limits to match expected traffic.', + }, + { + id: 'sec_webhook_secrets', + category: 'security', + title: 'Webhook Secrets Updated', + description: 'Update webhook signing secrets for production endpoints.', + status: 'pending', + severity: 'critical', + recommendation: 'Rotate all webhook secrets before going live.', + }, + { + id: 'cfg_isolation_removed', + category: 'configuration', + title: 'Sandbox Isolation Removed', + description: 'Ensure no sandbox-specific isolation flags remain in configuration.', + status: 'pending', + severity: 'critical', + }, + { + id: 'cfg_features_aligned', + category: 'configuration', + title: 'Feature Flags Aligned', + description: 'Verify feature flags match the production tier.', + status: 'pending', + severity: 'warning', + }, + { + id: 'cfg_webhooks_configured', + category: 'configuration', + title: 'Production Webhooks Configured', + description: 'All webhook endpoints point to production URLs.', + status: 'pending', + severity: 'critical', + recommendation: 'Replace any localhost/test URLs with production endpoints.', + }, + { + id: 'data_test_data_cleared', + category: 'data', + title: 'Test Data Cleared', + description: 'No test or mock data remains in the production environment.', + status: 'pending', + severity: 'critical', + recommendation: 'Run data cleanup to remove all sandbox-generated test data.', + }, + { + id: 'data_real_subscriptions', + category: 'data', + title: 'Real Subscriptions Ready', + description: 'Production subscriptions and pricing are configured.', + status: 'pending', + severity: 'warning', + }, + { + id: 'int_monitoring_setup', + category: 'integration', + title: 'Monitoring Configured', + description: 'Error tracking, logging, and alerting are set up for production.', + status: 'pending', + severity: 'warning', + recommendation: 'Set up production monitoring (Sentry, Datadog, etc.).', + }, + { + id: 'int_sla_configured', + category: 'integration', + title: 'SLA Configuration', + description: 'Service level agreement terms are configured for production.', + status: 'pending', + severity: 'info', + }, + { + id: 'com_gdpr_compliance', + category: 'compliance', + title: 'GDPR Compliance Verified', + description: 'Data handling meets GDPR requirements.', + status: 'pending', + severity: 'critical', + recommendation: 'Review GDPR compliance checklist before going live.', + }, + { + id: 'com_tos_accepted', + category: 'compliance', + title: 'Terms of Service Accepted', + description: 'Production Terms of Service have been reviewed and accepted.', + status: 'pending', + severity: 'critical', + }, + ]; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** Create a new migration plan for a sandbox environment */ + async createMigrationPlan(sourceEnvironment: SandboxEnvironment): Promise { + const planId = `mig_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + + const steps: MigrationStep[] = [ + { + id: 'step_preflight', + order: 1, + title: 'Pre-flight Validation', + description: 'Run automated checks to verify the sandbox is ready for migration.', + status: 'pending', + checklist: this.filterChecklist(['security', 'configuration']), + }, + { + id: 'step_export', + order: 2, + title: 'Export Configuration', + description: 'Export sandbox configuration, API key metadata, and webhook settings.', + status: 'pending', + checklist: [], + }, + { + id: 'step_data_cleanup', + order: 3, + title: 'Data Sanitization', + description: 'Remove all test data and verify no mock data leaks to production.', + status: 'pending', + checklist: this.filterChecklist(['data']), + }, + { + id: 'step_integration', + order: 4, + title: 'Production Integration Setup', + description: 'Configure production monitoring, SLAs, and integration points.', + status: 'pending', + checklist: this.filterChecklist(['integration']), + }, + { + id: 'step_final_review', + order: 5, + title: 'Final Review & Compliance', + description: 'Complete final compliance checks and proceed to go-live.', + status: 'pending', + checklist: this.filterChecklist(['compliance']), + }, + ]; + + const plan: MigrationPlan = { + id: planId, + sourceEnvironmentId: sourceEnvironment.id, + sourceEnvironmentName: sourceEnvironment.name, + status: 'draft', + steps, + createdAt: new Date(), + updatedAt: new Date(), + summary: this.computeSummary(steps), + }; + + this.plans.set(planId, plan); + return plan; + } + + /** Get a migration plan by ID */ + async getMigrationPlan(planId: string): Promise { + return this.plans.get(planId) || null; + } + + /** Start the migration process */ + async startMigration(planId: string): Promise { + const plan = this.plans.get(planId); + if (!plan) return null; + + plan.status = 'validating'; + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + // Validate all preflight checks + await this.runPreflightValidation(plan); + + plan.summary = this.computeSummary(plan.steps); + plan.status = plan.summary.canProceed ? 'ready' : 'failed'; + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + return plan; + } + + /** Execute a specific migration step */ + async executeStep(planId: string, stepId: string): Promise { + const plan = this.plans.get(planId); + if (!plan) return null; + + const step = plan.steps.find((s) => s.id === stepId); + if (!step) return null; + + step.status = 'in_progress'; + step.startedAt = new Date(); + plan.status = 'in_progress'; + plan.updatedAt = new Date(); + + // Simulate running checks for this step + for (const check of step.checklist) { + if (check.status === 'pending') { + // Auto-pass non-critical checks, flag critical ones for review + check.status = check.severity === 'critical' ? 'failed' : 'passed'; + check.checkedAt = new Date(); + } + } + + step.status = 'completed'; + step.completedAt = new Date(); + plan.summary = this.computeSummary(plan.steps); + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + return step; + } + + /** Update a checklist item status */ + async updateChecklistItem( + planId: string, + stepId: string, + itemId: string, + status: MigrationChecklistItem['status'] + ): Promise { + const plan = this.plans.get(planId); + if (!plan) return null; + + const step = plan.steps.find((s) => s.id === stepId); + if (!step) return null; + + const item = step.checklist.find((c) => c.id === itemId); + if (!item) return null; + + item.status = status; + item.checkedAt = new Date(); + + plan.summary = this.computeSummary(plan.steps); + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + return item; + } + + /** Export sandbox configuration for migration */ + async exportConfiguration(environment: SandboxEnvironment): Promise { + const migrationExport: MigrationExport = { + version: '1.0.0', + exportedAt: new Date(), + sourceEnvironment: { + id: environment.id, + name: environment.name, + config: { + apiVersion: environment.config.apiVersion, + rateLimits: environment.config.rateLimits, + features: environment.config.features, + customDomain: environment.config.customDomain, + webhookUrl: environment.config.webhookUrl, + callbackUrl: environment.config.callbackUrl, + }, + }, + apiKeys: environment.apiKeys + .filter((k) => k.status === 'active') + .map(({ key: _key, ...rest }) => rest), + testConfigurations: { + subscriptionCount: environment.testData.subscriptions.length, + paymentCount: environment.testData.payments.length, + webhookCount: environment.testData.webhooks.length, + userCount: environment.testData.users.length, + }, + webhookConfigs: environment.testData.webhooks.map((wh) => ({ + url: wh.url, + events: wh.events, + })), + }; + + this.exports.set(environment.id, migrationExport); + return migrationExport; + } + + /** Complete the migration and simulate production setup */ + async completeMigration(planId: string): Promise { + const plan = this.plans.get(planId); + if (!plan) { + return { + success: false, + errors: ['Migration plan not found'], + warnings: [], + rollbackAvailable: false, + }; + } + + if (!plan.summary.canProceed) { + return { + success: false, + errors: + plan.summary.failedChecks > 0 + ? ['Critical checks have failed. Please resolve before proceeding.'] + : ['Unable to proceed with migration.'], + warnings: [], + rollbackAvailable: false, + }; + } + + plan.status = 'completed'; + plan.completedAt = new Date(); + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + const result: MigrationResult = { + success: true, + productionEnvironmentId: `prod_${Date.now()}`, + errors: [], + warnings: [ + 'Remember to rotate ALL API keys', + 'Monitor production traffic for first 24 hours', + 'Keep sandbox environment active for rollback purposes', + ], + rollbackAvailable: true, + }; + + this.results.set(planId, result); + return result; + } + + /** Rollback a completed migration */ + async rollbackMigration(planId: string): Promise { + const plan = this.plans.get(planId); + const result = this.results.get(planId); + + if (!plan || !result?.rollbackAvailable) return false; + + plan.status = 'rolled_back'; + plan.updatedAt = new Date(); + this.plans.set(planId, plan); + + return true; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async runPreflightValidation(plan: MigrationPlan): Promise { + for (const step of plan.steps) { + for (const check of step.checklist) { + // Simulate validation delay + await this.delay(30 + Math.random() * 70); + + // In a real implementation, this would run actual validation logic + // For sandbox, critical security checks are auto-passed for demo + if (check.severity === 'critical') { + check.status = Math.random() > 0.15 ? 'passed' : 'failed'; + } else if (check.severity === 'warning') { + check.status = Math.random() > 0.3 ? 'passed' : 'failed'; + } else { + check.status = 'passed'; + } + check.checkedAt = new Date(); + } + } + } + + private filterChecklist( + categories: MigrationChecklistItem['category'][] + ): MigrationChecklistItem[] { + return this.DEFAULT_CHECKLIST.filter((item) => categories.includes(item.category)).map( + (item) => ({ ...item, status: 'pending' as const, checkedAt: undefined }) + ); + } + + private computeSummary(steps: MigrationStep[]): MigrationSummary { + let totalChecks = 0; + let passedChecks = 0; + let failedChecks = 0; + let criticalFailures = 0; + + for (const step of steps) { + for (const check of step.checklist) { + totalChecks++; + if (check.status === 'passed') passedChecks++; + if (check.status === 'failed') { + failedChecks++; + if (check.severity === 'critical') criticalFailures++; + } + } + } + + const completedSteps = steps.filter((s) => s.status === 'completed').length; + + return { + totalSteps: steps.length, + completedSteps, + totalChecks, + passedChecks, + failedChecks, + criticalFailures, + estimatedTimeMinutes: steps.length * 3, + canProceed: criticalFailures === 0, + }; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const migrationService = new MigrationService(); diff --git a/sandbox/services/sandboxLeakagePreventionService.ts b/sandbox/services/sandboxLeakagePreventionService.ts new file mode 100644 index 00000000..71264f01 --- /dev/null +++ b/sandbox/services/sandboxLeakagePreventionService.ts @@ -0,0 +1,404 @@ +/** + * SandboxLeakagePreventionService - Guards against sandbox data leaking into production. + * Enforces strict data isolation, prevents production endpoint calls from sandbox keys, + * and ensures mock data never reaches production systems. + * + * Edge cases handled: + * - Sandbox API key calling production endpoints + * - Production API key calling sandbox endpoints + * - Test data accidentally persisted to production DB + * - Webhook secrets shared between sandbox/production + * - Rate limit differences between sandbox and production + */ +import { ApiKey } from '../types/sandbox'; + +// ─── Leakage detection types ────────────────────────────────────────────────── + +export interface LeakageCheckResult { + allowed: boolean; + reason?: string; + severity: 'none' | 'warning' | 'critical' | 'blocked'; + category: LeakageCategory; + details?: Record; +} + +export type LeakageCategory = + | 'key_mismatch' + | 'endpoint_mismatch' + | 'data_leakage' + | 'webhook_leakage' + | 'rate_limit_mismatch' + | 'credential_sharing' + | 'network_boundary'; + +export interface LeakageAuditEntry { + id: string; + timestamp: Date; + category: LeakageCategory; + severity: 'warning' | 'critical' | 'blocked'; + description: string; + source: { environmentId: string; apiKeyId?: string }; + target: { endpoint: string; environment: 'sandbox' | 'production' }; + actionTaken: 'blocked' | 'warned' | 'flagged' | 'allowed'; + metadata?: Record; +} + +export interface ProductionGuardConfig { + enforceKeyOrigin: boolean; + enforceEndpointIsolation: boolean; + enforceDataSanitization: boolean; + enforceWebhookIsolation: boolean; + enforceRateLimitDifferentiation: boolean; + enforceCredentialRotation: boolean; + auditMode: boolean; + autoBlockLeakage: boolean; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +export class SandboxLeakagePreventionService { + private auditLog: LeakageAuditEntry[] = []; + private blockedEndpoints: Set = new Set(); + private sandboxKeyPrefix = 'sk_sandbox_'; + private productionKeyPrefix = 'sk_live_'; + + private readonly config: ProductionGuardConfig = { + enforceKeyOrigin: true, + enforceEndpointIsolation: true, + enforceDataSanitization: true, + enforceWebhookIsolation: true, + enforceRateLimitDifferentiation: true, + enforceCredentialRotation: true, + auditMode: false, + autoBlockLeakage: true, + }; + + // ── Production endpoint patterns that should NEVER be called from sandbox ── + + private readonly PRODUCTION_ONLY_ENDPOINTS = [ + '/api/v1/production/', + '/api/v1/live/', + '/api/v1/contracts/deploy', + '/api/v1/blockchain/submit', + '/api/v1/payments/charge', + '/api/v1/customers/real', + ]; + + // ── Sandbox-only endpoints ───────────────────────────────────────────────── + + private readonly SANDBOX_ONLY_ENDPOINTS = [ + '/api/v1/sandbox/', + '/api/v1/mock/', + '/api/v1/test/', + '/api/v1/simulate/', + ]; + + // ── Patterns indicating potential data leakage ────────────────────────────── + + private readonly DATA_LEAKAGE_PATTERNS = [ + /production/i, + /live_key/i, + /real_customer/i, + /actual_payment/i, + /prod_db/i, + /mainnet/i, + /0x[0-9a-fA-F]{40}/, // real blockchain addresses + ]; + + // ── Public API ────────────────────────────────────────────────────────────── + + /** Check if a sandbox API key can access a given endpoint */ + async checkKeyEndpointAccess(apiKey: ApiKey, endpoint: string): Promise { + const isSandboxKey = apiKey.key.startsWith(this.sandboxKeyPrefix); + + // Sandbox key trying to access production-only endpoints + if (isSandboxKey && this.isProductionEndpoint(endpoint)) { + await this.logLeakageAttempt({ + category: 'endpoint_mismatch', + severity: 'blocked', + description: `Sandbox key attempting to access production endpoint: ${endpoint}`, + source: { environmentId: 'unknown', apiKeyId: apiKey.id }, + target: { endpoint, environment: 'production' }, + actionTaken: 'blocked', + }); + + return { + allowed: false, + reason: `Sandbox API keys cannot access production endpoints. Use a production API key (${this.productionKeyPrefix}...) for: ${endpoint}`, + severity: 'blocked', + category: 'endpoint_mismatch', + details: { endpoint, keyPrefix: this.sandboxKeyPrefix }, + }; + } + + // Production key calling sandbox endpoints (warning but allowed) + if (!isSandboxKey && this.isSandboxEndpoint(endpoint)) { + await this.logLeakageAttempt({ + category: 'endpoint_mismatch', + severity: 'warning', + description: `Production key accessing sandbox endpoint: ${endpoint}`, + source: { environmentId: 'unknown', apiKeyId: apiKey.id }, + target: { endpoint, environment: 'sandbox' }, + actionTaken: 'warned', + }); + + return { + allowed: true, + reason: 'Production key on sandbox endpoint - allowed but not recommended', + severity: 'warning', + category: 'endpoint_mismatch', + }; + } + + return { allowed: true, severity: 'none', category: 'endpoint_mismatch' }; + } + + /** Validate data payloads for potential production data in sandbox context */ + async checkDataLeakage( + data: unknown, + context: 'sandbox' | 'production' + ): Promise { + if (context !== 'sandbox') { + return { allowed: true, severity: 'none', category: 'data_leakage' }; + } + + const dataString = JSON.stringify(data); + const matches: string[] = []; + + for (const pattern of this.DATA_LEAKAGE_PATTERNS) { + const match = dataString.match(pattern); + if (match) { + matches.push(match[0]); + } + } + + if (matches.length > 0) { + await this.logLeakageAttempt({ + category: 'data_leakage', + severity: 'critical', + description: `Potential production data detected in sandbox: ${matches.join(', ')}`, + source: { environmentId: 'unknown' }, + target: { endpoint: 'data_payload', environment: 'sandbox' }, + actionTaken: this.config.autoBlockLeakage ? 'blocked' : 'flagged', + metadata: { matches }, + }); + + return { + allowed: !this.config.autoBlockLeakage, + reason: `Potential production data detected in sandbox payload. Matches: ${matches.join(', ')}`, + severity: 'critical', + category: 'data_leakage', + details: { matches }, + }; + } + + return { allowed: true, severity: 'none', category: 'data_leakage' }; + } + + /** Validate webhook URLs to prevent sandbox webhooks pointing to production */ + async checkWebhookIsolation( + webhookUrl: string, + environment: 'sandbox' | 'production' + ): Promise { + const isProductionUrl = + webhookUrl.includes('api.') || + webhookUrl.includes('production') || + webhookUrl.includes('.com/api') || + (!webhookUrl.includes('localhost') && + !webhookUrl.includes('test') && + !webhookUrl.includes('sandbox') && + !webhookUrl.includes('staging') && + !webhookUrl.includes('dev.')); + + if (environment === 'sandbox' && isProductionUrl) { + await this.logLeakageAttempt({ + category: 'webhook_leakage', + severity: 'critical', + description: `Sandbox webhook URL appears to be production: ${webhookUrl}`, + source: { environmentId: 'unknown' }, + target: { endpoint: webhookUrl, environment: 'sandbox' }, + actionTaken: 'blocked', + }); + + return { + allowed: false, + reason: 'Sandbox webhooks must use test endpoints. Production URLs detected.', + severity: 'critical', + category: 'webhook_leakage', + details: { webhookUrl }, + }; + } + + return { allowed: true, severity: 'none', category: 'webhook_leakage' }; + } + + /** Ensure rate limits differ between sandbox and production */ + async checkRateLimitDifferentiation( + sandboxRateLimit: number, + productionRateLimit: number + ): Promise { + // Sandbox rate limits should be significantly lower than production + const ratio = productionRateLimit / sandboxRateLimit; + + if (ratio < 2 && sandboxRateLimit > 0) { + return { + allowed: true, + reason: `Sandbox rate limit (${sandboxRateLimit}) is too close to production (${productionRateLimit}). Recommended ratio is at least 3:1.`, + severity: 'warning', + category: 'rate_limit_mismatch', + details: { sandboxRateLimit, productionRateLimit, ratio }, + }; + } + + if (sandboxRateLimit >= productionRateLimit) { + await this.logLeakageAttempt({ + category: 'rate_limit_mismatch', + severity: 'critical', + description: `Sandbox rate limit (${sandboxRateLimit}) equals or exceeds production (${productionRateLimit})`, + source: { environmentId: 'unknown' }, + target: { endpoint: 'rate_limit_config', environment: 'sandbox' }, + actionTaken: 'flagged', + }); + + return { + allowed: true, + reason: 'Sandbox rate limit should be lower than production. Consider reducing.', + severity: 'critical', + category: 'rate_limit_mismatch', + details: { sandboxRateLimit, productionRateLimit, ratio }, + }; + } + + return { allowed: true, severity: 'none', category: 'rate_limit_mismatch' }; + } + + /** Sanitize data before persisting to ensure no production markers leak */ + sanitizeDataForSandbox(data: unknown): unknown { + if (typeof data === 'string') { + // Strip production key prefixes + return data + .replace(new RegExp(this.productionKeyPrefix, 'g'), '[REDACTED_PROD_KEY]') + .replace(/sk_live_[A-Za-z0-9]+/g, '[REDACTED_PROD_KEY]') + .replace(/prod_[a-zA-Z0-9_]+/g, '[REDACTED_PROD_ID]'); + } + + if (Array.isArray(data)) { + return data.map((item) => this.sanitizeDataForSandbox(item)); + } + + if (typeof data === 'object' && data !== null) { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + // Strip production-specific fields + if ( + key === 'productionKey' || + key === 'liveKey' || + key === 'prodEnvironment' || + key === 'mainnetAddress' + ) { + sanitized[key] = '[REDACTED]'; + continue; + } + sanitized[key] = this.sanitizeDataForSandbox(value); + } + return sanitized; + } + + return data; + } + + /** Get the full audit log */ + getAuditLog(options?: { + category?: LeakageCategory; + severity?: LeakageCheckResult['severity']; + limit?: number; + }): LeakageAuditEntry[] { + let filtered = this.auditLog; + + if (options?.category) { + filtered = filtered.filter((e) => e.category === options.category); + } + if (options?.severity) { + filtered = filtered.filter((e) => e.severity === options.severity); + } + + return filtered.slice(-(options?.limit || 100)).reverse(); + } + + /** Block a specific endpoint from sandbox access */ + blockEndpoint(endpoint: string): void { + this.blockedEndpoints.add(endpoint); + } + + /** Unblock an endpoint */ + unblockEndpoint(endpoint: string): void { + this.blockedEndpoints.delete(endpoint); + } + + /** Check if an endpoint is blocked */ + isEndpointBlocked(endpoint: string): boolean { + return this.blockedEndpoints.has(endpoint); + } + + /** Get summary of leakage prevention status */ + getLeakageSummary(): { + totalAuditEntries: number; + blockedAttempts: number; + warnings: number; + criticals: number; + topCategories: { category: string; count: number }[]; + } { + const blocked = this.auditLog.filter((e) => e.actionTaken === 'blocked').length; + const warnings = this.auditLog.filter((e) => e.severity === 'warning').length; + const criticals = this.auditLog.filter((e) => e.severity === 'critical').length; + + const categoryCounts: Record = {}; + for (const entry of this.auditLog) { + categoryCounts[entry.category] = (categoryCounts[entry.category] || 0) + 1; + } + + const topCategories = Object.entries(categoryCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([category, count]) => ({ category, count })); + + return { + totalAuditEntries: this.auditLog.length, + blockedAttempts: blocked, + warnings, + criticals, + topCategories, + }; + } + + /** Clear audit log */ + clearAuditLog(): void { + this.auditLog = []; + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private isProductionEndpoint(endpoint: string): boolean { + return this.PRODUCTION_ONLY_ENDPOINTS.some((ep) => endpoint.startsWith(ep)); + } + + private isSandboxEndpoint(endpoint: string): boolean { + return this.SANDBOX_ONLY_ENDPOINTS.some((ep) => endpoint.startsWith(ep)); + } + + private async logLeakageAttempt(entry: Omit): Promise { + const fullEntry: LeakageAuditEntry = { + ...entry, + id: `leak_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, + }; + + this.auditLog.push(fullEntry); + + // Keep audit log manageable + if (this.auditLog.length > 1000) { + this.auditLog = this.auditLog.slice(-500); + } + } +} + +export const sandboxLeakagePrevention = new SandboxLeakagePreventionService(); diff --git a/src/components/gamification/LoyaltyComponents.tsx b/src/components/gamification/LoyaltyComponents.tsx index e02c26f7..9b3e00a4 100644 --- a/src/components/gamification/LoyaltyComponents.tsx +++ b/src/components/gamification/LoyaltyComponents.tsx @@ -51,7 +51,7 @@ export const StreakCard: React.FC = ({ streak, onShare }) => { Best - + {streak.longest} @@ -99,11 +99,15 @@ export const AchievementCard: React.FC = ({ achievement, o {achievement.icon} - + {achievement.name} = ({ if (!nextTier) { return ( - + 🏆 Maximum tier reached! @@ -169,14 +173,14 @@ export const TierProgressBar: React.FC = ({ return ( - + {currentTier.toUpperCase()} → {nextTier.toUpperCase()} {lifetimePoints.toLocaleString()} / {to.toLocaleString()} pts - + = ({ return ( - {item.name} + + {item.name} + {item.pointsCost.toLocaleString()} pts @@ -224,7 +230,7 @@ export const RewardsCatalog: React.FC = ({ style={[ styles.redeemBtn, { - backgroundColor: canRedeem ? theme.colors.primary : theme.colors.border, + backgroundColor: canRedeem ? theme.colors.primary : theme.colors.border.default, }, ]} onPress={() => canRedeem && onRedeem(item.id)} @@ -250,7 +256,9 @@ export const RewardsCatalog: React.FC = ({ return ( - Rewards Catalog + + Rewards Catalog + r.isActive)} keyExtractor={(r) => r.id} @@ -271,7 +279,7 @@ export const AchievementsList: React.FC = ({ achievements const theme = useTheme(); return ( - Achievements + Achievements {achievements.map((a) => ( diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index dd205886..0f32b2ee 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -5,19 +5,14 @@ import { navigationRef } from './navigationRef'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useTranslation } from 'react-i18next'; -import { lazyScreen } from '../utils/lazyLoading'; +import { lazyScreen, prefetchModule } from '../utils/lazyLoading'; import { RootStackParamList, TabParamList } from './types'; import { useTheme } from '../theme'; import { darkNavigationTheme, lightNavigationTheme } from '../theme/navigationTheme'; // Eagerly loaded primary entrypoints for instant rendering import HomeScreen from '../screens/HomeScreen'; -import EditSubscriptionScreen from '../screens/EditSubscriptionScreen'; import { SettingsScreen } from '../screens/SettingsScreen'; -import BillingSettingsScreen from '../screens/BillingSettingsScreen'; -import ChangePlanScreen from '../screens/ChangePlanScreen'; -import { PaymentMethodsScreen } from '../../app/screens/PaymentMethodsScreen'; -import AnalyticsDashboard from '../../app/screens/AnalyticsDashboard'; // Lazy loaded auxiliary and heavy screens with suspense/retry support const AddSubscriptionScreen = lazyScreen(() => import('../screens/AddSubscriptionScreen')); @@ -73,6 +68,15 @@ const IntegrationGuidesScreen = lazyScreen(() => import('../screens/IntegrationG const PerformanceDashboardScreen = lazyScreen( () => import('../screens/PerformanceDashboardScreen') ); +const EditSubscriptionScreen = lazyScreen(() => import('../screens/EditSubscriptionScreen')); +const ChangePlanScreen = lazyScreen(() => import('../screens/ChangePlanScreen')); +const BillingSettingsScreen = lazyScreen(() => import('../screens/BillingSettingsScreen')); +const PaymentMethodsScreen = lazyScreen(() => + import('../../app/screens/PaymentMethodsScreen').then((m) => ({ + default: m.PaymentMethodsScreen, + })) +); +const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -435,6 +439,13 @@ const TabNavigator = () => { }; export const AppNavigator = () => { + React.useEffect(() => { + prefetchModule('AddSubscription', () => import('../screens/AddSubscriptionScreen')); + prefetchModule('WalletConnect', () => import('../screens/WalletConnectV2Screen')); + prefetchModule('Analytics', () => import('../screens/AnalyticsScreen')); + prefetchModule('SubscriptionDetail', () => import('../screens/SubscriptionDetailScreen')); + }, []); + const { isDark } = useTheme(); return ( diff --git a/src/navigation/__tests__/AppNavigator.lazy.test.tsx b/src/navigation/__tests__/AppNavigator.lazy.test.tsx new file mode 100644 index 00000000..294fd2e9 --- /dev/null +++ b/src/navigation/__tests__/AppNavigator.lazy.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { lazyWithRetry, LazyErrorBoundary, SuspenseLoadingFallback } from '../../utils/lazyLoading'; + +// Mock react-native completely with pass-through elements so testID is fully discoverable by testing-library +jest.mock('react-native', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require('react') as typeof import('react'); + return { + View: ({ + children, + testID, + style, + }: { + children?: React.ReactNode; + testID?: string; + style?: object; + }) => mockReact.createElement('View', { testID, style }, children), + Text: ({ + children, + testID, + style, + }: { + children?: React.ReactNode; + testID?: string; + style?: object; + }) => mockReact.createElement('Text', { testID, style }, children), + ActivityIndicator: ({ color }: { color?: string }) => + mockReact.createElement('ActivityIndicator', { color }), + TouchableOpacity: ({ + children, + onPress, + style, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + style?: object; + testID?: string; + }) => mockReact.createElement('TouchableOpacity', { onPress, style, testID }, children), + InteractionManager: { + runAfterInteractions: (cb: () => void) => cb(), + }, + StyleSheet: { + create: (styles: object) => styles, + flatten: (styles: object) => styles, + }, + Platform: { + OS: 'ios', + }, + }; +}); + +// Mocking design system constants +jest.mock('../../utils/constants', () => ({ + colors: { + primary: '#6366f1', + secondary: '#8b5cf6', + accent: '#06b6d4', + success: '#10b981', + warning: '#f59e0b', + error: '#ef4444', + background: '#0f172a', + surface: '#1e293b', + text: '#f8fafc', + textSecondary: '#cbd5e1', + onPrimary: '#ffffff', + border: '#334155', + }, + spacing: { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, + }, + borderRadius: { + sm: 4, + md: 8, + lg: 12, + xl: 16, + }, + typography: { + h3: { fontSize: 20 }, + body: { fontSize: 16 }, + body2: { fontSize: 14 }, + button: { fontSize: 16 }, + }, + shadows: { + sm: {}, + md: {}, + lg: {}, + }, +})); + +const DummyComponent = () => Loaded Content Successfully; + +describe('Lazy Loading Utilities & Error Boundaries', () => { + it('renders SuspenseLoadingFallback correctly', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('lazy-loading-fallback')).toBeTruthy(); + expect(getByText('Preparing premium modules...')).toBeTruthy(); + }); + + it('lazyWithRetry resolves successfully on initial attempt', async () => { + const importFn = jest.fn().mockResolvedValue({ default: DummyComponent }); + const lazyComponent = lazyWithRetry(importFn) as unknown as { + _payload: { _result: () => Promise<{ default: typeof DummyComponent }> }; + }; + + const loadFn = lazyComponent._payload._result; + const result = await loadFn(); + + expect(result.default).toBe(DummyComponent); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('lazyWithRetry retries on failure and resolves on second attempt', async () => { + let called = 0; + const importFn = jest.fn().mockImplementation(() => { + called++; + if (called === 1) { + return Promise.reject(new Error('Transient connection drop')); + } + return Promise.resolve({ default: DummyComponent }); + }); + + const lazyComponent = lazyWithRetry(importFn, 2, 5) as unknown as { + _payload: { _result: () => Promise<{ default: typeof DummyComponent }> }; + }; + const loadFn = lazyComponent._payload._result; + const result = await loadFn(); + + expect(result.default).toBe(DummyComponent); + expect(importFn).toHaveBeenCalledTimes(2); + }); + + it('LazyErrorBoundary catches loading failures and renders interactive retry screen', async () => { + let shouldThrow = true; + const FailingComponent = () => { + if (shouldThrow) { + throw new Error('All retries failed'); + } + return Recovered!; + }; + + // Suppress console.error to keep the logs clean during expected test failure + const originalConsoleError = console.error; + console.error = jest.fn(); + + try { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('lazy-error-fallback')).toBeTruthy(); + expect(getByText('Connection Interrupted')).toBeTruthy(); + + // Disable throwing state before retry click + shouldThrow = false; + + const retryButton = getByText('Try Again'); + fireEvent.press(retryButton); + + expect(getByTestId('recovered-component')).toBeTruthy(); + } finally { + console.error = originalConsoleError; + } + }); +}); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3c3fed91..10bf40b0 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -48,6 +48,7 @@ export type RootStackParamList = { ChangePlan: { subscriptionId: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; + NotFound: { reason?: string }; }; export type TabParamList = { diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index 74ecb9d8..12d2c9a4 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -12,7 +12,7 @@ import { Platform, Keyboard, } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; import { useSubscriptionStore, useSettingsStore } from '../store'; diff --git a/src/screens/SandboxDetailScreen.tsx b/src/screens/SandboxDetailScreen.tsx index d049a959..0325d38c 100644 --- a/src/screens/SandboxDetailScreen.tsx +++ b/src/screens/SandboxDetailScreen.tsx @@ -287,7 +287,7 @@ export default function SandboxDetailScreen() { {key.name} {key.description} - Scopes: {key.scopes.join(', ')} + Scopes: {key.scopes?.join(', ') || ''} Usage: {key.usageCount} diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index e58fa683..de811bd9 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -10,6 +10,7 @@ import { ActivityIndicator, Switch, RefreshControl, + Share, } from 'react-native'; import useRefresh from '../hooks/useRefresh'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; @@ -66,9 +67,22 @@ const SubscriptionDetailScreen: React.FC = () => { }, [subscription]); const handleEdit = useCallback(() => { - navigation.navigate('EditSubscription', { id: subscription.id }); + if (subscription) { + navigation.navigate('EditSubscription', { id: subscription.id }); + } }, [subscription, navigation]); + const handleShare = useCallback(async () => { + if (!subscription) return; + try { + await Share.share({ + message: `Check out my subscription to ${subscription.name} on SubTrackr!`, + }); + } catch (error) { + console.error('Error sharing:', error); + } + }, [subscription]); + const handlePauseResume = useCallback(async () => { if (!subscription) return; try { diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index 8e1017ff..2a4dd3dc 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -29,6 +29,7 @@ import * as Clipboard from 'expo-clipboard'; const WalletConnectScreen: React.FC = () => { const navigation = useNavigation>(); const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); const { open } = useAppKit(); const { address, isConnected, chainId } = useAppKitAccount(); const { walletProvider } = useAppKitProvider(); @@ -474,352 +475,354 @@ const WalletConnectScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - scrollView: { - flex: 1, - }, - header: { - padding: spacing.lg, - paddingBottom: spacing.md, - }, - title: { - ...typography.h1, - color: colors.text, - marginBottom: spacing.xs, - }, - subtitle: { - ...typography.body, - color: colors.textSecondary, - }, - connectSection: { - padding: spacing.lg, - paddingTop: 0, - }, - connectedSection: { - padding: spacing.lg, - paddingTop: 0, - }, - sectionTitle: { - ...typography.h3, - color: colors.text, - marginBottom: spacing.sm, - }, - sectionDescription: { - ...typography.body, - color: colors.textSecondary, - marginBottom: spacing.lg, - lineHeight: 22, - }, - connectHeader: { - alignItems: 'center', - marginBottom: spacing.lg, - }, - connectIcon: { - fontSize: 48, - marginBottom: spacing.sm, - }, - walletOptions: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'space-around', - marginBottom: spacing.lg, - gap: spacing.md, - }, - walletOption: { - alignItems: 'center', - minWidth: 80, - }, - walletIconContainer: { - width: 60, - height: 60, - borderRadius: borderRadius.full, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm, - ...shadows.sm, - }, - walletIcon: { - fontSize: 28, - }, - walletName: { - ...typography.caption, - color: colors.text, - textAlign: 'center', - fontWeight: '600', - marginBottom: spacing.xs, - }, - walletDescription: { - ...typography.caption, - color: colors.textSecondary, - textAlign: 'center', - fontSize: 10, - }, - connectButtonContainer: { - marginTop: spacing.md, - alignItems: 'center', - }, - connectNote: { - ...typography.caption, - color: colors.textSecondary, - textAlign: 'center', - marginTop: spacing.sm, - fontStyle: 'italic', - }, - connectionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - connectionStatus: { - flexDirection: 'row', - alignItems: 'center', - }, - statusIndicator: { - width: 12, - height: 12, - borderRadius: borderRadius.full, - marginRight: spacing.sm, - }, - statusText: { - ...typography.body, - color: colors.text, - fontWeight: '600', - }, - connectionTime: { - ...typography.caption, - color: colors.textSecondary, - marginLeft: spacing.sm, - }, - disconnectButton: { - flexDirection: 'row', - alignItems: 'center', - padding: spacing.sm, - backgroundColor: colors.error, - borderRadius: borderRadius.md, - }, - disconnectIcon: { - fontSize: 16, - marginRight: spacing.xs, - }, - disconnectText: { - ...typography.caption, - color: colors.text, - fontWeight: '600', - }, - walletInfo: { - marginBottom: spacing.md, - }, - addressContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - addressLabel: { - ...typography.caption, - color: colors.textSecondary, - }, - addressCopyButton: { - padding: spacing.xs, - backgroundColor: colors.surface, - borderRadius: borderRadius.sm, - }, - copyIcon: { - fontSize: 16, - color: colors.textSecondary, - }, - addressText: { - ...typography.h3, - color: colors.text, - fontFamily: 'monospace', - marginBottom: spacing.md, - }, - chainInfo: { - alignItems: 'flex-start', - }, - chainBadge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: borderRadius.full, - marginBottom: spacing.xs, - }, - chainIcon: { - fontSize: 16, - marginRight: spacing.xs, - }, - chainText: { - ...typography.caption, - color: colors.text, - fontWeight: '600', - }, - chainDescription: { - ...typography.caption, - color: colors.textSecondary, - marginLeft: spacing.md, - }, - balancesHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - balancesTitleContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - balancesIcon: { - fontSize: 24, - marginRight: spacing.sm, - }, - refreshButton: { - flexDirection: 'row', - alignItems: 'center', - padding: spacing.sm, - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - }, - refreshIcon: { - fontSize: 16, - marginRight: spacing.xs, - }, - refreshText: { - ...typography.caption, - color: colors.text, - fontWeight: '600', - }, - loadingContainer: { - alignItems: 'center', - paddingVertical: spacing.lg, - }, - loadingText: { - ...typography.body, - color: colors.textSecondary, - marginTop: spacing.md, - }, - balancesList: { - gap: spacing.sm, - }, - balanceItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - backgroundColor: colors.background, - borderRadius: borderRadius.md, - marginBottom: spacing.xs, - }, - tokenInfo: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - }, - tokenIconContainer: { - width: 40, - height: 40, - borderRadius: borderRadius.full, - backgroundColor: colors.surface, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm, - ...shadows.sm, - }, - tokenIcon: { - fontSize: 20, - }, - tokenDetails: { - flex: 1, - }, - tokenSymbol: { - ...typography.body, - color: colors.text, - fontWeight: '600', - marginBottom: spacing.xs, - }, - tokenName: { - ...typography.caption, - color: colors.textSecondary, - }, - balanceInfo: { - alignItems: 'flex-end', - }, - tokenBalance: { - ...typography.body, - color: colors.text, - fontWeight: '600', - marginBottom: spacing.xs, - }, - tokenValue: { - ...typography.caption, - color: colors.textSecondary, - }, - tokenPriceChange: { - ...typography.caption, - color: colors.textSecondary, - marginTop: spacing.xs / 2, - }, - priceWarning: { - ...typography.caption, - color: colors.warning, - marginBottom: spacing.sm, - }, - priceMetaText: { - ...typography.caption, - color: colors.textSecondary, - marginBottom: spacing.sm, - }, - setupButton: { - marginTop: spacing.md, - }, - cryptoHeader: { - alignItems: 'center', - marginBottom: spacing.lg, - }, - cryptoIcon: { - fontSize: 48, - marginBottom: spacing.sm, - }, - protocolInfo: { - flexDirection: 'row', - justifyContent: 'space-around', - marginBottom: spacing.lg, - gap: spacing.md, - }, - protocolItem: { - alignItems: 'center', - flex: 1, - }, - protocolIcon: { - fontSize: 24, - marginBottom: spacing.xs, - }, - protocolName: { - ...typography.caption, - color: colors.text, - fontWeight: '600', - marginBottom: spacing.xs, - }, - protocolDesc: { - ...typography.caption, - color: colors.textSecondary, - textAlign: 'center', - fontSize: 10, - }, - readyHeader: { - alignItems: 'center', - }, - readyIcon: { - fontSize: 48, - marginBottom: spacing.sm, - }, -}); +function createStyles(colors: ReturnType) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.primary, + }, + scrollView: { + flex: 1, + }, + header: { + padding: spacing.lg, + paddingBottom: spacing.md, + }, + title: { + ...typography.h1, + color: colors.text.primary, + marginBottom: spacing.xs, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + }, + connectSection: { + padding: spacing.lg, + paddingTop: 0, + }, + connectedSection: { + padding: spacing.lg, + paddingTop: 0, + }, + sectionTitle: { + ...typography.h3, + color: colors.text.primary, + marginBottom: spacing.sm, + }, + sectionDescription: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.lg, + lineHeight: 22, + }, + connectHeader: { + alignItems: 'center', + marginBottom: spacing.lg, + }, + connectIcon: { + fontSize: 48, + marginBottom: spacing.sm, + }, + walletOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-around', + marginBottom: spacing.lg, + gap: spacing.md, + }, + walletOption: { + alignItems: 'center', + minWidth: 80, + }, + walletIconContainer: { + width: 60, + height: 60, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + ...shadows.sm, + }, + walletIcon: { + fontSize: 28, + }, + walletName: { + ...typography.caption, + color: colors.text.primary, + textAlign: 'center', + fontWeight: '600', + marginBottom: spacing.xs, + }, + walletDescription: { + ...typography.caption, + color: colors.textSecondary, + textAlign: 'center', + fontSize: 10, + }, + connectButtonContainer: { + marginTop: spacing.md, + alignItems: 'center', + }, + connectNote: { + ...typography.caption, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.sm, + fontStyle: 'italic', + }, + connectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + connectionStatus: { + flexDirection: 'row', + alignItems: 'center', + }, + statusIndicator: { + width: 12, + height: 12, + borderRadius: borderRadius.full, + marginRight: spacing.sm, + }, + statusText: { + ...typography.body, + color: colors.text.primary, + fontWeight: '600', + }, + connectionTime: { + ...typography.caption, + color: colors.textSecondary, + marginLeft: spacing.sm, + }, + disconnectButton: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.sm, + backgroundColor: colors.error, + borderRadius: borderRadius.md, + }, + disconnectIcon: { + fontSize: 16, + marginRight: spacing.xs, + }, + disconnectText: { + ...typography.caption, + color: colors.text.primary, + fontWeight: '600', + }, + walletInfo: { + marginBottom: spacing.md, + }, + addressContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + addressLabel: { + ...typography.caption, + color: colors.textSecondary, + }, + addressCopyButton: { + padding: spacing.xs, + backgroundColor: colors.surface, + borderRadius: borderRadius.sm, + }, + copyIcon: { + fontSize: 16, + color: colors.textSecondary, + }, + addressText: { + ...typography.h3, + color: colors.text.primary, + fontFamily: 'monospace', + marginBottom: spacing.md, + }, + chainInfo: { + alignItems: 'flex-start', + }, + chainBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.full, + marginBottom: spacing.xs, + }, + chainIcon: { + fontSize: 16, + marginRight: spacing.xs, + }, + chainText: { + ...typography.caption, + color: colors.text.primary, + fontWeight: '600', + }, + chainDescription: { + ...typography.caption, + color: colors.textSecondary, + marginLeft: spacing.md, + }, + balancesHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + balancesTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + balancesIcon: { + fontSize: 24, + marginRight: spacing.sm, + }, + refreshButton: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.sm, + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + }, + refreshIcon: { + fontSize: 16, + marginRight: spacing.xs, + }, + refreshText: { + ...typography.caption, + color: colors.text.primary, + fontWeight: '600', + }, + loadingContainer: { + alignItems: 'center', + paddingVertical: spacing.lg, + }, + loadingText: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.md, + }, + balancesList: { + gap: spacing.sm, + }, + balanceItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + backgroundColor: colors.background.primary, + borderRadius: borderRadius.md, + marginBottom: spacing.xs, + }, + tokenInfo: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + tokenIconContainer: { + width: 40, + height: 40, + borderRadius: borderRadius.full, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm, + ...shadows.sm, + }, + tokenIcon: { + fontSize: 20, + }, + tokenDetails: { + flex: 1, + }, + tokenSymbol: { + ...typography.body, + color: colors.text.primary, + fontWeight: '600', + marginBottom: spacing.xs, + }, + tokenName: { + ...typography.caption, + color: colors.textSecondary, + }, + balanceInfo: { + alignItems: 'flex-end', + }, + tokenBalance: { + ...typography.body, + color: colors.text.primary, + fontWeight: '600', + marginBottom: spacing.xs, + }, + tokenValue: { + ...typography.caption, + color: colors.textSecondary, + }, + tokenPriceChange: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs / 2, + }, + priceWarning: { + ...typography.caption, + color: colors.warning, + marginBottom: spacing.sm, + }, + priceMetaText: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + setupButton: { + marginTop: spacing.md, + }, + cryptoHeader: { + alignItems: 'center', + marginBottom: spacing.lg, + }, + cryptoIcon: { + fontSize: 48, + marginBottom: spacing.sm, + }, + protocolInfo: { + flexDirection: 'row', + justifyContent: 'space-around', + marginBottom: spacing.lg, + gap: spacing.md, + }, + protocolItem: { + alignItems: 'center', + flex: 1, + }, + protocolIcon: { + fontSize: 24, + marginBottom: spacing.xs, + }, + protocolName: { + ...typography.caption, + color: colors.text.primary, + fontWeight: '600', + marginBottom: spacing.xs, + }, + protocolDesc: { + ...typography.caption, + color: colors.textSecondary, + textAlign: 'center', + fontSize: 10, + }, + readyHeader: { + alignItems: 'center', + }, + readyIcon: { + fontSize: 48, + marginBottom: spacing.sm, + }, + }); +} export default WalletConnectScreen; diff --git a/src/services/auditIntegration.ts b/src/services/auditIntegration.ts index 42d1642f..e901521e 100644 --- a/src/services/auditIntegration.ts +++ b/src/services/auditIntegration.ts @@ -1,5 +1,5 @@ -import { AuditService } from '../../backend/services/auditService'; -import { AlertingService } from '../../backend/services/alerting'; +import { AuditService } from '../../backend/services/shared/auditService'; +import { AlertingService } from '../../backend/services/notification/alerting'; import type { AuditAction, AuditContext, @@ -9,7 +9,7 @@ import type { AuditReport, AuditSeverity, ComplianceAuditReport, -} from '../../backend/services/auditTypes'; +} from '../../backend/services/shared/auditTypes'; const AUDIT_HMAC_SECRET = process.env['AUDIT_HMAC_SECRET'] ?? 'subtrackr-audit-secret'; diff --git a/src/services/sandbox/blockchainMockService.ts b/src/services/sandbox/blockchainMockService.ts new file mode 100644 index 00000000..c2b96017 --- /dev/null +++ b/src/services/sandbox/blockchainMockService.ts @@ -0,0 +1,342 @@ +/** + * Frontend BlockchainMockService - Client-side mock blockchain integration. + * Simulates wallet connections, transaction signing, and contract interactions + * for sandbox testing without any on-chain costs. + */ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const BLOCKCHAIN_STORAGE_KEY = '@subtrackr_mock_blockchain'; + +export interface MockWallet { + address: string; + label: string; + balances: MockTokenBalance[]; + totalUsdValue: number; + createdAt: Date; +} + +export interface MockTokenBalance { + token: string; + amount: string; + usdValue: number; + icon: string; +} + +export interface MockTransaction { + id: string; + hash: string; + from: string; + to: string; + method: string; + status: 'pending' | 'confirmed' | 'failed'; + value: string; + token: string; + gasUsed: number; + blockNumber: number; + timestamp: Date; + confirmationTime?: number; // ms +} + +export interface MockContractCall { + method: string; + params: Record; + result: unknown; + simulated: true; +} + +class BlockchainMockService { + private static instance: BlockchainMockService; + private wallets: MockWallet[] = []; + private transactions: MockTransaction[] = []; + private blockNumber = 18_500_000; + private initialized = false; + + private readonly SUPPORTED_TOKENS = [ + { symbol: 'USDC', price: 1.0, icon: '💵' }, + { symbol: 'ETH', price: 2500, icon: '🔷' }, + { symbol: 'DAI', price: 1.0, icon: '🟡' }, + { symbol: 'WBTC', price: 45000, icon: '₿' }, + { symbol: 'USDT', price: 1.0, icon: '💲' }, + { symbol: 'MATIC', price: 0.85, icon: '🟣' }, + ]; + + private constructor() { + this.init(); + } + + static getInstance(): BlockchainMockService { + if (!BlockchainMockService.instance) { + BlockchainMockService.instance = new BlockchainMockService(); + } + return BlockchainMockService.instance; + } + + private async init(): Promise { + if (this.initialized) return; + try { + const stored = await AsyncStorage.getItem(BLOCKCHAIN_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + this.wallets = parsed.wallets.map((w: Record) => ({ + ...w, + createdAt: new Date(w.createdAt as string), + })); + this.transactions = parsed.transactions.map((t: Record) => ({ + ...t, + timestamp: new Date(t.timestamp as string), + })); + } else { + // Seed with a default virtual wallet + await this.createWallet('Developer Wallet', { + USDC: '10000.00', + ETH: '2.5', + DAI: '5000.00', + }); + } + this.initialized = true; + } catch { + this.initialized = true; + } + } + + private async persist(): Promise { + try { + await AsyncStorage.setItem( + BLOCKCHAIN_STORAGE_KEY, + JSON.stringify({ wallets: this.wallets, transactions: this.transactions }) + ); + } catch (error) { + console.warn('Failed to persist blockchain mock data:', error); + } + } + + /** Create a virtual wallet with initial balances */ + async createWallet( + label: string, + initialBalances: Record = {} + ): Promise { + const address = `0x${Array.from({ length: 40 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`; + + const balances: MockTokenBalance[] = this.SUPPORTED_TOKENS.map((token) => { + const amount = initialBalances[token.symbol] || '0'; + return { + token: token.symbol, + amount, + usdValue: parseFloat(amount) * token.price, + icon: token.icon, + }; + }); + + const wallet: MockWallet = { + address, + label, + balances, + totalUsdValue: balances.reduce((sum, b) => sum + b.usdValue, 0), + createdAt: new Date(), + }; + + this.wallets.push(wallet); + await this.persist(); + return wallet; + } + + /** Get all virtual wallets */ + getWallets(): MockWallet[] { + return this.wallets; + } + + /** Get a specific wallet */ + getWallet(address: string): MockWallet | null { + return this.wallets.find((w) => w.address === address) || null; + } + + /** Simulate connecting a wallet (always succeeds in sandbox) */ + async connectWallet(address: string): Promise { + await this.delay(200 + Math.random() * 300); + return this.getWallet(address); + } + + /** Simulate a token transfer */ + async transferTokens( + fromAddress: string, + toAddress: string, + amount: string, + token: string + ): Promise { + await this.delay(500 + Math.random() * 1000); + + const fromWallet = this.getWallet(fromAddress); + if (!fromWallet) { + throw new Error('Source wallet not found'); + } + + const balance = fromWallet.balances.find((b) => b.token === token); + if (!balance || parseFloat(balance.amount) < parseFloat(amount)) { + throw new Error('Insufficient virtual balance'); + } + + // Update balances + balance.amount = (parseFloat(balance.amount) - parseFloat(amount)).toString(); + const tokenPrice = this.SUPPORTED_TOKENS.find((t) => t.symbol === token)?.price || 1; + balance.usdValue = parseFloat(balance.amount) * tokenPrice; + fromWallet.totalUsdValue = fromWallet.balances.reduce((s, b) => s + b.usdValue, 0); + + const toWallet = this.getWallet(toAddress); + if (toWallet) { + const toBalance = toWallet.balances.find((b) => b.token === token); + if (toBalance) { + toBalance.amount = (parseFloat(toBalance.amount) + parseFloat(amount)).toString(); + toBalance.usdValue = parseFloat(toBalance.amount) * tokenPrice; + toWallet.totalUsdValue = toWallet.balances.reduce((s, b) => s + b.usdValue, 0); + } + } + + this.blockNumber++; + const tx = this.generateTransaction(fromAddress, toAddress, amount, token, 'transferTokens'); + tx.status = 'confirmed'; + this.transactions.push(tx); + await this.persist(); + + return tx; + } + + /** Simulate signing a transaction (always succeeds in sandbox) */ + async signTransaction( + fromAddress: string, + method: string, + params: Record + ): Promise { + await this.delay(300 + Math.random() * 500); + + this.blockNumber++; + const tx = this.generateTransaction( + fromAddress, + (params.to as string) || '0xContract', + (params.amount as string) || '0', + (params.token as string) || 'USDC', + method + ); + tx.status = 'confirmed'; + this.transactions.push(tx); + await this.persist(); + + return tx; + } + + /** Simulate a contract call (view-only, no gas) */ + async contractCall( + _contractAddress: string, + method: string, + params: Record = {} + ): Promise { + await this.delay(50 + Math.random() * 150); + + return { + method, + params, + result: { simulated: true, ...params }, + simulated: true, + }; + } + + /** Get transaction history */ + getTransactions(walletAddress?: string, limit: number = 20): MockTransaction[] { + let filtered = this.transactions; + if (walletAddress) { + filtered = filtered.filter((t) => t.from === walletAddress); + } + return filtered.slice(-limit).reverse(); + } + + /** Top up virtual balance */ + async topUpBalance( + walletAddress: string, + token: string, + amount: string + ): Promise { + const wallet = this.getWallet(walletAddress); + if (!wallet) return null; + + const balance = wallet.balances.find((b) => b.token === token); + if (!balance) return null; + + balance.amount = (parseFloat(balance.amount) + parseFloat(amount)).toString(); + const tokenPrice = this.SUPPORTED_TOKENS.find((t) => t.symbol === token)?.price || 1; + balance.usdValue = parseFloat(balance.amount) * tokenPrice; + wallet.totalUsdValue = wallet.balances.reduce((s, b) => s + b.usdValue, 0); + + await this.persist(); + return balance; + } + + /** Get current mock block number */ + getBlockNumber(): number { + return this.blockNumber; + } + + /** Get supported tokens */ + getSupportedTokens() { + return this.SUPPORTED_TOKENS; + } + + /** Reset all mock blockchain state */ + async reset(): Promise { + this.wallets = []; + this.transactions = []; + this.blockNumber = 18_500_000; + await AsyncStorage.removeItem(BLOCKCHAIN_STORAGE_KEY); + } + + /** Estimate gas for a transaction (always returns mock values) */ + estimateGas(method: string): { gasUnits: number; estimatedCostUsd: string } { + const baseGas: Record = { + createSubscription: 180_000, + processPayment: 95_000, + cancelSubscription: 65_000, + transferTokens: 45_000, + default: 75_000, + }; + + const gasUnits = Math.round((baseGas[method] || baseGas.default) * (0.8 + Math.random() * 0.4)); + const ethPrice = 2500; + const gasCostEth = (gasUnits * 25) / 1e9; // 25 gwei + const estimatedCostUsd = (gasCostEth * ethPrice).toFixed(2); + + return { gasUnits, estimatedCostUsd }; + } + + private generateTransaction( + from: string, + to: string, + value: string, + token: string, + method: string + ): MockTransaction { + const hash = `0x${Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`; + + return { + id: `mtx_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, + hash, + from, + to, + method, + status: 'pending', + value, + token, + gasUsed: Math.floor(45_000 + Math.random() * 155_000), + blockNumber: this.blockNumber, + timestamp: new Date(), + confirmationTime: 3000 + Math.random() * 12000, + }; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const blockchainMockService = BlockchainMockService.getInstance(); diff --git a/src/services/sandbox/developerPortalService.ts b/src/services/sandbox/developerPortalService.ts index 31e74844..5c8d65e1 100644 --- a/src/services/sandbox/developerPortalService.ts +++ b/src/services/sandbox/developerPortalService.ts @@ -363,7 +363,7 @@ client = SubTrackr(api_key=os.environ["SUBTRACKR_API_KEY"])`, code: `subscriptions = client.subscriptions.list(page=1, limit=20) for sub in subscriptions.data: - print(f"{sub.name}: ${sub.price}/{sub.billing_cycle}")`, + print(f"{sub.name}: \${sub.price}/{sub.billing_cycle}")`, language: 'python', }, { diff --git a/src/services/sandbox/index.ts b/src/services/sandbox/index.ts index aa7ee8d6..dbb876e9 100644 --- a/src/services/sandbox/index.ts +++ b/src/services/sandbox/index.ts @@ -5,3 +5,17 @@ export { usageTrackingService } from './usageTrackingService'; export { documentationService } from './documentationService'; export { developerPortalService } from './developerPortalService'; export { developerOnboardingService } from './developerOnboardingService'; +export { migrationService } from './migrationService'; +export { blockchainMockService } from './blockchainMockService'; +export type { + MigrationPlan, + MigrationStep, + MigrationChecklistItem, + MigrationResult, +} from './migrationService'; +export type { + MockWallet, + MockTokenBalance, + MockTransaction, + MockContractCall, +} from './blockchainMockService'; diff --git a/src/services/sandbox/migrationService.ts b/src/services/sandbox/migrationService.ts new file mode 100644 index 00000000..a7bfe852 --- /dev/null +++ b/src/services/sandbox/migrationService.ts @@ -0,0 +1,377 @@ +/** + * Frontend MigrationService - Client-side bridge to the sandbox migration wizard. + * Integrates with the backend MigrationService and AsyncStorage for state. + */ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const MIGRATION_STORAGE_KEY = '@subtrackr_migration_state'; + +export interface MigrationChecklistItem { + id: string; + category: 'security' | 'configuration' | 'data' | 'integration' | 'compliance'; + title: string; + description: string; + status: 'pending' | 'passed' | 'failed' | 'skipped'; + severity: 'critical' | 'warning' | 'info'; + recommendation?: string; +} + +export interface MigrationStep { + id: string; + order: number; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + checklist: MigrationChecklistItem[]; +} + +export interface MigrationPlan { + id: string; + sourceEnvironmentId: string; + sourceEnvironmentName: string; + status: 'draft' | 'validating' | 'ready' | 'in_progress' | 'completed' | 'failed'; + steps: MigrationStep[]; + createdAt: Date; + updatedAt: Date; + summary: { + totalSteps: number; + completedSteps: number; + totalChecks: number; + passedChecks: number; + failedChecks: number; + criticalFailures: number; + canProceed: boolean; + }; +} + +export interface MigrationResult { + success: boolean; + productionEnvironmentId?: string; + errors: string[]; + warnings: string[]; +} + +class MigrationService { + private static instance: MigrationService; + private plans: MigrationPlan[] = []; + private currentPlan: MigrationPlan | null = null; + + private constructor() { + this.loadPlans(); + } + + static getInstance(): MigrationService { + if (!MigrationService.instance) { + MigrationService.instance = new MigrationService(); + } + return MigrationService.instance; + } + + private async loadPlans(): Promise { + try { + const stored = await AsyncStorage.getItem(MIGRATION_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + this.plans = parsed.map((p: Record) => ({ + ...p, + createdAt: new Date(p.createdAt as string), + updatedAt: new Date(p.updatedAt as string), + })); + this.currentPlan = + this.plans.find((p) => p.status !== 'completed' && p.status !== 'failed') || null; + } + } catch { + this.plans = []; + } + } + + private async savePlans(): Promise { + try { + await AsyncStorage.setItem(MIGRATION_STORAGE_KEY, JSON.stringify(this.plans)); + } catch (error) { + console.warn('Failed to save migration plans:', error); + } + } + + /** Create a new migration plan for going from sandbox to production */ + async createMigrationPlan( + environmentId: string, + environmentName: string + ): Promise { + const planId = `mig_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + + const plan: MigrationPlan = { + id: planId, + sourceEnvironmentId: environmentId, + sourceEnvironmentName: environmentName, + status: 'draft', + steps: [ + { + id: 'step_preflight', + order: 1, + title: 'Pre-flight Validation', + description: 'Verify sandbox environment is ready for migration.', + status: 'pending', + checklist: [ + { + id: 'sec_api_keys', + category: 'security', + title: 'API Keys Rotated', + description: 'New production-scoped API keys generated.', + status: 'pending', + severity: 'critical', + recommendation: 'Generate new production keys before migration.', + }, + { + id: 'sec_rate_limits', + category: 'security', + title: 'Rate Limits Configured', + description: 'Production rate limits match expected traffic.', + status: 'pending', + severity: 'warning', + }, + { + id: 'cfg_isolation', + category: 'configuration', + title: 'Sandbox Isolation Removed', + description: 'No sandbox-specific flags in configuration.', + status: 'pending', + severity: 'critical', + }, + { + id: 'cfg_webhooks', + category: 'configuration', + title: 'Production Webhooks Set', + description: 'Webhook URLs point to production endpoints.', + status: 'pending', + severity: 'critical', + }, + ], + }, + { + id: 'step_export', + order: 2, + title: 'Export Configuration', + description: 'Export sandbox settings for production import.', + status: 'pending', + checklist: [], + }, + { + id: 'step_cleanup', + order: 3, + title: 'Data Sanitization', + description: 'Clear all test data and mock records.', + status: 'pending', + checklist: [ + { + id: 'data_test_cleared', + category: 'data', + title: 'Test Data Removed', + description: 'All mock subscriptions and payments cleared.', + status: 'pending', + severity: 'critical', + }, + { + id: 'data_real_ready', + category: 'data', + title: 'Production Data Configured', + description: 'Real pricing and subscription plans ready.', + status: 'pending', + severity: 'warning', + }, + ], + }, + { + id: 'step_integration', + order: 4, + title: 'Integration Setup', + description: 'Set up production monitoring and integrations.', + status: 'pending', + checklist: [ + { + id: 'int_monitoring', + category: 'integration', + title: 'Monitoring Active', + description: 'Error tracking and alerts configured.', + status: 'pending', + severity: 'warning', + }, + ], + }, + { + id: 'step_review', + order: 5, + title: 'Final Review', + description: 'Complete compliance checks and go live.', + status: 'pending', + checklist: [ + { + id: 'com_tos', + category: 'compliance', + title: 'Terms Accepted', + description: 'Production ToS reviewed and accepted.', + status: 'pending', + severity: 'critical', + }, + ], + }, + ], + createdAt: new Date(), + updatedAt: new Date(), + summary: { + totalSteps: 5, + completedSteps: 0, + totalChecks: 0, + passedChecks: 0, + failedChecks: 0, + criticalFailures: 0, + canProceed: false, + }, + }; + + this.plans.push(plan); + this.currentPlan = plan; + await this.savePlans(); + return plan; + } + + /** Get current migration plan */ + getCurrentPlan(): MigrationPlan | null { + return this.currentPlan; + } + + /** Start the validation phase */ + async startValidation(): Promise { + if (!this.currentPlan) return null; + + this.currentPlan.status = 'validating'; + this.currentPlan.steps[0].status = 'in_progress'; + + // Simulate validation + for (const check of this.currentPlan.steps[0].checklist) { + check.status = Math.random() > 0.2 ? 'passed' : 'failed'; + } + + this.currentPlan.steps[0].status = 'completed'; + this.updateSummary(); + this.currentPlan.status = this.currentPlan.summary.canProceed ? 'ready' : 'failed'; + this.currentPlan.updatedAt = new Date(); + + await this.savePlans(); + return this.currentPlan; + } + + /** Execute a specific step */ + async executeStep(stepId: string): Promise { + if (!this.currentPlan) return null; + + const step = this.currentPlan.steps.find((s) => s.id === stepId); + if (!step) return null; + + step.status = 'in_progress'; + this.currentPlan.status = 'in_progress'; + this.currentPlan.updatedAt = new Date(); + + // Simulate step execution + await this.delay(500 + Math.random() * 1000); + + step.status = 'completed'; + this.updateSummary(); + this.currentPlan.updatedAt = new Date(); + + // Check if all steps done + if (this.currentPlan.steps.every((s) => s.status === 'completed')) { + this.currentPlan.status = 'completed'; + } + + await this.savePlans(); + return step; + } + + /** Update a checklist item */ + async updateChecklistItem( + stepId: string, + itemId: string, + status: MigrationChecklistItem['status'] + ): Promise { + if (!this.currentPlan) return; + + const step = this.currentPlan.steps.find((s) => s.id === stepId); + if (!step) return; + + const item = step.checklist.find((c) => c.id === itemId); + if (!item) return; + + item.status = status; + this.updateSummary(); + this.currentPlan.updatedAt = new Date(); + await this.savePlans(); + } + + /** Complete migration */ + async completeMigration(): Promise { + if (!this.currentPlan) { + return { success: false, errors: ['No migration plan active'], warnings: [] }; + } + + this.currentPlan.status = 'completed'; + this.currentPlan.updatedAt = new Date(); + await this.savePlans(); + + return { + success: true, + productionEnvironmentId: `prod_${Date.now()}`, + errors: [], + warnings: [ + 'Rotate all API keys for production', + 'Monitor production traffic for 24 hours', + 'Keep sandbox for rollback', + ], + }; + } + + /** Reset/clear migration state */ + async resetMigration(): Promise { + this.plans = []; + this.currentPlan = null; + await AsyncStorage.removeItem(MIGRATION_STORAGE_KEY); + } + + private updateSummary(): void { + if (!this.currentPlan) return; + + let totalChecks = 0; + let passedChecks = 0; + let failedChecks = 0; + let criticalFailures = 0; + + for (const step of this.currentPlan.steps) { + for (const check of step.checklist) { + totalChecks++; + if (check.status === 'passed') passedChecks++; + if (check.status === 'failed') { + failedChecks++; + if (check.severity === 'critical') criticalFailures++; + } + } + } + + const completedSteps = this.currentPlan.steps.filter((s) => s.status === 'completed').length; + + this.currentPlan.summary = { + totalSteps: this.currentPlan.steps.length, + completedSteps, + totalChecks, + passedChecks, + failedChecks, + criticalFailures, + canProceed: criticalFailures === 0, + }; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const migrationService = MigrationService.getInstance(); diff --git a/src/services/sandbox/testDataGenerator.ts b/src/services/sandbox/testDataGenerator.ts index ee2fc542..c9edd217 100644 --- a/src/services/sandbox/testDataGenerator.ts +++ b/src/services/sandbox/testDataGenerator.ts @@ -181,6 +181,350 @@ class TestDataGenerator { return data; } + + // ── Enhanced realistic scenario generators ──────────────────────────────── + + /** Generate realistic payment history with trends and seasonality */ + generatePaymentHistory( + subscriptionCount: number, + monthsBack: number = 6 + ): { + month: string; + totalRevenue: number; + successfulPayments: number; + failedPayments: number; + cryptoRevenue: number; + fiatRevenue: number; + refunds: number; + chargebacks: number; + }[] { + const history = []; + const baseRevenue = subscriptionCount * 25; + const now = new Date(); + + for (let i = monthsBack - 1; i >= 0; i--) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + const seasonalFactor = 1 + Math.sin((date.getMonth() / 12) * Math.PI * 2) * 0.15; + const growthFactor = 1 + (monthsBack - i) * 0.03; + const revenue = baseRevenue * seasonalFactor * growthFactor; + const cryptoShare = 0.15 + Math.random() * 0.1; + + history.push({ + month: date.toISOString().substring(0, 7), + totalRevenue: Math.round(revenue * 100) / 100, + successfulPayments: Math.round(subscriptionCount * (0.85 + Math.random() * 0.1)), + failedPayments: Math.round(subscriptionCount * (0.02 + Math.random() * 0.05)), + cryptoRevenue: Math.round(revenue * cryptoShare * 100) / 100, + fiatRevenue: Math.round(revenue * (1 - cryptoShare) * 100) / 100, + refunds: Math.round(subscriptionCount * 0.01), + chargebacks: Math.round(subscriptionCount * 0.005), + }); + } + + return history; + } + + /** Generate virtual wallet balances for sandbox testing */ + generateVirtualBalances(walletCount: number = 3): { + walletAddress: string; + balances: { token: string; amount: string; usdValue: number }[]; + totalUsdValue: number; + label: string; + }[] { + const tokens = [ + { symbol: 'USDC', price: 1.0 }, + { symbol: 'ETH', price: 2500 }, + { symbol: 'DAI', price: 1.0 }, + { symbol: 'WBTC', price: 45000 }, + { symbol: 'USDT', price: 1.0 }, + ]; + + const labels = ['Primary Wallet', 'Testing Wallet', 'Business Wallet', 'Savings', 'Operations']; + const wallets = []; + + for (let i = 0; i < walletCount; i++) { + const balances = tokens.slice(0, 2 + Math.floor(Math.random() * 3)).map((token) => { + const amount = + token.price > 100 ? (Math.random() * 2).toFixed(6) : (Math.random() * 10000).toFixed(2); + return { + token: token.symbol, + amount, + usdValue: Math.round(parseFloat(amount) * token.price * 100) / 100, + }; + }); + + wallets.push({ + walletAddress: `0x${Array.from({ length: 40 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`, + balances, + totalUsdValue: balances.reduce((sum, b) => sum + b.usdValue, 0), + label: labels[i % labels.length], + }); + } + + return wallets; + } + + /** Generate realistic webhook event sequences */ + generateWebhookScenarios(): { + name: string; + description: string; + events: { + type: string; + payload: Record; + delayMs: number; + }[]; + }[] { + return [ + { + name: 'New Subscription Flow', + description: 'Customer signs up for a new subscription', + events: [ + { + type: 'customer.created', + payload: { customerId: 'cus_test_001', email: 'customer@example.com' }, + delayMs: 0, + }, + { + type: 'subscription.created', + payload: { + subscriptionId: 'sub_test_001', + plan: 'Pro Monthly', + amount: 29.99, + currency: 'USD', + }, + delayMs: 500, + }, + { + type: 'payment.succeeded', + payload: { + paymentId: 'pay_test_001', + amount: 29.99, + method: 'card', + transactionHash: null, + }, + delayMs: 2000, + }, + { + type: 'invoice.created', + payload: { invoiceId: 'inv_test_001', amount: 29.99, status: 'paid' }, + delayMs: 3000, + }, + ], + }, + { + name: 'Crypto Payment Flow', + description: 'Customer pays subscription with cryptocurrency', + events: [ + { + type: 'subscription.created', + payload: { + subscriptionId: 'sub_test_002', + plan: 'Enterprise', + amount: 199.99, + currency: 'USDC', + }, + delayMs: 0, + }, + { + type: 'payment.processing', + payload: { + paymentId: 'pay_test_002', + token: 'USDC', + walletAddress: '0x1234...', + confirmations: 0, + }, + delayMs: 1000, + }, + { + type: 'payment.confirmed', + payload: { + paymentId: 'pay_test_002', + transactionHash: `0x${'a'.repeat(64)}`, + confirmations: 12, + gasUsed: 95000, + }, + delayMs: 15000, + }, + ], + }, + { + name: 'Failed Payment & Recovery', + description: 'Payment fails then succeeds on retry', + events: [ + { + type: 'payment.attempted', + payload: { paymentId: 'pay_test_003', amount: 9.99 }, + delayMs: 0, + }, + { + type: 'payment.failed', + payload: { + paymentId: 'pay_test_003', + reason: 'insufficient_funds', + retryCount: 1, + }, + delayMs: 1000, + }, + { + type: 'payment.retried', + payload: { paymentId: 'pay_test_003', retryAttempt: 2 }, + delayMs: 86400000, // 1 day later + }, + { + type: 'payment.succeeded', + payload: { paymentId: 'pay_test_003_recovery', amount: 9.99, recovered: true }, + delayMs: 86402000, + }, + ], + }, + { + name: 'Subscription Cancellation', + description: 'Customer cancels their subscription', + events: [ + { + type: 'subscription.scheduled_cancellation', + payload: { + subscriptionId: 'sub_test_004', + cancelAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + reason: 'too_expensive', + }, + delayMs: 0, + }, + { + type: 'subscription.cancelled', + payload: { + subscriptionId: 'sub_test_004', + effectiveDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + refundAmount: 0, + }, + delayMs: 30 * 24 * 60 * 60 * 1000, + }, + ], + }, + ]; + } + + /** Generate a realistic blockchain transaction history for display */ + generateBlockchainTransactions(count: number = 10): { + hash: string; + from: string; + to: string; + value: string; + token: string; + method: string; + status: 'confirmed' | 'pending' | 'failed'; + blockNumber: number; + gasUsed: number; + timestamp: Date; + }[] { + const methods = [ + 'createSubscription', + 'processPayment', + 'cancelSubscription', + 'transferTokens', + 'updateSubscription', + ]; + const tokens = ['USDC', 'ETH', 'DAI', 'USDT']; + let blockNum = 18_500_000; + + return Array.from({ length: count }, (_, i) => { + blockNum += Math.floor(Math.random() * 5); + const method = this.randomFromArray(methods); + + return { + hash: `0x${Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`, + from: `0x${Array.from({ length: 40 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('')}`, + to: `0x${Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join( + '' + )}`, + value: + method === 'transferTokens' + ? (Math.random() * 100).toFixed(2) + : (Math.random() * 50).toFixed(2), + token: this.randomFromArray(tokens), + method, + status: Math.random() > 0.1 ? 'confirmed' : Math.random() > 0.5 ? 'pending' : 'failed', + blockNumber: blockNum, + gasUsed: Math.floor(45_000 + Math.random() * 155_000), + timestamp: new Date(Date.now() - i * 3600000 - Math.random() * 86400000), + }; + }); + } + + /** Generate realistic error scenarios for testing error handling */ + generateErrorScenarios(): { + scenario: string; + endpoint: string; + httpStatus: number; + errorCode: string; + message: string; + sandboxSpecific: boolean; + }[] { + return [ + { + scenario: 'Rate Limit Exceeded', + endpoint: '/api/v1/subscriptions', + httpStatus: 429, + errorCode: 'RATE_LIMIT_EXCEEDED', + message: 'Sandbox rate limit of 60 req/min exceeded. Production limit is 300 req/min.', + sandboxSpecific: true, + }, + { + scenario: 'Invalid API Key', + endpoint: '/api/v1/*', + httpStatus: 401, + errorCode: 'INVALID_API_KEY', + message: 'The provided API key is invalid or has been revoked.', + sandboxSpecific: false, + }, + { + scenario: 'Insufficient Virtual Balance', + endpoint: '/api/v1/payments/crypto', + httpStatus: 402, + errorCode: 'INSUFFICIENT_VIRTUAL_BALANCE', + message: 'Virtual wallet balance too low. Top up in Sandbox Settings.', + sandboxSpecific: true, + }, + { + scenario: 'Sandbox Feature Not Available', + endpoint: '/api/v1/sla', + httpStatus: 403, + errorCode: 'SANDBOX_FEATURE_DISABLED', + message: 'SLA features are not available in Free tier sandbox. Upgrade to Pro.', + sandboxSpecific: true, + }, + { + scenario: 'Production Endpoint in Sandbox', + endpoint: '/api/v1/production/*', + httpStatus: 400, + errorCode: 'PRODUCTION_IN_SANDBOX', + message: 'Cannot call production endpoint with sandbox API key.', + sandboxSpecific: true, + }, + { + scenario: 'Blockchain Simulation Error', + endpoint: '/api/v1/blockchain/transaction', + httpStatus: 500, + errorCode: 'BLOCKCHAIN_SIMULATION_ERROR', + message: 'Mock blockchain node unavailable. This is a sandbox-only error.', + sandboxSpecific: true, + }, + { + scenario: 'Webhook Delivery Failed', + endpoint: '/api/v1/webhooks/test', + httpStatus: 502, + errorCode: 'WEBHOOK_DELIVERY_FAILED', + message: 'Test webhook endpoint unreachable. Check your webhook URL.', + sandboxSpecific: false, + }, + ]; + } } export const testDataGenerator = TestDataGenerator.getInstance(); diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 90cfbb7a..be443cb8 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -108,12 +108,6 @@ export interface StreamSetup { protocol: 'superfluid' | 'sablier'; } -export interface GasEstimate { - gasLimit: string; - gasPrice: string; - estimatedCost: string; -} - /** Result after an on-chain Superfluid CFA stream is created */ export interface SuperfluidStreamResult { txHash: string; diff --git a/src/store/developerPortalStore.ts b/src/store/developerPortalStore.ts index d826fc89..dfbdd9e9 100644 --- a/src/store/developerPortalStore.ts +++ b/src/store/developerPortalStore.ts @@ -1,14 +1,12 @@ import { create } from 'zustand'; import { DeveloperProfile, - ApiKey, - ApiKeyStatus, - UsageStats, - UsageMetric, + ApiKeyPermission, + OnboardingStep, DocumentationSection, IntegrationGuide, - OnboardingStepInfo, -} from '../types/sandbox'; +} from '../types/developerPortal'; +import { ApiKey, ApiKeyStatus, UsageStats, UsageMetric } from '../types/sandbox'; import { developerPortalService } from '../services/sandbox/developerPortalService'; import { apiKeyService } from '../services/sandbox/apiKeyService'; import { usageTrackingService } from '../services/sandbox/usageTrackingService'; @@ -19,7 +17,7 @@ interface DeveloperPortalState { apiKeys: ApiKey[]; usageStats: UsageStats | null; recentUsage: UsageMetric[]; - onboardingSteps: OnboardingStepInfo[]; + onboardingSteps: OnboardingStep[]; documentation: DocumentationSection[]; integrationGuides: IntegrationGuide[]; isLoading: boolean; @@ -38,6 +36,7 @@ interface DeveloperPortalState { createApiKey: ( developerId: string, name: string, + permissions?: ApiKeyPermission[], options?: { rateLimit?: number; dailyLimit?: number; expiresAt?: Date } ) => Promise; revokeApiKey: (keyId: string) => Promise; @@ -160,10 +159,18 @@ export const useDeveloperPortalStore = create()((set, get) } }, - createApiKey: async (developerId, name, permissions, _options) => { + createApiKey: async ( + developerId: string, + name: string, + permissions?: ApiKeyPermission[], + _options?: { rateLimit?: number; dailyLimit?: number; expiresAt?: Date } + ) => { set({ isLoading: true, error: null }); try { - const permissionStrings = permissions?.map((p) => p.toString()) || ['read', 'write']; + const permissionStrings = permissions?.map((p: ApiKeyPermission) => p.toString()) || [ + 'read', + 'write', + ]; const apiKey = await apiKeyService.createApiKey( developerId, name, diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index 00bb11ff..7a0d4dc8 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -11,6 +11,7 @@ import { FraudRiskScore, FraudReviewStatus, FraudSubscriptionRecord, + FraudSignal, } from '../types/fraud'; const STORAGE_KEY = 'subtrackr-fraud-store'; @@ -482,7 +483,6 @@ const scoreSubscription = ( evidence, }; }; - const computeAnalytics = ( subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[] diff --git a/src/store/loyaltyStore.ts b/src/store/loyaltyStore.ts index c5060793..a5e0bba3 100644 --- a/src/store/loyaltyStore.ts +++ b/src/store/loyaltyStore.ts @@ -11,6 +11,21 @@ import { LoyaltyProgram, } from '../types/loyalty'; +export interface StreakData { + current: number; + longest: number; + lastPaymentDate?: string | null; + frozenUntil?: string | null; +} + +export interface Achievement { + id: string; + name: string; + description: string; + icon: string; + unlockedAt?: Date | null | string; +} + const STORAGE_KEY = 'subtrackr-loyalty'; const STORE_VERSION = 1; diff --git a/src/store/sandboxStore.ts b/src/store/sandboxStore.ts index d1815a06..40c5a876 100644 --- a/src/store/sandboxStore.ts +++ b/src/store/sandboxStore.ts @@ -27,8 +27,6 @@ const STORE_VERSION = 3; const API_KEY_PREFIX = 'sk_sandbox_'; const KEY_PREFIX_LENGTH = 8; const HASH_COST = 10; -const _FALLBACK_HASH = bcrypt.hashSync('fallback-placeholder', HASH_COST); - const generateId = (prefix: string): string => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; @@ -691,11 +689,12 @@ export const useSandboxStore = create()( generateApiKey: async (name) => { try { set({ isLoading: true, error: null }); + const id = generateId('key'); const key = generateApiKeyString(); const hashedKey = await hashApiKey(key); const sandboxId = get().currentSandbox?.id || get().sandboxConfig.id; const apiKey: ApiKey = { - id: generateId('key'), + id, key: key.substring(0, KEY_PREFIX_LENGTH), keyPrefix: key.substring(0, KEY_PREFIX_LENGTH), hashedKey, @@ -707,11 +706,10 @@ export const useSandboxStore = create()( expiresAt: null, lastUsedAt: null, usageCount: 0, - auditLogs: [createAuditEntry('', 'created', 'Generated a new API key in state')], + auditLogs: [createAuditEntry(id, 'created', 'Generated a new API key in state')], createdAt: new Date(), updatedAt: new Date(), }; - apiKey.auditLogs[0].apiKeyId = apiKey.id; set((state) => ({ apiKeys: [...state.apiKeys, apiKey], onboardingSteps: state.onboardingSteps.map((s) => @@ -735,10 +733,11 @@ export const useSandboxStore = create()( createApiKey: async (input) => { try { set({ isLoading: true, error: null }); + const id = generateId('key'); const key = generateApiKeyString(); const hashedKey = await hashApiKey(key); const apiKey: ApiKey = { - id: generateId('key'), + id, key: key.substring(0, KEY_PREFIX_LENGTH), keyPrefix: key.substring(0, KEY_PREFIX_LENGTH), hashedKey, @@ -751,11 +750,10 @@ export const useSandboxStore = create()( expiresAt: null, lastUsedAt: null, usageCount: 0, - auditLogs: [createAuditEntry('', 'created', 'Created a new managed API key')], + auditLogs: [createAuditEntry(id, 'created', 'Created a new managed API key')], createdAt: new Date(), updatedAt: new Date(), }; - apiKey.auditLogs[0].apiKeyId = apiKey.id; set((state) => ({ apiKeys: [...state.apiKeys, apiKey], isLoading: false, diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 323053b3..8be1e478 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -268,18 +268,21 @@ export const useSubscriptionStore = create()( const preview = previewProration(sub, newPlanData.price ?? sub.price, effectiveDate); + // Generate credit memo if downgrade const updatedCreditMemos = { ...get().creditMemos }; if (preview.isCredit && preview.amount > 0) { const memo = generateCreditMemo(id, preview.amount, preview.description); updatedCreditMemos[id] = memo; } + // Update subscription const updates: Partial = { ...newPlanData, updatedAt: new Date(), }; if (effectiveDate === 'immediate') { + // Reset billing cycle updates.nextBillingDate = advanceBillingDate( new Date(), newPlanData.billingCycle ?? sub.billingCycle @@ -318,6 +321,7 @@ export const useSubscriptionStore = create()( }, })); + // Could trigger a reduced charge here console.log(`Applied credit: final charge ${finalCharge}`); }, diff --git a/src/store/supportStore.ts b/src/store/supportStore.ts index c090578c..efc8db8c 100644 --- a/src/store/supportStore.ts +++ b/src/store/supportStore.ts @@ -177,12 +177,9 @@ export const useSupportStore = create((set, get) => ({ return updated; } - const ticket = createTicketFromEvent(event, event.relatedTicketIds ?? []); const relatedTicketIds = get() - .tickets.filter( - (ticket) => ticket.subscriptionId === event.subscriptionId && ticket.status !== 'closed' - ) - .map((ticket) => ticket.id); + .tickets.filter((t) => t.subscriptionId === event.subscriptionId && t.status !== 'closed') + .map((t) => t.id); const ticket = createTicketFromEvent(event, relatedTicketIds); set((state) => ({ tickets: [...state.tickets, ticket] })); return ticket; diff --git a/src/theme/colors.ts b/src/theme/colors.ts index ec73918c..7493c16f 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -53,20 +53,11 @@ export const lightColors = { warningBackground: 'rgba(245, 158, 11, 0.16)', primary: '#6366F1', secondary: '#10B981', - accent: '#06B6D4', success: '#10B981', warning: '#F59E0B', error: '#EF4444', backgroundFlat: '#FFFFFF', - surface: '#FFFFFF', - surfaceVariant: '#F3F4F6', - text: '#1A1A1A', textSecondary: '#6B7280', - border: '#E5E7EB', - onPrimary: '#FFFFFF', - onSecondary: '#FFFFFF', - onSurface: '#1A1A1A', - onSurfaceVariant: '#6B7280', } as const; export const darkColors = { @@ -124,20 +115,11 @@ export const darkColors = { warningBackground: 'rgba(251, 191, 36, 0.16)', primary: '#6366F1', secondary: '#34D399', - accent: '#34D399', success: '#34D399', warning: '#FBBF24', error: '#F87171', backgroundFlat: '#000000', - surface: '#1A1A1A', - surfaceVariant: '#262626', - text: '#F9FAFB', textSecondary: '#9CA3AF', - border: '#374151', - onPrimary: '#1A1A1A', - onSecondary: '#1A1A1A', - onSurface: '#F9FAFB', - onSurfaceVariant: '#9CA3AF', } as const; export type ColorTokens = typeof lightColors; diff --git a/src/utils/__tests__/imageCache.test.ts b/src/utils/__tests__/imageCache.test.ts index 0665fd24..ad10a062 100644 --- a/src/utils/__tests__/imageCache.test.ts +++ b/src/utils/__tests__/imageCache.test.ts @@ -32,7 +32,7 @@ beforeEach(() => { mockStorage.setItem.mockResolvedValue(undefined); mockStorage.removeItem.mockResolvedValue(undefined); mockImage.prefetch.mockResolvedValue(true); - mockImage.clearDiskCache.mockResolvedValue(undefined); + mockImage.clearDiskCache.mockResolvedValue(true); }); // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/src/utils/startupTimeOptimizer.ts b/src/utils/startupTimeOptimizer.ts index c780f192..a27dbf9d 100644 --- a/src/utils/startupTimeOptimizer.ts +++ b/src/utils/startupTimeOptimizer.ts @@ -73,8 +73,9 @@ export const initHermesOptimizations = () => { startupTimeOptimizer.setupAppStateObserver(); - const hermesFlags = global.HermesInternal?.getInstrumentedFlags?.() ?? {}; - const isHermesEnabled = !!global.HermesInternal; + const hermesInternal = (global as any).HermesInternal; + const hermesFlags = hermesInternal?.getInstrumentedFlags?.() ?? {}; + const isHermesEnabled = !!hermesInternal; if (__DEV__) { console.info('[Hermes] Optimizations initialized', {