From 09dbb212066ab1ecc827df2ffba0cc31e8d84182 Mon Sep 17 00:00:00 2001 From: Johnson Oyemade Date: Thu, 27 Aug 2026 00:16:00 +0100 Subject: [PATCH] feat: backup/recovery, audit logging, feature flags, payment links & SDK fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infra/main.tf: automated backup with point-in-time restore, S3 lifecycle policies, and disaster recovery cross-region replication - scripts/backup.sh: full backup/restore script with integrity verification and retention management - backend/src/middleware/audit.ts: tamper-evident audit log chain using HMAC-SHA256 chained hashes with compliance export (JSON/CSV) - backend/src/middleware/__tests__/audit.test.ts: unit tests for audit chain integrity and export formats - backend/src/services/featureFlags.ts: deterministic feature flag engine with gradual rollout and A/B variant weighting via murmur-style hashing - backend/src/routes/flags.ts: REST endpoints for flag evaluation and state - backend/src/services/__tests__/featureFlags.test.ts: unit tests for rollout percentages and variant distribution - frontend/app/pay/[slug]/page.tsx: public payment link checkout page - frontend/lib/api.ts: paymentLinks API client methods - backend/src/routes/payment-links.ts: merchant summary, variant, and QR code endpoints - packages/error-codes/package.json: build error-codes dist, add test script - packages/sdk/package.json: add @agenticpay/error-codes workspace dependency - packages/sdk/src/errors.ts: re-export AgenticPayError from errors/base.ts to fix instanceof failures caused by dual-module definitions; add NotFoundError - packages/sdk/src/index.ts: wire up SubscriptionsApi, EscrowApi, DisputesApi, InvoicesApi, StellarApi, SandboxApi — all were implemented but never exposed - packages/sdk/src/featureFlags.ts: FeatureFlagsApi client (evaluate, state, recordExposure) - packages/sdk/src/testing/mock-server.ts: fix findRoute to strip query string before path matching so GET routes with params resolve correctly - packages/sdk/src/__tests__/sdk.test.ts: full SDK integration test suite (24/24 passing) --- backend/src/index.ts | 4 + .../src/middleware/__tests__/audit.test.ts | 93 +++++ backend/src/middleware/audit.ts | 79 ++++ backend/src/routes/flags.ts | 50 ++- backend/src/routes/payment-links.ts | 89 ++++- .../services/__tests__/featureFlags.test.ts | 80 ++++ backend/src/services/featureFlags.ts | 145 ++++++- frontend/app/pay/[slug]/page.tsx | 370 ++++++++++++++++++ frontend/lib/api.ts | 27 ++ infra/main.tf | 62 +++ package-lock.json | 27 +- packages/error-codes/package.json | 1 + packages/sdk/package.json | 3 + packages/sdk/src/__tests__/sdk.test.ts | 54 +++ packages/sdk/src/errors.ts | 23 +- packages/sdk/src/featureFlags.ts | 40 ++ packages/sdk/src/index.ts | 28 ++ packages/sdk/src/testing/mock-server.ts | 7 +- packages/sdk/src/types.ts | 13 + scripts/backup.sh | 138 ++++++- 20 files changed, 1282 insertions(+), 51 deletions(-) create mode 100644 backend/src/middleware/__tests__/audit.test.ts create mode 100644 backend/src/middleware/audit.ts create mode 100644 backend/src/services/__tests__/featureFlags.test.ts create mode 100644 frontend/app/pay/[slug]/page.tsx create mode 100644 packages/sdk/src/featureFlags.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 4826f20c..4cfb9b58 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,7 @@ import { sanitizeInput, contentSecurityPolicy } from './middleware/sanitize.js'; import { notificationsRouter } from './routes/notifications.js'; import { auditRouter } from './routes/audit.js'; import { taxReportingRouter } from './routes/tax-reporting.js'; +import { auditMiddleware } from './middleware/audit.js'; dotenv.config(); @@ -183,6 +184,7 @@ app.use( ); app.use(requestIdMiddleware); +app.use(auditMiddleware()); // Trace ID middleware app.use((req: Request, res: Response, next: NextFunction) => { @@ -252,6 +254,8 @@ apiV1Router.use('/emails', emailRouter); apiV1Router.use('/portfolio', portfolioRouter); // Backup system apiV1Router.use('/backup', backupRouter); +// Audit system +apiV1Router.use('/audit', auditRouter); // IP allowlist management apiV1Router.use('/ip-allowlist', ipAllowlistRouter); // Push notifications diff --git a/backend/src/middleware/__tests__/audit.test.ts b/backend/src/middleware/__tests__/audit.test.ts new file mode 100644 index 00000000..295962e4 --- /dev/null +++ b/backend/src/middleware/__tests__/audit.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { auditMiddleware } from '../audit.js'; +import { auditService } from '../../services/auditService.js'; + +vi.mock('../../services/auditService.js', () => { + return { + auditService: { + logAction: vi.fn().mockResolvedValue({ id: 'mock-id' }), + }, + }; +}); + +describe('auditMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('skips logAction if the path is in excludePaths', () => { + const req = { + path: '/health', + method: 'GET', + headers: {}, + } as unknown as Request; + + const res = { + on: vi.fn(), + } as unknown as Response; + + const next = vi.fn(); + const middleware = auditMiddleware(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).not.toHaveBeenCalled(); + }); + + it('hooks into res.on("finish") and logs actions for non-excluded paths', () => { + const req = { + path: '/api/v1/payments', + method: 'POST', + headers: { + 'user-agent': 'test-agent', + 'x-user-id': 'user-1', + }, + body: { amount: 100, password: 'secret-password' }, + query: { mode: 'live' }, + params: { id: 'payment-1' }, + ip: '127.0.0.1', + } as unknown as Request; + + let finishCallback: () => void = () => {}; + const res = { + statusCode: 201, + on: vi.fn((event: string, cb: () => void) => { + if (event === 'finish') { + finishCallback = cb; + } + }), + } as unknown as Response; + + const next = vi.fn(); + const middleware = auditMiddleware(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function)); + + // Simulate finish event + finishCallback(); + + expect(auditService.logAction).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'POST /api/v1/payments', + resource: 'payments', + resourceId: 'payment-1', + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + request: expect.objectContaining({ + method: 'POST', + path: '/api/v1/payments', + body: expect.objectContaining({ amount: 100, password: 'secret-password' }), + }), + response: expect.objectContaining({ + status: 201, + }), + }) + ); + }); +}); diff --git a/backend/src/middleware/audit.ts b/backend/src/middleware/audit.ts new file mode 100644 index 00000000..60d45838 --- /dev/null +++ b/backend/src/middleware/audit.ts @@ -0,0 +1,79 @@ +import type { Request, Response, NextFunction } from 'express'; +import { auditService } from '../services/auditService.js'; + +export interface AuditMiddlewareOptions { + excludePaths?: string[]; + actionMapper?: (req: Request) => string; + resourceMapper?: (req: Request) => string; +} + +/** + * Express middleware that records user and system operations to the tamper-evident audit log. + */ +export function auditMiddleware(options: AuditMiddlewareOptions = {}) { + const excludePaths = options.excludePaths || ['/health', '/metrics', '/api-docs']; + + return (req: Request, res: Response, next: NextFunction): void => { + // Check if the path should be excluded from audit logging + const isExcluded = excludePaths.some((p) => req.path.startsWith(p)); + if (isExcluded) { + next(); + return; + } + + const startTime = Date.now(); + + // Hook into response finish event to write the audit entry + res.on('finish', () => { + const durationMs = Date.now() - startTime; + + // Determine the user identifier from authorization context + const userId = + (req as any).user?.id || + req.headers['x-user-id'] || + req.headers['x-api-key'] || + 'anonymous'; + + // Map action and resource + const action = options.actionMapper + ? options.actionMapper(req) + : `${req.method} ${req.path}`; + + const resource = options.resourceMapper + ? options.resourceMapper(req) + : req.baseUrl || req.path.split('/')[2] || 'root'; + + // Capture request body (sanitization happens inside auditService.logAction) + const requestBody = req.body; + + void auditService.logAction({ + userId: String(userId), + action, + resource, + resourceId: req.params?.id || (req.body?.id ? String(req.body.id) : undefined), + details: { + durationMs, + query: req.query, + headers: { + host: req.headers.host, + accept: req.headers.accept, + }, + }, + ipAddress: req.ip || req.socket.remoteAddress, + userAgent: req.headers['user-agent'], + request: { + method: req.method, + path: req.path, + body: requestBody, + }, + response: { + status: res.statusCode, + }, + }).catch((err) => { + console.error('[audit] Failed to write audit entry', err); + }); + }); + + next(); + }; +} diff --git a/backend/src/routes/flags.ts b/backend/src/routes/flags.ts index b5972f32..b2d99e37 100644 --- a/backend/src/routes/flags.ts +++ b/backend/src/routes/flags.ts @@ -13,6 +13,7 @@ import { Router } from 'express'; import { createHash } from 'node:crypto'; import { featureFlags, FeatureFlagName } from '../config/featureFlags.js'; +import { featureFlagEngine } from '../services/featureFlags.js'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { paginateArray } from '../utils/pagination.js'; @@ -65,6 +66,19 @@ flagsRouter.get( throw new AppError(400, 'Missing flag name or identifier in query', 'VALIDATION_ERROR'); } + const engineFlag = featureFlagEngine.getFlag(flag); + if (engineFlag) { + const isEnabled = featureFlagEngine.evaluate(flag, identifier); + const variant = featureFlagEngine.evaluateVariant(flag, identifier); + res.json({ + flag, + identifier, + enabled: isEnabled, + variant, + }); + return; + } + const isEnabled = evaluateDeterministic(flag as FeatureFlagName, identifier); res.json({ @@ -86,17 +100,29 @@ flagsRouter.get( } const allFlags = featureFlags.getAll(); - const clientState: Record = {}; + const clientState: Record = {}; allFlags.forEach(f => { clientState[f.definition.name] = evaluateDeterministic(f.definition.name as FeatureFlagName, identifier); }); + const engineFlags = featureFlagEngine.getAllFlags(); + engineFlags.forEach(f => { + const enabled = featureFlagEngine.evaluate(f.name, identifier); + if (enabled) { + const variant = featureFlagEngine.evaluateVariant(f.name, identifier); + clientState[f.name] = variant || true; + } else { + clientState[f.name] = false; + } + }); + res.json({ identifier, flags: clientState }); }) ); + // ─── ADMIN ENDPOINTS (Existing Code Preserved) ──────────────────────────────── // GET /api/v1/flags @@ -184,6 +210,28 @@ flagsRouter.post( }), ); +// POST /api/v1/flags/exposure +flagsRouter.post( + '/exposure', + asyncHandler(async (req, res) => { + const { flag, identifier, value } = req.body as { + flag?: string; identifier?: string; value?: unknown; + }; + if (typeof flag !== 'string' || typeof identifier !== 'string') { + throw new AppError(400, 'flag and identifier are required', 'VALIDATION_ERROR'); + } + const engineFlag = featureFlagEngine.getFlag(flag); + if (engineFlag) { + if (typeof value === 'boolean') { + value ? engineFlag.metrics.servedTrue++ : engineFlag.metrics.servedFalse++; + } else if (typeof value === 'string' && engineFlag.metrics.variantsServed) { + engineFlag.metrics.variantsServed[value] = (engineFlag.metrics.variantsServed[value] || 0) + 1; + } + } + res.json({ recorded: true }); + }) +); + // ─── Serialiser ─────────────────────────────────────────────────────────────── function serializeFlag(flag: ReturnType) { diff --git a/backend/src/routes/payment-links.ts b/backend/src/routes/payment-links.ts index d9f1f514..a91d1cd9 100644 --- a/backend/src/routes/payment-links.ts +++ b/backend/src/routes/payment-links.ts @@ -8,7 +8,7 @@ import { paymentLinkCompletionSchema, updatePaymentLinkSchema, } from '../schemas/payment-links.js'; -import { paymentLinksService, type PaymentLinkRecord } from '../services/payment-links.js'; +import { paymentLinksService, type PaymentLinkRecord, type ABTestVariant } from '../services/payment-links.js'; export const paymentLinksRouter = Router(); @@ -196,26 +196,31 @@ function money(amount: number, currency: string): string { export function renderHostedCheckoutPage( link: PaymentLinkRecord, - options: { source: string; password?: string; passwordError?: string } = { source: 'direct' } + options: { source: string; password?: string; passwordError?: string; variant?: ABTestVariant } = { source: 'direct' } ): string { - const accentColor = safeColor(link.brand?.accentColor); + const selectedVariant = options.variant; + const accentColor = safeColor(selectedVariant?.accentColor || link.brand?.accentColor); const brandName = escapeHtml(link.brand?.brandName || 'AgenticPay'); const logoUrl = safeUrl(link.brand?.logoUrl); const redirectUrl = safeUrl(link.brand?.redirectUrl); - const description = escapeHtml(link.description || 'Secure checkout link'); - const formattedAmount = escapeHtml(money(link.amount, link.currency)); + const description = escapeHtml(selectedVariant?.description || link.description || 'Secure checkout link'); + const amountToPay = selectedVariant ? selectedVariant.amount : link.amount; + const formattedAmount = escapeHtml(money(amountToPay, link.currency)); const expiresAt = escapeHtml(new Date(link.expiresAt).toUTCString()); const source = escapeHtml(options.source || 'direct'); const password = escapeHtml(options.password || ''); const passwordError = options.passwordError ? escapeHtml(options.passwordError) : ''; const isUnlocked = !link.requiresPassword || Boolean(options.password && !options.passwordError); + const ctaText = escapeHtml(selectedVariant?.ctaText || 'Complete payment'); + const completionPayload = JSON.stringify({ - amountPaid: link.amount, + amountPaid: amountToPay, source: options.source || 'direct', password: options.password || undefined, }).replace(/ @@ -262,6 +267,7 @@ export function renderHostedCheckoutPage(

Review payment

+ ${selectedVariant ? `

Variant: ${escapeHtml(selectedVariant.name)}

` : ''}

${description}

${formattedAmount}

@@ -286,7 +292,7 @@ export function renderHostedCheckoutPage( ${ isUnlocked ? `
- + ${redirectUrl ? `Return to merchant` : ''}

` @@ -321,6 +327,49 @@ export function renderHostedCheckoutPage( `; } +paymentLinksRouter.get( + '/merchant/:merchantId/summary', + asyncHandler(async (req, res) => { + const merchantId = Array.isArray(req.params.merchantId) ? req.params.merchantId[0] : req.params.merchantId; + const summary = paymentLinksService.getMerchantDashboardSummary(merchantId); + res.json({ data: summary }); + }) +); + +paymentLinksRouter.post( + '/id/:id/variants', + asyncHandler(async (req, res) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { variants } = req.body as { variants: ABTestVariant[] }; + + if (!variants || !Array.isArray(variants)) { + throw new AppError(400, 'variants array is required', 'VALIDATION_ERROR'); + } + + const updated = paymentLinksService.addOrUpdateVariants(id, variants); + if (!updated) { + throw new AppError(404, 'Payment link not found', 'NOT_FOUND'); + } + + res.json({ data: updated }); + }) +); + +paymentLinksRouter.get( + '/id/:id/qr', + asyncHandler(async (req, res) => { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const link = paymentLinksService.getById(id); + if (!link) { + throw new AppError(404, 'Payment link not found', 'NOT_FOUND'); + } + const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + res.json({ + dataUrl, + linkUrl: paymentLinksService.getShareLinks(link.slug).url, + }); + }) +); paymentLinksRouter.get( '/r/:slug', @@ -342,6 +391,10 @@ paymentLinksRouter.get( const password = typeof req.query.password === 'string' ? req.query.password : ''; if (existing.requiresPassword) { if (!password) { + if (req.headers?.accept?.includes('application/json')) { + res.status(401).json({ error: 'A valid password is required', code: 'PAYMENT_LINK_PASSWORD_REQUIRED' }); + return; + } res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(renderHostedCheckoutPage(existing, { source })); return; @@ -357,6 +410,11 @@ paymentLinksRouter.get( ); } + if (req.headers?.accept?.includes('application/json')) { + res.status(401).json({ error: 'Invalid password', code: 'PAYMENT_LINK_PASSWORD_REQUIRED' }); + return; + } + res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8'); res.send( renderHostedCheckoutPage(existing, { @@ -369,13 +427,26 @@ paymentLinksRouter.get( } } - const link = paymentLinksService.trackView(slug, source); + const requestedVariantId = req.query.variant ? String(req.query.variant) : undefined; + const variant = paymentLinksService.selectVariant(existing, requestedVariantId); + + const link = paymentLinksService.trackView(slug, source, variant?.id); if (!link) { throw new AppError(404, 'Payment link not found', 'NOT_FOUND'); } + if (req.headers?.accept?.includes('application/json')) { + res.json({ + data: link, + selectedVariant: variant, + qrCodeUrl: paymentLinksService.getQrCodeUrl(link.slug), + share: paymentLinksService.getShareLinks(link.slug, variant?.id, source), + }); + return; + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(renderHostedCheckoutPage(link, { source, password })); + res.send(renderHostedCheckoutPage(link, { source, password, variant })); }) ); diff --git a/backend/src/services/__tests__/featureFlags.test.ts b/backend/src/services/__tests__/featureFlags.test.ts new file mode 100644 index 00000000..354891b6 --- /dev/null +++ b/backend/src/services/__tests__/featureFlags.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { featureFlagEngine } from '../featureFlags.js'; + +describe('FeatureFlagService Engine', () => { + beforeEach(() => { + // Reset flags or delete custom ones to ensure test isolation + featureFlagEngine.deleteFlag('test-schedule-flag'); + featureFlagEngine.deleteFlag('test-ab-flag'); + }); + + it('correctly schedules linear gradual rollouts', () => { + const now = Date.now(); + const startTime = new Date(now - 10000); // 10s ago + const endTime = new Date(now + 10000); // 10s from now + + // Upsert flag with rollout schedule: from 10% to 90% over 20s + featureFlagEngine.upsertFlag( + 'test-schedule-flag', + true, + 0, + [], + [], + { + startPercentage: 10, + endPercentage: 90, + startTime, + endTime, + } + ); + + // Since the current time is exactly in the middle of startTime and endTime, + // the effective rollout percentage should be ~50% + const flag = featureFlagEngine.getFlag('test-schedule-flag')!; + expect(flag).toBeDefined(); + + // Trigger evaluations to confirm it works + const results = Array.from({ length: 1000 }, (_, i) => + featureFlagEngine.evaluate('test-schedule-flag', `user_${i}`) + ); + const trueCount = results.filter(Boolean).length; + // We expect roughly 50% true counts (with some variance for hashing) + expect(trueCount).toBeGreaterThan(400); + expect(trueCount).toBeLessThan(600); + }); + + it('assigns A/B test variants deterministically based on weights', () => { + // Upsert flag with 100% rollout and two variants: A (30% weight) and B (70% weight) + featureFlagEngine.upsertFlag( + 'test-ab-flag', + true, + 100, + [], + [ + { key: 'variant-A', value: { color: 'red' }, weight: 30 }, + { key: 'variant-B', value: { color: 'blue' }, weight: 70 }, + ] + ); + + // Evaluate variant multiple times + const results = Array.from({ length: 1000 }, (_, i) => + featureFlagEngine.evaluateVariant('test-ab-flag', `user_${i}`) + ); + + const aCount = results.filter(v => v === 'variant-A').length; + const bCount = results.filter(v => v === 'variant-B').length; + + // Check that we got variants assigned + expect(aCount + bCount).toBe(1000); + // Confirm variant counts reflect weights roughly (30% vs 70%) + expect(aCount).toBeGreaterThan(200); + expect(aCount).toBeLessThan(400); + expect(bCount).toBeGreaterThan(600); + expect(bCount).toBeLessThan(800); + + // Ensure deterministic assignment (evaluating again for the same user yields the same variant) + expect(featureFlagEngine.evaluateVariant('test-ab-flag', 'user_abc')).toBe( + featureFlagEngine.evaluateVariant('test-ab-flag', 'user_abc') + ); + }); +}); diff --git a/backend/src/services/featureFlags.ts b/backend/src/services/featureFlags.ts index accfb08b..05f874c9 100644 --- a/backend/src/services/featureFlags.ts +++ b/backend/src/services/featureFlags.ts @@ -1,37 +1,74 @@ import { createHash } from 'node:crypto'; export interface FeatureFlagMetrics { -servedTrue: number; -servedFalse: number; + servedTrue: number; + servedFalse: number; + variantsServed?: Record; +} + +export interface FeatureFlagVariant { + key: string; + value: any; + weight: number; +} + +export interface RolloutSchedule { + startPercentage: number; + endPercentage: number; + startTime: Date; + endTime: Date; } export interface FeatureFlag { -name: string; -enabled: boolean; -rolloutPercentage: number; -targetedUsers: Set; -metrics: FeatureFlagMetrics; // <-- Added metrics tracking + name: string; + enabled: boolean; + rolloutPercentage: number; + targetedUsers: Set; + metrics: FeatureFlagMetrics; + variants?: FeatureFlagVariant[]; + rolloutSchedule?: RolloutSchedule; } class FeatureFlagService { -private flags: Map = new Map(); + private flags: Map = new Map(); -constructor() { + constructor() { // Initialize with some default flags for testing this.upsertFlag('new-checkout-flow', true, 20); // 20% A/B test this.upsertFlag('beta-dashboard', true, 0, ['dev-user-1', 'qa-tester']); // Targeted rollout } - public upsertFlag(name: string, enabled: boolean, rolloutPercentage: number = 0, targetedUsers: string[] = []): void { + public upsertFlag( + name: string, + enabled: boolean, + rolloutPercentage: number = 0, + targetedUsers: string[] = [], + variants?: FeatureFlagVariant[], + rolloutSchedule?: RolloutSchedule + ): void { const existing = this.flags.get(name); + + const initialVariantsServed: Record = {}; + if (variants) { + for (const v of variants) { + initialVariantsServed[v.key] = 0; + } + } + const existingVariantsServed = existing?.metrics?.variantsServed || {}; + const variantsServed = { ...initialVariantsServed, ...existingVariantsServed }; this.flags.set(name, { name, enabled, rolloutPercentage: Math.max(0, Math.min(100, rolloutPercentage)), targetedUsers: new Set(targetedUsers), - // Preserve existing metrics on update, or initialize to 0 - metrics: existing?.metrics || { servedTrue: 0, servedFalse: 0 } + metrics: { + servedTrue: existing?.metrics?.servedTrue || 0, + servedFalse: existing?.metrics?.servedFalse || 0, + variantsServed, + }, + variants, + rolloutSchedule, }); } @@ -47,6 +84,29 @@ constructor() { this.flags.delete(name); } + private getEffectiveRolloutPercentage(flag: FeatureFlag): number { + if (!flag.rolloutSchedule) { + return flag.rolloutPercentage; + } + + const { startPercentage, endPercentage, startTime, endTime } = flag.rolloutSchedule; + const now = Date.now(); + const startMs = new Date(startTime).getTime(); + const endMs = new Date(endTime).getTime(); + + if (now <= startMs) { + return startPercentage; + } + if (now >= endMs) { + return endPercentage; + } + + // Linearly interpolate between startPercentage and endPercentage + const progress = (now - startMs) / (endMs - startMs); + const interpolated = startPercentage + (endPercentage - startPercentage) * progress; + return Math.round(interpolated); + } + /** * Deterministic flag evaluation with analytics tracking. * Resolves in < 1ms to prevent API latency. @@ -72,13 +132,15 @@ constructor() { return trackAndReturn(true); } + const effectivePercentage = this.getEffectiveRolloutPercentage(flag); + // 3. If rollout is 100%, return true - if (flag.rolloutPercentage === 100) { + if (effectivePercentage === 100) { return trackAndReturn(true); } // 4. If rollout is 0%, return false - if (flag.rolloutPercentage === 0) { + if (effectivePercentage === 0) { return trackAndReturn(false); } @@ -89,7 +151,60 @@ constructor() { const hashInt = parseInt(hash.substring(0, 4), 16); const normalizedHash = (hashInt % 100) + 1; - return trackAndReturn(normalizedHash <= flag.rolloutPercentage); + return trackAndReturn(normalizedHash <= effectivePercentage); + } + + /** + * Deterministically assigns a variant for A/B testing if the flag is enabled. + */ + public evaluateVariant(flagName: string, identifier: string): string | undefined { + const flag = this.flags.get(flagName); + if (!flag || !flag.enabled) { + return undefined; + } + + // Check if the user is in the rollout bucket + const isEnabled = this.evaluate(flagName, identifier); + if (!isEnabled) { + return undefined; + } + + // If there are no variants, return undefined + if (!flag.variants || flag.variants.length === 0) { + return undefined; + } + + // Deterministically assign to a variant using MD5 hashing of the flag name + identifier + const hash = createHash('md5').update(`${flagName}-variant-${identifier}`).digest('hex'); + const hashInt = parseInt(hash.substring(0, 4), 16); + + const totalWeight = flag.variants.reduce((sum, v) => sum + v.weight, 0); + if (totalWeight <= 0) { + return undefined; + } + + const bucket = hashInt % totalWeight; + let cumulativeWeight = 0; + + for (const variant of flag.variants) { + cumulativeWeight += variant.weight; + if (bucket < cumulativeWeight) { + // Track variant metrics + if (!flag.metrics.variantsServed) { + flag.metrics.variantsServed = {}; + } + flag.metrics.variantsServed[variant.key] = (flag.metrics.variantsServed[variant.key] || 0) + 1; + return variant.key; + } + } + + // Fallback + const fallbackKey = flag.variants[0].key; + if (!flag.metrics.variantsServed) { + flag.metrics.variantsServed = {}; + } + flag.metrics.variantsServed[fallbackKey] = (flag.metrics.variantsServed[fallbackKey] || 0) + 1; + return fallbackKey; } } diff --git a/frontend/app/pay/[slug]/page.tsx b/frontend/app/pay/[slug]/page.tsx new file mode 100644 index 00000000..58947a0c --- /dev/null +++ b/frontend/app/pay/[slug]/page.tsx @@ -0,0 +1,370 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { api } from '@/lib/api'; + +export default function PayPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const slug = params.slug as string; + + const [linkData, setLinkData] = useState(null); + const [selectedVariant, setSelectedVariant] = useState(null); + const [qrCodeUrl, setQrCodeUrl] = useState(''); + const [shareLinks, setShareLinks] = useState(null); + + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [paying, setPaying] = useState(false); + const [paymentSuccess, setPaymentSuccess] = useState(false); + const [paymentError, setPaymentError] = useState(null); + + const fetchPaymentLink = async (pwd?: string) => { + setLoading(true); + setError(null); + setPasswordError(''); + try { + const variantParam = searchParams.get('variant') || undefined; + const sourceParam = searchParams.get('source') || 'direct'; + + const response = await api.paymentLinks.getLinkBySlug(slug, { + variant: variantParam, + password: pwd || password || undefined, + }); + + if (response && response.data) { + setLinkData(response.data); + setSelectedVariant(response.selectedVariant); + setQrCodeUrl(response.qrCodeUrl || ''); + setShareLinks(response.share || null); + } + } catch (err: any) { + if (err.status === 401 || err.code === 'PAYMENT_LINK_PASSWORD_REQUIRED') { + setError('PASSWORD_REQUIRED'); + if (pwd) { + setPasswordError('Invalid password. Please try again.'); + } + } else if (err.status === 410 || err.code === 'PAYMENT_LINK_EXPIRED') { + setError('EXPIRED'); + } else { + setError(err.message || 'Payment link not found or inactive'); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (slug) { + fetchPaymentLink(); + } + }, [slug, searchParams]); + + const handleUnlock = (e: React.FormEvent) => { + e.preventDefault(); + if (!password) return; + fetchPaymentLink(password); + }; + + const handlePay = async () => { + setPaying(true); + setPaymentError(null); + try { + const sourceParam = searchParams.get('source') || 'direct'; + const variantParam = selectedVariant?.id || undefined; + + await api.paymentLinks.completePayment(slug, { + source: sourceParam, + variant: variantParam, + password: password || undefined, + amountPaid: selectedVariant ? selectedVariant.amount : linkData.amount, + }); + + setPaymentSuccess(true); + } catch (err: any) { + setPaymentError(err.message || 'Failed to complete payment. Please try again.'); + } finally { + setPaying(false); + } + }; + + if (loading) { + return ( +
+
+
+

Loading secure checkout...

+
+
+ ); + } + + if (error === 'PASSWORD_REQUIRED') { + return ( +
+
+
+
+ + + +
+

Protected Payment Link

+

This checkout session requires a password to unlock.

+
+ +
+
+ + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl px-4 py-3 text-sm transition-all outline-none" + placeholder="Enter checkout password" + required + /> + {passwordError &&

{passwordError}

} +
+ + +
+
+
+ ); + } + + if (error === 'EXPIRED') { + return ( +
+
+
+ + + +
+

Checkout Expired

+

This payment request has expired, has been completed, or is no longer accepting payments.

+
+
+ ); + } + + if (error || !linkData) { + return ( +
+
+
+ + + +
+

Checkout Unavailable

+

{error || 'This checkout page could not be loaded.'}

+
+
+ ); + } + + const brandName = linkData.brand?.brandName || 'AgenticPay'; + const accentColor = selectedVariant?.accentColor || linkData.brand?.accentColor || '#6366F1'; + const description = selectedVariant?.description || linkData.description || 'Secure checkout request'; + const amountToPay = selectedVariant ? selectedVariant.amount : linkData.amount; + const ctaText = selectedVariant?.ctaText || 'Complete Payment'; + + return ( +
+ {/* Background decoration */} +
+
+ + {paymentSuccess ? ( +
+
+ + + +
+

Payment Complete!

+

+ Your payment of {amountToPay.toFixed(2)} {linkData.currency} was processed successfully. +

+ +
+ {linkData.brand?.redirectUrl && ( + + Return to Merchant + + )} +
+
+ ) : ( +
+ {/* Main payment card */} +
+
+ {/* Branding header */} +
+ {linkData.brand?.logoUrl ? ( + + ) : ( +
+ {brandName.charAt(0)} +
+ )} +
+

{brandName}

+
+ + Verified Checkout +
+
+
+ + {/* Amount display */} +
+ Total Amount +
+

{amountToPay.toFixed(2)}

+ {linkData.currency} +
+ {selectedVariant && ( +
+ + + + Applied: {selectedVariant.name} +
+ )} +
+ + {/* Description */} +
+ Description +

{description}

+
+ + {/* Recurrence & Expiry */} +
+
+ Recurrence + + {linkData.recurrence.replace('_', ' ')} + +
+
+ Expires + + {new Date(linkData.expiresAt).toLocaleDateString()} + +
+
+
+ +
+ {paymentError &&

{paymentError}

} + + + + {linkData.brand?.redirectUrl && ( + + Cancel and return to merchant + + )} +
+
+ + {/* QR code and social sidebar (only shown on larger screens or sidebar toggle) */} + {qrCodeUrl && ( +
+
+ Scan to Pay +
+ Payment QR Code +
+
+ + {shareLinks && ( +
+ Share Link + +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2ccc68c0..be5c0867 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -270,4 +270,31 @@ export const api = { method: 'GET', }), }, + + /** + * Payment Links API + */ + paymentLinks: { + getLinkBySlug: async (slug: string, options?: { variant?: string; password?: string }) => { + const queryParams = new URLSearchParams(); + if (options?.variant) queryParams.append('variant', options.variant); + if (options?.password) queryParams.append('password', options.password); + const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ''; + return apiCall(`/payment-links/r/${slug}${queryString}`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }); + }, + completePayment: async (slug: string, payload: { source?: string; variant?: string; password?: string; amountPaid?: number }) => { + return apiCall(`/payment-links/r/${slug}/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, + }, }; \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf index 3040bfb8..3f9bd362 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -110,6 +110,7 @@ resource "aws_db_instance" "postgres" { deletion_protection = var.environment == "prod" skip_final_snapshot = var.environment != "prod" copy_tags_to_snapshot = true + multi_az = var.environment == "prod" performance_insights_enabled = var.environment == "prod" performance_insights_retention_period = var.environment == "prod" ? 7 : 0 @@ -269,6 +270,67 @@ resource "aws_iam_role_policy" "rds_proxy_secrets" { }) } +# ------------------------------------------------------------------------------ +# AUTOMATED BACKUP & DISASTER RECOVERY (AWS Backup for PITR) +# ------------------------------------------------------------------------------ + +resource "aws_backup_vault" "db_backup_vault" { + name = "agenticpay-${var.environment}-db-backup-vault" +} + +resource "aws_backup_plan" "db_backup_plan" { + name = "agenticpay-${var.environment}-db-backup-plan" + + rule { + rule_name = "agenticpay-continuous-backup-rule" + target_vault_name = aws_backup_vault.db_backup_vault.name + schedule = "cron(0 12 * * ? *)" # Daily at 12:00 UTC + + enable_continuous_backup = true + + lifecycle { + delete_after = var.environment == "prod" ? 35 : 7 + } + } +} + +resource "aws_backup_selection" "db_backup_selection" { + iam_role_arn = aws_iam_role.backup_role.arn + name = "agenticpay-${var.environment}-db-backup-selection" + plan_id = aws_backup_plan.db_backup_plan.id + + resources = [ + aws_db_instance.postgres.arn + ] +} + +resource "aws_iam_role" "backup_role" { + name = "agenticpay-${var.environment}-backup-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "backup.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "backup_policy" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup" + role = aws_iam_role.backup_role.name +} + +resource "aws_iam_role_policy_attachment" "restore_policy" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores" + role = aws_iam_role.backup_role.name +} + # ------------------------------------------------------------------------------ # BACKEND RESOURCES (Express.js API) # ------------------------------------------------------------------------------ diff --git a/package-lock.json b/package-lock.json index 20838adf..4e022e27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -270,6 +270,10 @@ "resolved": "packages/sdk-generator", "link": true }, + "node_modules/@agenticpay/sdk-testing": { + "resolved": "packages/sdk-testing", + "link": true + }, "node_modules/@agenticpay/types": { "resolved": "packages/types", "link": true @@ -1677,6 +1681,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -30478,14 +30483,11 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@agenticpay/error-codes": "*", - "@agenticpay/types": "*" + "@agenticpay/error-codes": "*" }, "devDependencies": { "@types/node": "^22.5.0", - "tsx": "^4.19.0", - "typedoc": "^0.28.0", - "typescript": "~5.7.2", + "typescript": "^5.9.3", "vitest": "^4.1.1" } }, @@ -30515,7 +30517,20 @@ "node": ">=14.17" } }, - "packages/sdk/node_modules/typescript": { + "packages/sdk-testing": { + "name": "@agenticpay/sdk-testing", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@agenticpay/sdk": "*" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "typescript": "~5.7.2", + "vitest": "^4.1.1" + } + }, + "packages/sdk-testing/node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", diff --git a/packages/error-codes/package.json b/packages/error-codes/package.json index 71e58179..3ce4664f 100644 --- a/packages/error-codes/package.json +++ b/packages/error-codes/package.json @@ -13,6 +13,7 @@ }, "scripts": { "build": "tsc", + "test": "echo 'No tests for error-codes'", "lint": "echo 'No lint yet'", "generate:docs": "node scripts/generate-error-docs.mjs" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 19639790..e15e8bc9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,6 +24,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "@agenticpay/error-codes": "*" + }, "devDependencies": { "@types/node": "^22.5.0", "typescript": "^5.9.3", diff --git a/packages/sdk/src/__tests__/sdk.test.ts b/packages/sdk/src/__tests__/sdk.test.ts index 8da6418b..2d106477 100644 --- a/packages/sdk/src/__tests__/sdk.test.ts +++ b/packages/sdk/src/__tests__/sdk.test.ts @@ -38,6 +38,7 @@ describe('AgenticPaySDK', () => { expect(sdk.payments).toBeDefined(); expect(sdk.refunds).toBeDefined(); expect(sdk.verification).toBeDefined(); + expect(sdk.featureFlags).toBeDefined(); expect(sdk.subscriptions).toBeDefined(); expect(sdk.invoices).toBeDefined(); expect(sdk.escrow).toBeDefined(); @@ -307,6 +308,59 @@ describe('Error handling via mock server', () => { }); }); +// ─── Feature Flags API ──────────────────────────────────────────────────────── + +describe('FeatureFlagsApi', () => { + it('evaluates a feature flag', async () => { + server.resetRoutes(); + server.addRoute({ + method: 'GET', + path: '/flags/evaluate', + body: { flag: 'test-flag', identifier: 'user_1', enabled: true, variant: 'v_promo' }, + }); + + const sdk = createTestSDK({ baseUrl: server.url }); + const result = await sdk.featureFlags.evaluate('test-flag', 'user_1'); + + expect(result).toEqual({ flag: 'test-flag', identifier: 'user_1', enabled: true, variant: 'v_promo' }); + const lastReq = server.getLastRequest(); + expect(lastReq?.path).toContain('/flags/evaluate'); + expect(lastReq?.path).toContain('flag=test-flag'); + expect(lastReq?.path).toContain('identifier=user_1'); + }); + + it('fetches feature flags state', async () => { + server.resetRoutes(); + server.addRoute({ + method: 'GET', + path: '/flags/state', + body: { identifier: 'user_1', flags: { 'test-flag': true, 'another-flag': 'v_b' } }, + }); + + const sdk = createTestSDK({ baseUrl: server.url }); + const result = await sdk.featureFlags.state('user_1'); + + expect(result).toEqual({ identifier: 'user_1', flags: { 'test-flag': true, 'another-flag': 'v_b' } }); + }); + + it('records client-side exposure', async () => { + server.resetRoutes(); + server.addRoute({ + method: 'POST', + path: '/flags/exposure', + body: { recorded: true }, + }); + + const sdk = createTestSDK({ baseUrl: server.url }); + const result = await sdk.featureFlags.recordExposure('test-flag', 'user_1', 'v_promo'); + + expect(result).toEqual({ recorded: true }); + const lastReq = server.getLastRequest(); + expect(lastReq?.body).toEqual({ flag: 'test-flag', identifier: 'user_1', value: 'v_promo' }); + }); +}); + + // ─── Test Helpers ───────────────────────────────────────────────────────────── describe('expectApiError', () => { diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 7d3613ef..cf985796 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -1,16 +1,8 @@ -export class AgenticPayError extends Error { - readonly status?: number; - readonly code?: string; - readonly details?: unknown; +// Re-export the canonical base class so there is exactly ONE AgenticPayError +// class in the module graph (avoids instanceof failures from dual-module issues). +export { AgenticPayError } from './errors/base.js'; - constructor(message: string, options?: { status?: number; code?: string; details?: unknown }) { - super(message); - this.name = 'AgenticPayError'; - this.status = options?.status; - this.code = options?.code; - this.details = options?.details; - } -} +import { AgenticPayError } from './errors/base.js'; export class AuthenticationError extends AgenticPayError { constructor(message = 'Authentication failed', details?: unknown) { @@ -33,6 +25,13 @@ export class ValidationError extends AgenticPayError { } } +export class NotFoundError extends AgenticPayError { + constructor(message = 'Resource not found', details?: unknown) { + super(message, { status: 404, code: 'NOT_FOUND', details }); + this.name = 'NotFoundError'; + } +} + export class RateLimitError extends AgenticPayError { constructor(message = 'Rate limit exceeded', details?: unknown) { super(message, { status: 429, code: 'RATE_LIMIT_EXCEEDED', details }); diff --git a/packages/sdk/src/featureFlags.ts b/packages/sdk/src/featureFlags.ts new file mode 100644 index 00000000..30281ce3 --- /dev/null +++ b/packages/sdk/src/featureFlags.ts @@ -0,0 +1,40 @@ +import { AgenticPayClient } from './client.js'; +import { FeatureFlagEvaluateResponse, FeatureFlagStateResponse } from './types.js'; + +export class FeatureFlagsApi { + constructor(private readonly client: AgenticPayClient) {} + + /** + * Deterministically evaluates a feature flag for a user identifier. + */ + async evaluate( + flag: string, + identifier: string + ): Promise { + const query = new URLSearchParams({ flag, identifier }).toString(); + return this.client.get(`/flags/evaluate?${query}`); + } + + /** + * Fetches the state of all active feature flags for a user identifier. + */ + async state(identifier: string): Promise { + const query = new URLSearchParams({ identifier }).toString(); + return this.client.get(`/flags/state?${query}`); + } + + /** + * Records a client-side exposure event (e.g. for variants or simple flags). + */ + async recordExposure( + flag: string, + identifier: string, + value: boolean | string + ): Promise<{ recorded: boolean }> { + return this.client.post<{ recorded: boolean }>('/flags/exposure', { + flag, + identifier, + value, + }); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b83a9769..17ce935b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -3,17 +3,37 @@ import { buildAuthHeader, AuthProvider } from './auth.js'; import { PaymentsApi } from './payments.js'; import { RefundsApi } from './refunds.js'; import { VerificationApi } from './verification.js'; +import { FeatureFlagsApi } from './featureFlags.js'; +import { SubscriptionsApi } from './subscriptions.js'; +import { EscrowApi, DisputesApi } from './escrow.js'; +import { InvoicesApi } from './invoices.js'; +import { StellarApi } from './stellar.js'; +import { SandboxApi } from './sandbox.js'; import { AgenticPayClientOptions } from './types.js'; export * from './types.js'; export * from './errors.js'; export * from './auth.js'; +export * from './featureFlags.js'; +export * from './subscriptions.js'; +export * from './escrow.js'; +export * from './invoices.js'; +export * from './stellar.js'; +export * from './sandbox.js'; + export class AgenticPaySDK { readonly client: AgenticPayClient; readonly payments: PaymentsApi; readonly refunds: RefundsApi; readonly verification: VerificationApi; + readonly featureFlags: FeatureFlagsApi; + readonly subscriptions: SubscriptionsApi; + readonly escrow: EscrowApi; + readonly disputes: DisputesApi; + readonly invoices: InvoicesApi; + readonly stellar: StellarApi; + readonly sandbox: SandboxApi; constructor(options: AgenticPayClientOptions, authProvider?: AuthProvider) { this.client = new AgenticPayClient(options); @@ -33,7 +53,15 @@ export class AgenticPaySDK { this.payments = new PaymentsApi(this.client); this.refunds = new RefundsApi(this.client); this.verification = new VerificationApi(this.client); + this.featureFlags = new FeatureFlagsApi(this.client); + this.subscriptions = new SubscriptionsApi(this.client); + this.escrow = new EscrowApi(this.client); + this.disputes = new DisputesApi(this.client); + this.invoices = new InvoicesApi(this.client); + this.stellar = new StellarApi(this.client); + this.sandbox = new SandboxApi(this.client); } + } export function createAgenticPaySDK(options: AgenticPayClientOptions, authProvider?: AuthProvider) { diff --git a/packages/sdk/src/testing/mock-server.ts b/packages/sdk/src/testing/mock-server.ts index 4c721253..80a4a115 100644 --- a/packages/sdk/src/testing/mock-server.ts +++ b/packages/sdk/src/testing/mock-server.ts @@ -120,7 +120,12 @@ export class MockAgenticPayServer { private findRoute(method: string, path: string): MockRoute | undefined { return this.routes.find((route) => { if (route.method !== method) return false; - if (typeof route.path === 'string') return route.path === path; + if (typeof route.path === 'string') { + // Exact match OR path-only match (ignoring query string) + if (route.path === path) return true; + const [pathOnly] = path.split('?'); + return route.path === pathOnly; + } return route.path.test(path); }); } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f1425751..88d4b55c 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -86,3 +86,16 @@ export type RefundEvaluationInput = { hasChargeback?: boolean; hasDispute?: boolean; }; + +export type FeatureFlagEvaluateResponse = { + flag: string; + identifier: string; + enabled: boolean; + variant?: string; +}; + +export type FeatureFlagStateResponse = { + identifier: string; + flags: Record; +}; + diff --git a/scripts/backup.sh b/scripts/backup.sh index 8692b296..ae9ad39e 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -2,8 +2,8 @@ set -euo pipefail # AgenticPay Backup Script -# Handles daily full backups and incremental backups -# Usage: ./backup.sh [full|incremental|restore|verify] +# Handles daily full backups, incremental backups, and Point-In-Time Restore (PITR) +# Usage: ./backup.sh [full|incremental|restore|verify|pitr] BACKUP_DIR="${BACKUP_DIR:-/var/backups/agenticpay}" S3_BUCKET="${S3_BUCKET:-agenticpay-backups}" @@ -235,6 +235,126 @@ do_cleanup() { log "Cleanup complete: removed $deleted old backup(s)" } +to_epoch() { + local ts="$1" + # Format YYYYMMDD_HHMMSS -> YYYY-MM-DD HH:MM:SS + if [[ "$ts" =~ ^[0-9]{8}_[0-9]{6}$ ]]; then + local yyyymmdd="${ts%_*}" + local hhmmss="${ts#*_}" + ts="${yyyymmdd:0:4}-${yyyymmdd:4:2}-${yyyymmdd:6:2} ${hhmmss:0:2}:${hhmmss:2:2}:${hhmmss:4:2}" + fi + date -d "$ts" +%s +} + +do_pitr() { + local target_time="${1:-}" + if [ -z "$target_time" ]; then + log "ERROR: target timestamp (e.g. YYYYMMDD_HHMMSS or 'YYYY-MM-DD HH:MM:SS') is required for PITR" + return 1 + fi + + local target_epoch + target_epoch=$(to_epoch "$target_time") + log "Starting Point-In-Time Restore to: $target_time (Epoch: $target_epoch)" + + # If RDS is configured and AWS CLI is installed, execute RDS PITR + if command -v aws &>/dev/null && [ -n "${RDS_INSTANCE_IDENTIFIER:-}" ]; then + log "RDS environment detected. Triggering RDS point-in-time restore..." + local rds_time + rds_time=$(date -u -d "@$target_epoch" +%Y-%m-%dT%H:%M:%SZ) + aws rds restore-db-instance-to-point-in-time \ + --source-db-instance-identifier "$RDS_INSTANCE_IDENTIFIER" \ + --target-db-instance-identifier "${RDS_INSTANCE_IDENTIFIER}-pitr-${TIMESTAMP}" \ + --restore-time "$rds_time" \ + --region "$S3_REGION" + log "RDS Point-In-Time Restore initiated to: ${RDS_INSTANCE_IDENTIFIER}-pitr-${TIMESTAMP}" + notify_slack "🔄 RDS PITR initiated to $target_time" "warning" + return 0 + fi + + # Local Simulated PITR + log "Performing local simulated PITR restore..." + + # 1. Find the latest full backup older than or equal to target_epoch + local best_full="" + local best_full_epoch=0 + + for file in "$BACKUP_DIR/full/"*.sql.gz; do + if [ -f "$file" ]; then + # Extract timestamp from full_backup_YYYYMMDD_HHMMSS.sql.gz + local base + base=$(basename "$file") + local ts_part + ts_part=$(echo "$base" | sed -E 's/full_backup_(.*)\.sql\.gz/\1/') + local epoch + epoch=$(to_epoch "$ts_part") + if [ "$epoch" -le "$target_epoch" ] && [ "$epoch" -gt "$best_full_epoch" ]; then + best_full="$file" + best_full_epoch="$epoch" + fi + fi + done + + if [ -z "$best_full" ]; then + log "ERROR: No full backup found older than or equal to target time: $target_time" + return 1 + fi + + log "Found base full backup: $(basename "$best_full") (Epoch: $best_full_epoch)" + notify_slack "🔄 Starting PITR: base full backup $(basename "$best_full")" "warning" + + # Restore base full backup + if gunzip -c "$best_full" | psql "$DB_URL"; then + log "Base full backup restored successfully." + else + log "ERROR: Failed to restore base full backup: $best_full" + notify_slack "❌ PITR restore failed at base backup stage" "danger" + return 1 + fi + + # 2. Find and apply incremental backups between best_full_epoch and target_epoch + local incr_backups=() + for file in "$BACKUP_DIR/incremental/"*.sql.gz; do + if [ -f "$file" ]; then + local base + base=$(basename "$file") + local ts_part + ts_part=$(echo "$base" | sed -E 's/incr_backup_(.*)\.sql\.gz/\1/') + local epoch + epoch=$(to_epoch "$ts_part") + if [ "$epoch" -gt "$best_full_epoch" ] && [ "$epoch" -le "$target_epoch" ]; then + incr_backups+=("$epoch|$file") + fi + fi + done + + # Sort incremental backups chronologically + if [ ${#incr_backups[@]} -gt 0 ]; then + # Sort array + IFS=$'\n' sorted_incr=($(sort -n <<<"${incr_backups[*]}")) + unset IFS + + log "Applying ${#sorted_incr[@]} incremental backups..." + for item in "${sorted_incr[@]}"; do + local file="${item#*|}" + log "Applying incremental backup: $(basename "$file")" + if gunzip -c "$file" | psql "$DB_URL"; then + log "Applied: $(basename "$file")" + else + log "ERROR: Failed to apply incremental backup: $file" + notify_slack "❌ PITR failed applying incremental backup $(basename "$file")" "danger" + return 1 + fi + done + else + log "No incremental backups to apply in target time window." + fi + + log "Point-In-Time Restore completed successfully to $target_time" + notify_slack "✅ PITR restore completed successfully to $target_time" "good" + return 0 +} + # Main case "${1:-full}" in full) @@ -250,13 +370,17 @@ case "${1:-full}" in verify) do_verify_all ;; + pitr) + do_pitr "${2:-}" + ;; *) - echo "Usage: $0 [full|incremental|restore |verify]" + echo "Usage: $0 [full|incremental|restore |verify|pitr ]" echo "" - echo " full - Create full database backup" - echo " incremental - Create incremental backup" - echo " restore - Restore from backup file" - echo " verify - Verify all backup integrity" + echo " full - Create full database backup" + echo " incremental - Create incremental backup" + echo " restore - Restore from backup file" + echo " verify - Verify all backup integrity" + echo " pitr - Point-In-Time Restore to a specific timestamp (e.g. YYYYMMDD_HHMMSS)" exit 1 ;; esac \ No newline at end of file