diff --git a/backend/prisma/migrations/20260627000000_tenant_partitioning/migration.sql b/backend/prisma/migrations/20260627000000_tenant_partitioning/migration.sql new file mode 100644 index 00000000..18fe343a --- /dev/null +++ b/backend/prisma/migrations/20260627000000_tenant_partitioning/migration.sql @@ -0,0 +1,261 @@ +-- Migration: Tenant partitioning for multi-tenant isolation (#504) +-- Strategy: list-partitioning by tenant_id with zero-downtime shadow-table approach. +-- Partitioned shadow tables run alongside originals; the partition-manager drives +-- the data backfill and final cutover per deployment. + +-- ─── Phase 1: Enrich audit_logs with tenant_id ─────────────────────────────── + +ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "tenant_id" TEXT; + +UPDATE "audit_logs" al +SET "tenant_id" = u."tenant_id" +FROM "users" u +WHERE al."user_id" = u."id" + AND al."tenant_id" IS NULL; + +UPDATE "audit_logs" +SET "tenant_id" = 'system' +WHERE "tenant_id" IS NULL; + +CREATE INDEX IF NOT EXISTS "audit_logs_tenant_id_idx" + ON "audit_logs" ("tenant_id"); + +CREATE INDEX IF NOT EXISTS "audit_logs_tenant_created_at_idx" + ON "audit_logs" ("tenant_id", "created_at"); + +-- ─── Phase 2: Partition registry ───────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "tenant_partitions" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "table_name" TEXT NOT NULL, + "partition_name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "row_count" BIGINT NOT NULL DEFAULT 0, + "size_bytes" BIGINT NOT NULL DEFAULT 0, + "last_analyzed" TIMESTAMP(3), + CONSTRAINT "tenant_partitions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "tenant_partitions_unique" UNIQUE ("table_name", "partition_name") +); + +CREATE INDEX IF NOT EXISTS "tenant_partitions_tenant_idx" + ON "tenant_partitions" ("tenant_id"); + +CREATE INDEX IF NOT EXISTS "tenant_partitions_table_idx" + ON "tenant_partitions" ("table_name"); + +-- ─── Phase 3: PostgreSQL helper functions ───────────────────────────────────── + +-- Idempotently creates a LIST partition for a given tenant on a partitioned table. +CREATE OR REPLACE FUNCTION create_tenant_partition( + p_table TEXT, + p_tenant TEXT +) RETURNS TEXT LANGUAGE plpgsql AS $$ +DECLARE + v_safe TEXT; + v_part TEXT; +BEGIN + v_safe := lower(regexp_replace(p_tenant, '[^a-zA-Z0-9]', '_', 'g')); + v_part := p_table || '_t_' || v_safe; + + IF NOT EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = v_part + AND n.nspname = current_schema() + ) THEN + EXECUTE format( + 'CREATE TABLE %I PARTITION OF %I FOR VALUES IN (%L)', + v_part, p_table, p_tenant + ); + INSERT INTO tenant_partitions (id, tenant_id, table_name, partition_name) + VALUES (gen_random_uuid()::text, p_tenant, p_table, v_part) + ON CONFLICT (table_name, partition_name) DO NOTHING; + END IF; + + RETURN v_part; +END; +$$; + +-- Refreshes row count and byte size for all tracked partitions. +CREATE OR REPLACE FUNCTION refresh_partition_stats() RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + r RECORD; +BEGIN + FOR r IN SELECT table_name, partition_name FROM tenant_partitions LOOP + UPDATE tenant_partitions + SET row_count = (SELECT reltuples::BIGINT FROM pg_class WHERE relname = r.partition_name), + size_bytes = pg_relation_size(r.partition_name::regclass), + last_analyzed = now() + WHERE table_name = r.table_name + AND partition_name = r.partition_name; + END LOOP; +END; +$$; + +-- ─── Phase 4: Partitioned shadow tables ────────────────────────────────────── +-- These mirror the originals with composite PKs (id, tenant_id) required by +-- PostgreSQL LIST partitioning. The partition-manager handles data sync and +-- the final cutover rename. + +CREATE TABLE IF NOT EXISTS "payments_partitioned" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "tx_hash" TEXT, + "amount" DECIMAL(20,8) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'XLM', + "network" TEXT NOT NULL DEFAULT 'stellar', + "status" TEXT NOT NULL DEFAULT 'pending', + "type" TEXT NOT NULL DEFAULT 'milestone_payment', + "project_title" TEXT, + "project_id" TEXT, + "milestone_id" TEXT, + "user_id" TEXT, + "from_address" TEXT, + "to_address" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + CONSTRAINT "payments_part_pkey" PRIMARY KEY ("id", "tenant_id") +) PARTITION BY LIST ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "payments_partitioned_default" + PARTITION OF "payments_partitioned" DEFAULT; + +CREATE INDEX IF NOT EXISTS "payments_part_tenant_created_idx" + ON "payments_partitioned" ("tenant_id", "created_at"); +CREATE INDEX IF NOT EXISTS "payments_part_status_idx" + ON "payments_partitioned" ("status"); +CREATE INDEX IF NOT EXISTS "payments_part_tx_hash_idx" + ON "payments_partitioned" ("tx_hash"); +CREATE INDEX IF NOT EXISTS "payments_part_project_id_idx" + ON "payments_partitioned" ("project_id"); + +INSERT INTO "payments_partitioned" ( + id, tenant_id, tx_hash, amount, currency, network, status, type, + project_title, project_id, milestone_id, user_id, + from_address, to_address, metadata, created_at, updated_at, deleted_at +) +SELECT + id, tenant_id, tx_hash, amount, currency, network, status::text, type::text, + project_title, project_id, milestone_id, user_id, + from_address, to_address, metadata, created_at, updated_at, deleted_at +FROM "payments" +ON CONFLICT DO NOTHING; + +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "invoices_partitioned" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "milestone_id" TEXT, + "amount" DECIMAL(20,8) NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'XLM', + "status" TEXT NOT NULL DEFAULT 'draft', + "generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "due_at" TIMESTAMP(3), + "paid_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(3), + CONSTRAINT "invoices_part_pkey" PRIMARY KEY ("id", "tenant_id") +) PARTITION BY LIST ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "invoices_partitioned_default" + PARTITION OF "invoices_partitioned" DEFAULT; + +CREATE INDEX IF NOT EXISTS "invoices_part_tenant_generated_idx" + ON "invoices_partitioned" ("tenant_id", "generated_at"); +CREATE INDEX IF NOT EXISTS "invoices_part_project_id_idx" + ON "invoices_partitioned" ("project_id"); +CREATE INDEX IF NOT EXISTS "invoices_part_status_idx" + ON "invoices_partitioned" ("status"); + +INSERT INTO "invoices_partitioned" ( + id, tenant_id, project_id, milestone_id, amount, currency, status, + generated_at, due_at, paid_at, created_at, updated_at, deleted_at +) +SELECT + id, tenant_id, project_id, milestone_id, amount, currency, status::text, + generated_at, due_at, paid_at, created_at, updated_at, deleted_at +FROM "invoices" +ON CONFLICT DO NOTHING; + +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "audit_logs_partitioned" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL DEFAULT 'system', + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "actor" TEXT NOT NULL, + "action" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "details" JSONB, + "previous_hash" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "anchor_id" TEXT, + "archived_at" TIMESTAMP(3), + "cold_archived_at" TIMESTAMP(3), + "entity_id" TEXT, + "entity_type" TEXT, + "user_id" TEXT, + "metadata" JSONB, + "ip_address" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "audit_logs_part_pkey" PRIMARY KEY ("id", "tenant_id") +) PARTITION BY LIST ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "audit_logs_partitioned_default" + PARTITION OF "audit_logs_partitioned" DEFAULT; + +CREATE INDEX IF NOT EXISTS "audit_logs_part_tenant_created_idx" + ON "audit_logs_partitioned" ("tenant_id", "created_at"); +CREATE INDEX IF NOT EXISTS "audit_logs_part_actor_idx" + ON "audit_logs_partitioned" ("actor"); +CREATE INDEX IF NOT EXISTS "audit_logs_part_action_idx" + ON "audit_logs_partitioned" ("action"); +CREATE INDEX IF NOT EXISTS "audit_logs_part_entity_idx" + ON "audit_logs_partitioned" ("entity_id", "created_at"); + +INSERT INTO "audit_logs_partitioned" ( + id, tenant_id, timestamp, actor, action, resource, details, + previous_hash, hash, anchor_id, archived_at, cold_archived_at, + entity_id, entity_type, user_id, metadata, ip_address, created_at +) +SELECT + id, COALESCE(tenant_id, 'system'), + timestamp, actor, action, resource, details, + previous_hash, hash, anchor_id, archived_at, cold_archived_at, + entity_id, entity_type, user_id, metadata, ip_address, created_at +FROM "audit_logs" +ON CONFLICT DO NOTHING; + +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "webhook_logs_partitioned" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "webhook_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "payload" JSONB, + "status_code" INTEGER, + "response" TEXT, + "attempt" INTEGER NOT NULL DEFAULT 1, + "duration_ms" INTEGER, + "delivered_at" TIMESTAMP(3), + "failed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "webhook_logs_part_pkey" PRIMARY KEY ("id", "tenant_id") +) PARTITION BY LIST ("tenant_id"); + +CREATE TABLE IF NOT EXISTS "webhook_logs_partitioned_default" + PARTITION OF "webhook_logs_partitioned" DEFAULT; + +CREATE INDEX IF NOT EXISTS "webhook_logs_part_tenant_created_idx" + ON "webhook_logs_partitioned" ("tenant_id", "created_at"); +CREATE INDEX IF NOT EXISTS "webhook_logs_part_webhook_id_idx" + ON "webhook_logs_partitioned" ("webhook_id"); +CREATE INDEX IF NOT EXISTS "webhook_logs_part_event_type_idx" + ON "webhook_logs_partitioned" ("event_type"); diff --git a/backend/src/db/partition-manager.ts b/backend/src/db/partition-manager.ts new file mode 100644 index 00000000..86bcec16 --- /dev/null +++ b/backend/src/db/partition-manager.ts @@ -0,0 +1,257 @@ +import { PrismaClient } from '@prisma/client'; + +export type PartitionedTable = + | 'payments_partitioned' + | 'invoices_partitioned' + | 'audit_logs_partitioned' + | 'webhook_logs_partitioned'; + +export const PARTITIONED_TABLES: PartitionedTable[] = [ + 'payments_partitioned', + 'invoices_partitioned', + 'audit_logs_partitioned', + 'webhook_logs_partitioned', +]; + +export interface PartitionStats { + tenantId: string; + tableName: string; + partitionName: string; + rowCount: bigint; + sizeBytes: bigint; + lastAnalyzed: Date | null; + createdAt: Date; +} + +export interface PartitionQueryMetrics { + tenantId: string; + tableName: string; + scanPartitions: number; + totalPartitions: number; + pruningEfficiency: number; +} + +export interface TenantMigrationResult { + tenantId: string; + table: PartitionedTable; + rowsMigrated: number; + durationMs: number; + success: boolean; + error?: string; +} + +export class PartitionManager { + constructor(private readonly prisma: PrismaClient) {} + + async ensurePartitionsForTenant(tenantId: string): Promise { + for (const table of PARTITIONED_TABLES) { + await this.createPartition(table, tenantId); + } + } + + async createPartition(table: PartitionedTable, tenantId: string): Promise { + const result = await this.prisma.$queryRaw<[{ create_tenant_partition: string }]>` + SELECT create_tenant_partition(${table}, ${tenantId}) + `; + return result[0].create_tenant_partition; + } + + async dropTenantPartitions(tenantId: string): Promise { + for (const table of PARTITIONED_TABLES) { + const safe = tenantId.toLowerCase().replace(/[^a-z0-9]/g, '_'); + const partitionName = `${table}_t_${safe}`; + + const exists = await this.prisma.$queryRaw<[{ exists: boolean }]>` + SELECT EXISTS( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = ${partitionName} + AND n.nspname = current_schema() + ) AS exists + `; + + if (exists[0].exists) { + await this.prisma.$executeRawUnsafe(`DROP TABLE ${partitionName}`); + await this.prisma.$executeRaw` + DELETE FROM tenant_partitions + WHERE tenant_id = ${tenantId} AND table_name = ${table} + `; + } + } + } + + async getPartitionStats(tenantId?: string): Promise { + await this.prisma.$executeRaw`SELECT refresh_partition_stats()`; + + if (tenantId) { + return this.prisma.$queryRaw` + SELECT + tenant_id AS "tenantId", + table_name AS "tableName", + partition_name AS "partitionName", + row_count AS "rowCount", + size_bytes AS "sizeBytes", + last_analyzed AS "lastAnalyzed", + created_at AS "createdAt" + FROM tenant_partitions + WHERE tenant_id = ${tenantId} + ORDER BY table_name, partition_name + `; + } + + return this.prisma.$queryRaw` + SELECT + tenant_id AS "tenantId", + table_name AS "tableName", + partition_name AS "partitionName", + row_count AS "rowCount", + size_bytes AS "sizeBytes", + last_analyzed AS "lastAnalyzed", + created_at AS "createdAt" + FROM tenant_partitions + ORDER BY size_bytes DESC, table_name, partition_name + `; + } + + async getPartitionDistribution(): Promise< + { tableName: string; tenantCount: number; totalRows: bigint; totalBytes: bigint }[] + > { + return this.prisma.$queryRaw` + SELECT + table_name AS "tableName", + COUNT(*) AS "tenantCount", + SUM(row_count) AS "totalRows", + SUM(size_bytes) AS "totalBytes" + FROM tenant_partitions + GROUP BY table_name + ORDER BY table_name + `; + } + + async migrateExistingTenantData( + tenantId: string, + table: PartitionedTable, + ): Promise { + const start = Date.now(); + + try { + await this.createPartition(table, tenantId); + + let rowsMigrated = 0; + + if (table === 'payments_partitioned') { + const res = await this.prisma.$executeRaw` + INSERT INTO payments_partitioned ( + id, tenant_id, tx_hash, amount, currency, network, status, type, + project_title, project_id, milestone_id, user_id, + from_address, to_address, metadata, created_at, updated_at, deleted_at + ) + SELECT + id, tenant_id, tx_hash, amount, currency, network, status::text, type::text, + project_title, project_id, milestone_id, user_id, + from_address, to_address, metadata, created_at, updated_at, deleted_at + FROM payments + WHERE tenant_id = ${tenantId} + ON CONFLICT DO NOTHING + `; + rowsMigrated = Number(res); + } else if (table === 'invoices_partitioned') { + const res = await this.prisma.$executeRaw` + INSERT INTO invoices_partitioned ( + id, tenant_id, project_id, milestone_id, amount, currency, status, + generated_at, due_at, paid_at, created_at, updated_at, deleted_at + ) + SELECT + id, tenant_id, project_id, milestone_id, amount, currency, status::text, + generated_at, due_at, paid_at, created_at, updated_at, deleted_at + FROM invoices + WHERE tenant_id = ${tenantId} + ON CONFLICT DO NOTHING + `; + rowsMigrated = Number(res); + } else if (table === 'audit_logs_partitioned') { + const res = await this.prisma.$executeRaw` + INSERT INTO audit_logs_partitioned ( + id, tenant_id, timestamp, actor, action, resource, details, + previous_hash, hash, anchor_id, archived_at, cold_archived_at, + entity_id, entity_type, user_id, metadata, ip_address, created_at + ) + SELECT + id, COALESCE(tenant_id, 'system'), + timestamp, actor, action, resource, details, + previous_hash, hash, anchor_id, archived_at, cold_archived_at, + entity_id, entity_type, user_id, metadata, ip_address, created_at + FROM audit_logs + WHERE COALESCE(tenant_id, 'system') = ${tenantId} + ON CONFLICT DO NOTHING + `; + rowsMigrated = Number(res); + } + + return { tenantId, table, rowsMigrated, durationMs: Date.now() - start, success: true }; + } catch (error) { + return { + tenantId, + table, + rowsMigrated: 0, + durationMs: Date.now() - start, + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + async migrateAllTenants(table: PartitionedTable): Promise { + const tenants = await this.getDistinctTenants(table); + const results: TenantMigrationResult[] = []; + + for (const tenantId of tenants) { + results.push(await this.migrateExistingTenantData(tenantId, table)); + } + + return results; + } + + private async getDistinctTenants(table: PartitionedTable): Promise { + type Row = { tenant_id: string }; + + if (table === 'payments_partitioned') { + const rows = await this.prisma.$queryRaw` + SELECT DISTINCT tenant_id FROM payments WHERE tenant_id IS NOT NULL + `; + return rows.map((r) => r.tenant_id); + } + + if (table === 'invoices_partitioned') { + const rows = await this.prisma.$queryRaw` + SELECT DISTINCT tenant_id FROM invoices WHERE tenant_id IS NOT NULL + `; + return rows.map((r) => r.tenant_id); + } + + if (table === 'audit_logs_partitioned') { + const rows = await this.prisma.$queryRaw` + SELECT DISTINCT COALESCE(tenant_id, 'system') AS tenant_id FROM audit_logs + `; + return rows.map((r) => r.tenant_id); + } + + return []; + } + + async getQueryMetrics(tenantId: string, table: PartitionedTable): Promise { + const allPartitions = await this.prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) AS count FROM tenant_partitions WHERE table_name = ${table} + `; + + const totalPartitions = Number(allPartitions[0].count); + + return { + tenantId, + tableName: table, + scanPartitions: 1, + totalPartitions, + pruningEfficiency: totalPartitions > 0 ? (1 - 1 / totalPartitions) * 100 : 0, + }; + } +} diff --git a/backend/src/middleware/cache-headers.ts b/backend/src/middleware/cache-headers.ts new file mode 100644 index 00000000..4e01b2cc --- /dev/null +++ b/backend/src/middleware/cache-headers.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { Request, Response, NextFunction } from 'express'; + +// CDN TTL tiers (seconds) +export const CDN_TTL = { + NONE: 0, + REALTIME: 0, + USER: 30, + STATIC: 300, +} as const; + +// How long stale content may be served while a CDN revalidates (seconds). +const SWR_SLACK = 60; + +// Authorization header is hashed so CDN can cache per-user without leaking tokens. +const AUTH_HASH_LENGTH = 16; + +export interface CacheHeaderOptions { + ttl: number; + surrogateTtl?: number; + staleWhileRevalidate?: number; + staleIfError?: number; + varyOn?: string[]; + surrogateKeys?: string[]; + bypassForMutations?: boolean; +} + +interface CacheMetric { + path: string; + method: string; + ttl: number; + cacheControl: string; + surrogateControl: string; + timestamp: number; +} + +const metrics: CacheMetric[] = []; +const MAX_METRICS = 5000; + +export function getCacheMetrics(): CacheMetric[] { + return metrics.slice(); +} + +export function getCacheHitRatioSummary(): { + total: number; + cachedPaths: number; + bypassedPaths: number; +} { + const cached = metrics.filter((m) => m.ttl > 0).length; + return { total: metrics.length, cachedPaths: cached, bypassedPaths: metrics.length - cached }; +} + +function record(metric: Omit): void { + metrics.push({ ...metric, timestamp: Date.now() }); + if (metrics.length > MAX_METRICS) metrics.splice(0, metrics.length - MAX_METRICS); +} + +function hashAuth(authorization: string | undefined): string { + if (!authorization) return 'anon'; + return createHash('sha256').update(authorization).digest('hex').slice(0, AUTH_HASH_LENGTH); +} + +function buildCacheControl(ttl: number, swr?: number, sie?: number): string { + if (ttl <= 0) return 'no-store'; + const parts = ['private', `max-age=${ttl}`]; + if (swr && swr > 0) parts.push(`stale-while-revalidate=${swr}`); + if (sie && sie > 0) parts.push(`stale-if-error=${sie}`); + return parts.join(', '); +} + +function buildSurrogateControl(ttl: number, swr?: number): string { + if (ttl <= 0) return 'no-store'; + const stale = swr ?? SWR_SLACK; + return `max-age=${ttl}, stale-while-revalidate=${stale}`; +} + +export function cacheHeaders(options: CacheHeaderOptions) { + const { + ttl, + surrogateTtl = ttl, + staleWhileRevalidate = SWR_SLACK, + staleIfError = 3600, + varyOn = [], + surrogateKeys = [], + bypassForMutations = true, + } = options; + + const cacheControl = buildCacheControl(ttl, staleWhileRevalidate, staleIfError); + const surrogateControl = buildSurrogateControl(surrogateTtl, staleWhileRevalidate); + + return function cacheHeaderMiddleware(req: Request, res: Response, next: NextFunction): void { + const isMutation = !['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase()); + + if (bypassForMutations && isMutation) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Surrogate-Control', 'no-store'); + next(); + return; + } + + // Build Vary header: always include Accept, plus caller-specified fields plus + // a hashed version of Authorization so CDN can cache per-user safely. + const varyFields = ['Accept', ...varyOn]; + const authorization = req.headers['authorization']; + if (authorization) { + res.setHeader('X-Auth-Hash', hashAuth(authorization)); + varyFields.push('X-Auth-Hash'); + } + if (req.headers['accept-language']) varyFields.push('Accept-Language'); + + res.setHeader('Cache-Control', cacheControl); + res.setHeader('Surrogate-Control', surrogateControl); + res.setHeader('Vary', [...new Set(varyFields)].join(', ')); + + if (surrogateKeys.length > 0) { + res.setHeader('Surrogate-Key', surrogateKeys.join(' ')); + res.setHeader('Cache-Tag', surrogateKeys.join(' ')); // Cloudflare syntax + } + + res.setHeader('X-Cache-TTL', String(ttl)); + + record({ + path: req.path, + method: req.method, + ttl, + cacheControl, + surrogateControl, + }); + + next(); + }; +} + +// ─── Purge helpers ──────────────────────────────────────────────────────────── +// Call these after mutations to invalidate CDN edge caches. + +export interface PurgeTarget { + provider: 'cloudfront' | 'cloudflare' | 'fastly'; + paths?: string[]; + surrogateKeys?: string[]; +} + +export async function purgeCdnCache(target: PurgeTarget): Promise<{ ok: boolean; error?: string }> { + try { + switch (target.provider) { + case 'cloudfront': + return purgeCloudFront(target.paths ?? ['/*']); + case 'cloudflare': + return purgeCloudflare(target.surrogateKeys ?? [], target.paths ?? []); + case 'fastly': + return purgeFastly(target.surrogateKeys ?? []); + default: + return { ok: false, error: 'Unknown CDN provider' }; + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function purgeCloudFront(paths: string[]): Promise<{ ok: boolean; error?: string }> { + const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID; + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretKey = process.env.AWS_SECRET_ACCESS_KEY; + + if (!distributionId || !accessKeyId || !secretKey) { + return { ok: false, error: 'CloudFront credentials not configured' }; + } + + const callerReference = `purge-${Date.now()}`; + const body = JSON.stringify({ + Paths: { Quantity: paths.length, Items: paths }, + CallerReference: callerReference, + }); + + const res = await fetch( + `https://cloudfront.amazonaws.com/2020-05-31/distribution/${distributionId}/invalidation`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }, + ); + + return { ok: res.ok, error: res.ok ? undefined : `CloudFront responded ${res.status}` }; +} + +async function purgeCloudflare( + surrogateKeys: string[], + paths: string[], +): Promise<{ ok: boolean; error?: string }> { + const zoneId = process.env.CLOUDFLARE_ZONE_ID; + const token = process.env.CLOUDFLARE_API_TOKEN; + + if (!zoneId || !token) { + return { ok: false, error: 'Cloudflare credentials not configured' }; + } + + const payload = + surrogateKeys.length > 0 ? { tags: surrogateKeys } : { files: paths }; + + const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + return { ok: res.ok, error: res.ok ? undefined : `Cloudflare responded ${res.status}` }; +} + +async function purgeFastly(surrogateKeys: string[]): Promise<{ ok: boolean; error?: string }> { + const serviceId = process.env.FASTLY_SERVICE_ID; + const token = process.env.FASTLY_API_TOKEN; + + if (!serviceId || !token) { + return { ok: false, error: 'Fastly credentials not configured' }; + } + + const results = await Promise.all( + surrogateKeys.map((key) => + fetch(`https://api.fastly.com/service/${serviceId}/purge/${key}`, { + method: 'POST', + headers: { 'Fastly-Key': token }, + }), + ), + ); + + const allOk = results.every((r) => r.ok); + return { ok: allOk, error: allOk ? undefined : 'One or more Fastly purge requests failed' }; +} + +// ─── Pre-built presets ──────────────────────────────────────────────────────── + +export const cdnCache = { + none: () => cacheHeaders({ ttl: CDN_TTL.NONE }), + realtime: () => cacheHeaders({ ttl: CDN_TTL.REALTIME }), + userData: (surrogateKeys?: string[]) => + cacheHeaders({ ttl: CDN_TTL.USER, staleWhileRevalidate: 10, surrogateKeys }), + staticData: (surrogateKeys?: string[]) => + cacheHeaders({ ttl: CDN_TTL.STATIC, staleWhileRevalidate: SWR_SLACK, surrogateKeys }), +}; diff --git a/frontend/lib/query-keys.ts b/frontend/lib/query-keys.ts index 60c34e08..5e8cb019 100644 --- a/frontend/lib/query-keys.ts +++ b/frontend/lib/query-keys.ts @@ -5,6 +5,19 @@ export const queryKeys = { lists: () => [...queryKeys.payments.all(), 'list'] as const, list: (filters: Record = {}) => [...queryKeys.payments.lists(), filters] as const, detail: (id: string) => [...queryKeys.payments.all(), 'detail', id] as const, + infinite: (filters: Record = {}) => [...queryKeys.payments.lists(), 'infinite', filters] as const, + }, + invoices: { + all: () => [...queryKeys.all, 'invoices'] as const, + lists: () => [...queryKeys.invoices.all(), 'list'] as const, + list: (filters: Record = {}) => [...queryKeys.invoices.lists(), filters] as const, + detail: (id: string) => [...queryKeys.invoices.all(), 'detail', id] as const, + }, + webhooks: { + all: () => [...queryKeys.all, 'webhooks'] as const, + lists: () => [...queryKeys.webhooks.all(), 'list'] as const, + list: (filters: Record = {}) => [...queryKeys.webhooks.lists(), filters] as const, + detail: (id: string) => [...queryKeys.webhooks.all(), 'detail', id] as const, }, disputes: { all: () => [...queryKeys.all, 'disputes'] as const, diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 50f2bd7f..d2eedc48 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,18 +1,25 @@ // AgenticPay service worker: offline shell, API fallback cache, and payment replay queue. +// Updated for PWA (#501): IndexedDB v2 with retryAt index, conflict resolution, +// storage-limit eviction, and periodic background-sync fallback. -const APP_VERSION = '2026-06-01.1'; +const APP_VERSION = '2026-06-27.1'; const CACHE_PREFIX = 'agenticpay'; const PRECACHE = `${CACHE_PREFIX}-precache-${APP_VERSION}`; const RUNTIME = `${CACHE_PREFIX}-runtime-${APP_VERSION}`; const API_CACHE = `${CACHE_PREFIX}-api-${APP_VERSION}`; const DB_NAME = 'agenticpay-offline-db'; -const DB_VERSION = 1; +const DB_VERSION = 2; // v2 adds retryAt index and exponential backoff const PAYMENT_STORE = 'offline-payments'; + +const MAX_RETRIES = 5; +const RETRY_BACKOFF_MS = [5_000, 15_000, 60_000, 300_000, 600_000]; const SYNC_TAG = 'agenticpay-payment-sync'; const PRECACHE_URLS = [ '/', '/auth', '/dashboard', + '/dashboard/payments', + '/dashboard/transactions', '/manifest.webmanifest', '/icons/image-192.png', '/icons/image-512.png', @@ -245,10 +252,18 @@ function openDb() { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); - request.onupgradeneeded = () => { + request.onupgradeneeded = (event) => { const db = request.result; if (!db.objectStoreNames.contains(PAYMENT_STORE)) { - db.createObjectStore(PAYMENT_STORE, { keyPath: 'id' }); + const store = db.createObjectStore(PAYMENT_STORE, { keyPath: 'id' }); + store.createIndex('status', 'status', { unique: false }); + store.createIndex('createdAt', 'createdAt', { unique: false }); + store.createIndex('retryAt', 'retryAt', { unique: false }); + } else if (event.oldVersion < 2) { + const store = request.transaction.objectStore(PAYMENT_STORE); + if (!store.indexNames.contains('retryAt')) { + store.createIndex('retryAt', 'retryAt', { unique: false }); + } } }; }); @@ -282,12 +297,15 @@ async function getQueuedPayments() { async function flushPaymentQueue() { const queued = await getQueuedPayments(); + const now = Date.now(); + const due = queued.filter( + (i) => i.status !== 'synced' && i.status !== 'syncing' && + (i.retryAt === undefined || i.retryAt <= now), + ); let synced = 0; let failed = 0; - for (const item of queued) { - if (item.status === 'syncing') continue; - + for (const item of due) { await savePayment({ ...item, status: 'syncing' }); try { const response = await fetch(item.endpoint, { @@ -297,20 +315,38 @@ async function flushPaymentQueue() { }); if (response.ok || response.status === 409) { + // 409 Conflict: server already processed this request (idempotent) await deletePayment(item.id); synced += 1; } else { - await savePayment({ ...item, status: 'failed', retryCount: (item.retryCount || 0) + 1, lastError: `HTTP ${response.status}` }); + const retryCount = (item.retryCount || 0) + 1; + const backoffMs = RETRY_BACKOFF_MS[Math.min(retryCount - 1, RETRY_BACKOFF_MS.length - 1)]; + await savePayment({ + ...item, + status: retryCount >= MAX_RETRIES ? 'failed' : 'pending', + retryCount, + retryAt: Date.now() + backoffMs, + lastError: `HTTP ${response.status}`, + }); failed += 1; } } catch (error) { - await savePayment({ ...item, status: 'failed', retryCount: (item.retryCount || 0) + 1, lastError: String(error?.message || error) }); + const retryCount = (item.retryCount || 0) + 1; + const backoffMs = RETRY_BACKOFF_MS[Math.min(retryCount - 1, RETRY_BACKOFF_MS.length - 1)]; + await savePayment({ + ...item, + status: retryCount >= MAX_RETRIES ? 'failed' : 'pending', + retryCount, + retryAt: Date.now() + backoffMs, + lastError: String(error?.message || error), + }); failed += 1; - break; + if (!self.navigator || !self.navigator.onLine) break; } } - await broadcast({ type: 'PAYMENT_QUEUE_SYNCED', synced, failed, remaining: (await getQueuedPayments()).length }); + const remaining = (await getQueuedPayments()).length; + await broadcast({ type: 'PAYMENT_QUEUE_SYNCED', synced, failed, remaining }); return { synced, failed }; } diff --git a/frontend/src/components/common/optimistic-status.tsx b/frontend/src/components/common/optimistic-status.tsx new file mode 100644 index 00000000..c15c8205 --- /dev/null +++ b/frontend/src/components/common/optimistic-status.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { isOptimisticId } from '@/src/hooks/mutations/usePaymentMutations'; + +export type MutationState = 'idle' | 'pending' | 'success' | 'error'; + +interface OptimisticStatusProps { + id?: string; + state: MutationState; + pendingLabel?: string; + successLabel?: string; + errorLabel?: string; + successDurationMs?: number; + className?: string; +} + +export function OptimisticStatus({ + id, + state, + pendingLabel = 'Saving…', + successLabel = 'Saved', + errorLabel = 'Failed — tap to retry', + successDurationMs = 2000, + className = '', +}: OptimisticStatusProps) { + const [visible, setVisible] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (state !== 'idle') { + setVisible(true); + } + if (state === 'success') { + timerRef.current = setTimeout(() => setVisible(false), successDurationMs); + } + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [state, successDurationMs]); + + if (!visible && state === 'idle') return null; + + const isOptimistic = id ? isOptimisticId(id) : false; + + const stateMap: Record = { + idle: { label: '', cls: '', icon: '' }, + pending: { + label: isOptimistic ? `${pendingLabel} (optimistic)` : pendingLabel, + cls: 'bg-blue-50 text-blue-700 border-blue-200', + icon: '⟳', + }, + success: { + label: successLabel, + cls: 'bg-green-50 text-green-700 border-green-200', + icon: '✓', + }, + error: { + label: errorLabel, + cls: 'bg-red-50 text-red-700 border-red-200', + icon: '✕', + }, + }; + + const { label, cls, icon } = stateMap[state]; + if (!label) return null; + + return ( + + + {icon} + + {label} + + ); +} + +// ─── Stale badge ───────────────────────────────────────────────────────────── + +interface StaleBadgeProps { + isStale: boolean; + label?: string; + className?: string; +} + +export function StaleBadge({ isStale, label = 'Stale', className = '' }: StaleBadgeProps) { + if (!isStale) return null; + return ( + + {label} + + ); +} + +// ─── Conflict banner ───────────────────────────────────────────────────────── + +interface ConflictBannerProps { + hasConflict: boolean; + onAcceptServer: () => void; + onKeepOptimistic: () => void; +} + +export function ConflictBanner({ hasConflict, onAcceptServer, onKeepOptimistic }: ConflictBannerProps) { + if (!hasConflict) return null; + return ( +
+ Data conflict detected. + + · + +
+ ); +} + +// ─── useMutationState helper ───────────────────────────────────────────────── + +interface UseMutationStateReturn { + state: MutationState; + reset: () => void; +} + +export function useMutationState( + isPending: boolean, + isSuccess: boolean, + isError: boolean, +): UseMutationStateReturn { + const [state, setState] = useState('idle'); + + useEffect(() => { + if (isPending) setState('pending'); + else if (isSuccess) setState('success'); + else if (isError) setState('error'); + else setState('idle'); + }, [isPending, isSuccess, isError]); + + return { state, reset: () => setState('idle') }; +} diff --git a/frontend/src/hooks/mutations/usePaymentMutations.ts b/frontend/src/hooks/mutations/usePaymentMutations.ts new file mode 100644 index 00000000..24d37c27 --- /dev/null +++ b/frontend/src/hooks/mutations/usePaymentMutations.ts @@ -0,0 +1,236 @@ +'use client'; + +import { + useMutation, + useQueryClient, + type QueryClient, +} from '@tanstack/react-query'; +import { queryKeys } from '@/lib/query-keys'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +// Payments above this amount skip optimistic updates and wait for the server. +const OPTIMISTIC_THRESHOLD = 10_000; + +export interface Payment { + id: string; + tenantId: string; + amount: number; + currency: string; + network: string; + status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'refunded'; + type: 'milestone_payment' | 'full_payment' | 'refund'; + projectId?: string; + milestoneId?: string; + userId?: string; + fromAddress?: string; + toAddress?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreatePaymentInput { + amount: number; + currency: string; + network: string; + type?: Payment['type']; + projectId?: string; + milestoneId?: string; + fromAddress?: string; + toAddress?: string; + metadata?: Record; +} + +export interface UpdatePaymentInput { + id: string; + status?: Payment['status']; + metadata?: Record; +} + +async function apiCall(url: string, options: RequestInit): Promise { + const res = await fetch(url, { + ...options, + headers: { 'Content-Type': 'application/json', ...options.headers }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw Object.assign(new Error(body?.message ?? `HTTP ${res.status}`), { statusCode: res.status, body }); + } + return res.json() as Promise; +} + +function buildOptimisticPayment(input: CreatePaymentInput): Payment { + return { + id: `optimistic-${crypto.randomUUID()}`, + tenantId: '', + amount: input.amount, + currency: input.currency, + network: input.network, + status: 'pending', + type: input.type ?? 'milestone_payment', + projectId: input.projectId, + milestoneId: input.milestoneId, + fromAddress: input.fromAddress, + toAddress: input.toAddress, + metadata: input.metadata, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; +} + +// ─── Create payment ─────────────────────────────────────────────────────────── + +export function useCreatePayment(filters: Record = {}) { + const client = useQueryClient(); + const listKey = queryKeys.payments.list(filters); + + return useMutation({ + mutationFn: (input) => + apiCall(`${BASE_URL}/api/v1/payments`, { + method: 'POST', + body: JSON.stringify(input), + }), + + onMutate: async (input) => { + if (input.amount >= OPTIMISTIC_THRESHOLD) return { previous: undefined }; + + await client.cancelQueries({ queryKey: listKey }); + const previous = client.getQueryData(listKey); + + const optimistic = buildOptimisticPayment(input); + client.setQueryData(listKey, (old = []) => [optimistic, ...old]); + + return { previous }; + }, + + onError: (_err, _input, ctx) => { + if (ctx?.previous !== undefined) { + client.setQueryData(listKey, ctx.previous); + } + }, + + onSettled: () => { + client.invalidateQueries({ queryKey: queryKeys.payments.lists() }); + }, + }); +} + +// ─── Update payment ─────────────────────────────────────────────────────────── + +export function useUpdatePayment(filters: Record = {}) { + const client = useQueryClient(); + + return useMutation< + Payment, + Error, + UpdatePaymentInput, + { previousList: Payment[] | undefined; previousDetail: Payment | undefined } + >({ + mutationFn: ({ id, ...body }) => + apiCall(`${BASE_URL}/api/v1/payments/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + + onMutate: async (input) => { + const listKey = queryKeys.payments.list(filters); + const detailKey = queryKeys.payments.detail(input.id); + + await client.cancelQueries({ queryKey: listKey }); + await client.cancelQueries({ queryKey: detailKey }); + + const previousList = client.getQueryData(listKey); + const previousDetail = client.getQueryData(detailKey); + + const applyUpdate = (p: Payment): Payment => + p.id === input.id ? { ...p, ...input, updatedAt: new Date().toISOString() } : p; + + client.setQueryData(listKey, (old = []) => old.map(applyUpdate)); + if (previousDetail) { + client.setQueryData(detailKey, applyUpdate(previousDetail)); + } + + return { previousList, previousDetail }; + }, + + onError: (_err, input, ctx) => { + if (ctx?.previousList !== undefined) { + client.setQueryData(queryKeys.payments.list(filters), ctx.previousList); + } + if (ctx?.previousDetail !== undefined) { + client.setQueryData(queryKeys.payments.detail(input.id), ctx.previousDetail); + } + }, + + onSettled: (_data, _err, input) => { + client.invalidateQueries({ queryKey: queryKeys.payments.lists() }); + client.invalidateQueries({ queryKey: queryKeys.payments.detail(input.id) }); + }, + }); +} + +// ─── Cancel payment ─────────────────────────────────────────────────────────── + +export function useCancelPayment(filters: Record = {}) { + const client = useQueryClient(); + + return useMutation({ + mutationFn: (id) => + apiCall(`${BASE_URL}/api/v1/payments/${id}/cancel`, { method: 'POST' }), + + onMutate: async (id) => { + const listKey = queryKeys.payments.list(filters); + await client.cancelQueries({ queryKey: listKey }); + const previous = client.getQueryData(listKey); + + client.setQueryData(listKey, (old = []) => + old.map((p) => (p.id === id ? { ...p, status: 'cancelled', updatedAt: new Date().toISOString() } : p)), + ); + + return { previous }; + }, + + onError: (_err, _id, ctx) => { + if (ctx?.previous !== undefined) { + client.setQueryData(queryKeys.payments.list(filters), ctx.previous); + } + }, + + onSettled: (_data, _err, id) => { + client.invalidateQueries({ queryKey: queryKeys.payments.lists() }); + client.invalidateQueries({ queryKey: queryKeys.payments.detail(id) }); + }, + }); +} + +// ─── Retry failed payment ───────────────────────────────────────────────────── + +export function useRetryPayment() { + const client = useQueryClient(); + + return useMutation({ + mutationFn: (id) => + apiCall(`${BASE_URL}/api/v1/payments/${id}/retry`, { method: 'POST' }), + + onSettled: (_data, _err, id) => { + client.invalidateQueries({ queryKey: queryKeys.payments.lists() }); + client.invalidateQueries({ queryKey: queryKeys.payments.detail(id) }); + }, + }); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function isOptimisticId(id: string): boolean { + return id.startsWith('optimistic-'); +} + +export function prefetchPayment(client: QueryClient, id: string): void { + client.prefetchQuery({ + queryKey: queryKeys.payments.detail(id), + queryFn: () => + apiCall(`${BASE_URL}/api/v1/payments/${id}`, { method: 'GET' }), + staleTime: 30_000, + }); +} diff --git a/frontend/src/hooks/useOnlineStatus.ts b/frontend/src/hooks/useOnlineStatus.ts new file mode 100644 index 00000000..36fd2ce2 --- /dev/null +++ b/frontend/src/hooks/useOnlineStatus.ts @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { flush, getSnapshot, subscribeToQueue, type FlushResult, type QueueSnapshot } from '@/src/lib/offline-queue'; + +const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +export interface OnlineStatus { + isOnline: boolean; + wasOffline: boolean; + queue: QueueSnapshot; + isFlushing: boolean; + lastFlushResult: FlushResult | null; + flushNow: () => Promise; +} + +export function useOnlineStatus(): OnlineStatus { + const [isOnline, setIsOnline] = useState( + typeof navigator !== 'undefined' ? navigator.onLine : true, + ); + const [wasOffline, setWasOffline] = useState(false); + const [queue, setQueue] = useState({ + pending: 0, + syncing: 0, + failed: 0, + total: 0, + items: [], + }); + const [isFlushing, setIsFlushing] = useState(false); + const [lastFlushResult, setLastFlushResult] = useState(null); + const flushingRef = useRef(false); + + const refreshQueue = useCallback(async () => { + try { + const snap = await getSnapshot(); + setQueue(snap); + } catch { + // IndexedDB unavailable (SSR / private mode) + } + }, []); + + const flushNow = useCallback(async () => { + if (flushingRef.current) return; + flushingRef.current = true; + setIsFlushing(true); + try { + const result = await flush(BASE_URL); + setLastFlushResult(result); + await refreshQueue(); + } finally { + flushingRef.current = false; + setIsFlushing(false); + } + }, [refreshQueue]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + refreshQueue(); + + const handleOnline = () => { + setIsOnline(true); + setWasOffline(true); + flushNow(); + }; + + const handleOffline = () => { + setIsOnline(false); + }; + + // Handle messages from the service worker about queue state changes. + const handleSwMessage = (e: MessageEvent) => { + const { type } = e.data ?? {}; + if (type === 'PAYMENT_QUEUE_CHANGED' || type === 'PAYMENT_QUEUE_SYNCED') { + refreshQueue(); + } + }; + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + const unsubscribeQueue = subscribeToQueue(refreshQueue); + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', handleSwMessage); + } + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + unsubscribeQueue(); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.removeEventListener('message', handleSwMessage); + } + }; + }, [flushNow, refreshQueue]); + + // Flush any pending items on mount when already online. + useEffect(() => { + if (isOnline) { + refreshQueue().then((async () => { + const snap = await getSnapshot(); + if (snap.pending > 0) flushNow(); + }) as () => Promise); + } + // Only on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { isOnline, wasOffline, queue, isFlushing, lastFlushResult, flushNow }; +} diff --git a/frontend/src/lib/offline-queue.ts b/frontend/src/lib/offline-queue.ts new file mode 100644 index 00000000..1638e19b --- /dev/null +++ b/frontend/src/lib/offline-queue.ts @@ -0,0 +1,215 @@ +// IndexedDB-backed offline transaction queue. +// Queued items are submitted when connectivity returns via the SyncManager API +// or the useOnlineStatus hook's manual flush trigger. + +const DB_NAME = 'agenticpay-offline-db'; +const DB_VERSION = 2; // v1 used by sw.js; v2 adds retry_at index +const TX_STORE = 'offline-payments'; +const SYNC_TAG = 'agenticpay-payment-sync'; + +export type QueuedItemStatus = 'pending' | 'syncing' | 'synced' | 'failed'; + +export interface QueuedTransaction { + id: string; + endpoint: string; + method: string; + headers: Record; + body: string; + createdAt: number; + updatedAt: number; + status: QueuedItemStatus; + retryCount: number; + retryAt?: number; + lastError?: string; +} + +export interface QueueSnapshot { + pending: number; + syncing: number; + failed: number; + total: number; + items: QueuedTransaction[]; +} + +export interface FlushResult { + synced: number; + failed: number; + remaining: number; +} + +const MAX_RETRIES = 5; +const RETRY_BACKOFF_MS = [5_000, 15_000, 60_000, 300_000, 600_000]; + +// ─── IndexedDB helpers ──────────────────────────────────────────────────────── + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(TX_STORE)) { + const store = db.createObjectStore(TX_STORE, { keyPath: 'id' }); + store.createIndex('status', 'status', { unique: false }); + store.createIndex('createdAt', 'createdAt', { unique: false }); + store.createIndex('retryAt', 'retryAt', { unique: false }); + } else if (e.oldVersion < 2) { + const store = req.transaction!.objectStore(TX_STORE); + if (!store.indexNames.contains('retryAt')) { + store.createIndex('retryAt', 'retryAt', { unique: false }); + } + } + }; + }); +} + +async function withStore( + mode: IDBTransactionMode, + op: (store: IDBObjectStore) => IDBRequest, +): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(TX_STORE, mode); + const store = tx.objectStore(TX_STORE); + const req = op(store); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }).finally(() => db.close()) as Promise; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function enqueue( + input: Pick, +): Promise { + const item: QueuedTransaction = { + id: crypto.randomUUID(), + ...input, + createdAt: Date.now(), + updatedAt: Date.now(), + status: 'pending', + retryCount: 0, + }; + await withStore('readwrite', (s) => s.put(item)); + await triggerBackgroundSync(); + dispatchQueueEvent(); + return item; +} + +export async function getSnapshot(): Promise { + const items = await withStore('readonly', (s) => s.getAll()); + return { + pending: items.filter((i) => i.status === 'pending').length, + syncing: items.filter((i) => i.status === 'syncing').length, + failed: items.filter((i) => i.status === 'failed').length, + total: items.length, + items, + }; +} + +export async function flush(apiBaseUrl = ''): Promise { + const items = await withStore('readonly', (s) => s.getAll()); + const due = items.filter( + (i) => i.status !== 'synced' && i.status !== 'syncing' && (i.retryAt === undefined || i.retryAt <= Date.now()), + ); + + let synced = 0; + let failed = 0; + + for (const item of due) { + await put({ ...item, status: 'syncing', updatedAt: Date.now() }); + + try { + const res = await fetch(`${apiBaseUrl}${item.endpoint}`, { + method: item.method, + headers: { ...item.headers, 'X-AgenticPay-Offline-Replay': 'true' }, + body: item.body, + }); + + if (res.ok || res.status === 409) { + await remove(item.id); + synced += 1; + } else { + await markFailed(item, `HTTP ${res.status}`); + failed += 1; + } + } catch (err) { + await markFailed(item, err instanceof Error ? err.message : String(err)); + failed += 1; + if (!navigator.onLine) break; // stop retrying if we've gone offline + } + } + + dispatchQueueEvent(); + return { synced, failed, remaining: (await getSnapshot()).total }; +} + +export async function removeItem(id: string): Promise { + await remove(id); + dispatchQueueEvent(); +} + +export async function clearAll(): Promise { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(TX_STORE, 'readwrite'); + const req = tx.objectStore(TX_STORE).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }).finally(() => db.close()); + dispatchQueueEvent(); +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +async function put(item: QueuedTransaction): Promise { + await withStore('readwrite', (s) => s.put({ ...item, updatedAt: Date.now() })); +} + +async function remove(id: string): Promise { + await withStore('readwrite', (s) => s.delete(id)); +} + +async function markFailed(item: QueuedTransaction, error: string): Promise { + const retryCount = (item.retryCount ?? 0) + 1; + const backoffMs = RETRY_BACKOFF_MS[Math.min(retryCount - 1, RETRY_BACKOFF_MS.length - 1)]; + await put({ + ...item, + status: retryCount >= MAX_RETRIES ? 'failed' : 'pending', + retryCount, + retryAt: Date.now() + backoffMs, + lastError: error, + }); +} + +async function triggerBackgroundSync(): Promise { + if ('serviceWorker' in navigator) { + try { + const reg = await navigator.serviceWorker.ready; + if ('sync' in reg) { + await (reg as ServiceWorkerRegistration & { sync: { register(tag: string): Promise } }).sync.register( + SYNC_TAG, + ); + } + } catch { + // SyncManager not supported or SW not active — flush will run manually + } + } +} + +const QUEUE_EVENT = 'agenticpay:offline-queue-changed'; + +function dispatchQueueEvent(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(QUEUE_EVENT)); + } +} + +export function subscribeToQueue(listener: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + window.addEventListener(QUEUE_EVENT, listener); + return () => window.removeEventListener(QUEUE_EVENT, listener); +} diff --git a/infra/cdn.tf b/infra/cdn.tf new file mode 100644 index 00000000..a74d8d3b --- /dev/null +++ b/infra/cdn.tf @@ -0,0 +1,351 @@ +# CDN-powered geo-distributed API edge caching (#502) +# Provisions a CloudFront distribution in front of the AgenticPay API with +# configurable TTLs per behaviour, origin shield, and geo-routing. + +# ─── Variables ──────────────────────────────────────────────────────────────── + +variable "api_origin_domain" { + description = "Domain name of the origin API (ALB or ECS service URL)." + type = string +} + +variable "cdn_price_class" { + description = "CloudFront price class controls which edge locations serve traffic." + type = string + default = "PriceClass_100" # US, Canada, Europe — cheapest; use PriceClass_All for global +} + +variable "cdn_acm_certificate_arn" { + description = "ACM certificate ARN (us-east-1) for the CDN distribution." + type = string + default = "" +} + +variable "cdn_aliases" { + description = "Alternate domain names (CNAMEs) for the CloudFront distribution." + type = list(string) + default = [] +} + +variable "origin_shield_region" { + description = "AWS region for CloudFront origin shield — pick one closest to the origin." + type = string + default = "us-east-1" +} + +# ─── Cache policies ─────────────────────────────────────────────────────────── + +resource "aws_cloudfront_cache_policy" "static_data" { + name = "agenticpay-${var.environment}-static-data" + comment = "Static API data: 5 min TTL (config, metadata)" + default_ttl = 300 + max_ttl = 600 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "none" + } + headers_config { + header_behavior = "whitelist" + headers { + items = ["Accept", "Accept-Language", "X-Auth-Hash"] + } + } + query_strings_config { + query_string_behavior = "all" + } + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + } +} + +resource "aws_cloudfront_cache_policy" "user_data" { + name = "agenticpay-${var.environment}-user-data" + comment = "Per-user API data: 30 s TTL (payments, invoices)" + default_ttl = 30 + max_ttl = 60 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "none" + } + headers_config { + header_behavior = "whitelist" + headers { + items = ["Accept", "Accept-Language", "X-Auth-Hash", "Authorization"] + } + } + query_strings_config { + query_string_behavior = "all" + } + enable_accept_encoding_gzip = true + enable_accept_encoding_brotli = true + } +} + +resource "aws_cloudfront_cache_policy" "no_cache" { + name = "agenticpay-${var.environment}-no-cache" + comment = "Real-time / mutation endpoints — never cache" + default_ttl = 0 + max_ttl = 0 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "none" + } + headers_config { + header_behavior = "none" + } + query_strings_config { + query_string_behavior = "none" + } + enable_accept_encoding_gzip = false + enable_accept_encoding_brotli = false + } +} + +# ─── Origin request policy ──────────────────────────────────────────────────── + +resource "aws_cloudfront_origin_request_policy" "api" { + name = "agenticpay-${var.environment}-api-origin" + comment = "Forward necessary headers and query strings to the API origin" + + cookies_config { + cookie_behavior = "none" + } + + headers_config { + header_behavior = "whitelist" + headers { + items = [ + "Accept", + "Accept-Language", + "Authorization", + "Content-Type", + "X-Request-ID", + "X-Tenant-ID", + "CloudFront-Viewer-Country", + "CloudFront-Viewer-City", + ] + } + } + + query_strings_config { + query_string_behavior = "all" + } +} + +# ─── CloudFront distribution ────────────────────────────────────────────────── + +resource "aws_cloudfront_distribution" "api" { + enabled = true + is_ipv6_enabled = true + comment = "AgenticPay API CDN — ${var.environment}" + price_class = var.cdn_price_class + aliases = var.cdn_aliases + + origin { + domain_name = var.api_origin_domain + origin_id = "agenticpay-api-origin" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + + # Origin shield reduces load on the origin by coalescing requests from all + # edge locations through a single intermediate cache layer. + origin_shield { + enabled = true + origin_shield_region = var.origin_shield_region + } + + custom_header { + name = "X-CDN-Secret" + value = var.environment == "prod" ? data.aws_ssm_parameter.cdn_secret[0].value : "dev-secret" + } + } + + # ── Default: no cache (mutations, auth, websockets) ─────────────────────── + default_cache_behavior { + target_origin_id = "agenticpay-api-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.no_cache.id + origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id + compress = true + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.auth_hash.arn + } + } + + # ── Static / public API data (GET /api/v1/config, /api/v1/currencies …) ── + ordered_cache_behavior { + path_pattern = "/api/v1/config*" + target_origin_id = "agenticpay-api-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.static_data.id + origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id + compress = true + } + + ordered_cache_behavior { + path_pattern = "/api/v1/currencies*" + target_origin_id = "agenticpay-api-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.static_data.id + origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id + compress = true + } + + # ── User-scoped read data (GET /api/v1/payments, /api/v1/invoices …) ────── + ordered_cache_behavior { + path_pattern = "/api/v1/payments*" + target_origin_id = "agenticpay-api-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.user_data.id + origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id + compress = true + } + + ordered_cache_behavior { + path_pattern = "/api/v1/invoices*" + target_origin_id = "agenticpay-api-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD"] + cache_policy_id = aws_cloudfront_cache_policy.user_data.id + origin_request_policy_id = aws_cloudfront_origin_request_policy.api.id + compress = true + } + + # ── Geo restrictions ────────────────────────────────────────────────────── + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + # ── TLS ─────────────────────────────────────────────────────────────────── + dynamic "viewer_certificate" { + for_each = var.cdn_acm_certificate_arn != "" ? [1] : [] + content { + acm_certificate_arn = var.cdn_acm_certificate_arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } + } + + dynamic "viewer_certificate" { + for_each = var.cdn_acm_certificate_arn == "" ? [1] : [] + content { + cloudfront_default_certificate = true + } + } + + # ── Logging ─────────────────────────────────────────────────────────────── + logging_config { + bucket = aws_s3_bucket.cdn_logs.bucket_domain_name + prefix = "cdn/${var.environment}/" + include_cookies = false + } + + tags = { + Name = "agenticpay-api-cdn-${var.environment}" + } +} + +# ─── CloudFront function: hash Authorization before caching ────────────────── + +resource "aws_cloudfront_function" "auth_hash" { + name = "agenticpay-${var.environment}-auth-hash" + runtime = "cloudfront-js-2.0" + comment = "Replace Authorization header with a SHA-256 prefix for cache-key safety" + publish = true + + code = <<-EOF + import crypto from 'crypto'; + function handler(event) { + var req = event.request; + var auth = (req.headers['authorization'] || {}).value; + if (auth) { + var hash = crypto.subtle + ? btoa(String.fromCharCode(...new Uint8Array( + crypto.createHash('sha256').update(auth).digest() + ))).slice(0, 16) + : auth.slice(0, 16); + req.headers['x-auth-hash'] = { value: hash }; + delete req.headers['authorization']; + } + return req; + } + EOF +} + +# ─── CDN access log bucket ──────────────────────────────────────────────────── + +resource "aws_s3_bucket" "cdn_logs" { + bucket = "agenticpay-cdn-logs-${var.environment}-${data.aws_caller_identity.current.account_id}" + + tags = { + Name = "agenticpay-cdn-logs-${var.environment}" + Purpose = "CloudFront access logs" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "cdn_logs" { + bucket = aws_s3_bucket.cdn_logs.id + + rule { + id = "expire-old-logs" + status = "Enabled" + + expiration { + days = 90 + } + + filter { + prefix = "cdn/" + } + } +} + +# ─── CDN origin shared secret (prod only) ──────────────────────────────────── + +data "aws_ssm_parameter" "cdn_secret" { + count = var.environment == "prod" ? 1 : 0 + name = "/agenticpay/${var.environment}/cdn-origin-secret" +} + +data "aws_caller_identity" "current" {} + +# ─── Outputs ───────────────────────────────────────────────────────────────── + +output "cdn_distribution_id" { + description = "CloudFront distribution ID — used for cache invalidation." + value = aws_cloudfront_distribution.api.id +} + +output "cdn_domain_name" { + description = "CloudFront distribution domain name." + value = aws_cloudfront_distribution.api.domain_name +} + +output "cdn_hosted_zone_id" { + description = "CloudFront hosted zone ID for Route53 alias records." + value = aws_cloudfront_distribution.api.hosted_zone_id +} diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..8e40d016 --- /dev/null +++ b/pr.md @@ -0,0 +1,19 @@ +# Pull Request: Add Deployment Guidelines + +## 📝 Description +This Pull Request introduces comprehensive deployment documentation for AgenticPay. Previously, the project lacked centralized instructions for deploying the various components. This guide provides step-by-step instructions for deploying the Smart Contracts, Backend API Server, and Frontend Web Application. + +## 🚀 Changes Made +Created a new `docs/deployment.md` file including: +- **Deployment Prerequisites**: Outlining required tools (Node.js, Rust, Cargo, Stellar CLI, PM2). +- **Environment Configuration**: Listing necessary `.env` variables for smart contracts, backend, and frontend. +- **Deployment Steps**: Detailed execution order for building and deploying each part of the stack (contracts -> backend -> frontend). +- **Rollback Procedures**: Clear instructions for reverting failed deployments on all layers, including smart contract behavior. + +## ✅ Checklist +- [x] Verified prerequisites are accurate for the current tech stack. +- [x] Verified deployment steps align with actual project structure (Next.js, Express, Soroban). +- [x] Documentation is formatted in clean and readable Markdown. + +## 📸 Notes to Reviewers +Reviewers, please confirm that the environment variables listed for the React frontend and Express backend match any recent updates to our config systems, and that the Smart Contract deployment steps match the latest Soroban CLI changes.