diff --git a/fluid-server/Cargo.toml b/fluid-server/Cargo.toml index 6528825..ab3aac7 100644 --- a/fluid-server/Cargo.toml +++ b/fluid-server/Cargo.toml @@ -13,9 +13,6 @@ sha2 = "0.10" stellar-strkey = "0.0.16" stellar-xdr = { version = "26.0.0", features = ["base64"] } wasm-bindgen = "0.2.105" - -base64 = "0.22" - dotenvy = "0.15" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde", "rng-getrandom"] } diff --git a/server/.env.example b/server/.env.example index 8951cd0..0dae9d3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -126,6 +126,16 @@ FLUID_MAX_OPERATIONS=100 # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" +# --- Data Residency (Multi-Region) --- +# The region this server instance serves. Must be "US" or "EU". +# Responses include an X-Fluid-Region header reflecting this value. +DATABASE_REGION=US +# Per-region connection strings. When set, tenants with the matching region +# have all their data read from and written to the corresponding database. +# If a regional URL is omitted, DATABASE_URL is used as the fallback for that region. +DATABASE_URL_US="postgresql://user:pass@us-db-host:5432/fluid?schema=public" +DATABASE_URL_EU="postgresql://user:pass@eu-db-host:5432/fluid?schema=public" + # Stripe Billing (for fiat quota top-ups) STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/server/prisma/migrations/20260329180000_add_tenant_region/migration.sql b/server/prisma/migrations/20260329180000_add_tenant_region/migration.sql new file mode 100644 index 0000000..17d173b --- /dev/null +++ b/server/prisma/migrations/20260329180000_add_tenant_region/migration.sql @@ -0,0 +1,6 @@ +-- Add data residency region field to Tenant +-- Default "US" preserves existing rows without data loss. +ALTER TABLE "Tenant" ADD COLUMN "region" TEXT NOT NULL DEFAULT 'US'; + +-- Index supports per-region administrative queries (e.g. list all EU tenants) +CREATE INDEX "Tenant_region_idx" ON "Tenant"("region"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index df6b906..439547d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -25,6 +25,7 @@ model SubscriptionTier { model Tenant { id String @id @default(uuid()) name String + region String @default("US") // Data residency region: "US" | "EU" subscriptionTierId String subscriptionTier SubscriptionTier @relation(fields: [subscriptionTierId], references: [id]) dailyQuotaStroops BigInt @default(1000000) // 0.1 XLM default diff --git a/server/src/handlers/feeBump.slippage.test.ts b/server/src/handlers/feeBump.slippage.test.ts index b10683c..388f8fe 100644 --- a/server/src/handlers/feeBump.slippage.test.ts +++ b/server/src/handlers/feeBump.slippage.test.ts @@ -75,6 +75,9 @@ describe("feeBumpHandler - Slippage Protection", () => { maxRequests: 100, windowMs: 60000, dailyQuotaStroops: 1000000, + isSandbox: false, + allowedChains: ["stellar"] as any, + region: "US" as const, }; mockReq = { body: {}, headers: {}, method: "POST", url: "/fee-bump" }; diff --git a/server/src/index.ts b/server/src/index.ts index a2a3f6d..b68c6fb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -96,6 +96,7 @@ import { } from "./services/chainRegistryService"; import { initializeFeeManager } from "./services/feeManager"; import { initializeOFACScreening, stopOFACScreening } from "./services/ofacScreening"; +import { initializeRegionalDbs, DEFAULT_REGION } from "./services/regionRouter"; import { listTransactionsHandler } from "./handlers/adminTransactions"; import { listSARReportsHandler, @@ -135,6 +136,7 @@ async function initializeAuditLog() { } initializeAuditLog(); +initializeRegionalDbs(); initializeOFACScreening(); const feeManager = initializeFeeManager(config); @@ -167,6 +169,12 @@ app.use(ipFilterMiddleware); app.use(express.json()); app.use(soc2RequestLogger); +// Stamp every response with the instance's home region for observability +app.use((_req, res, next) => { + res.setHeader("X-Fluid-Region", DEFAULT_REGION); + next(); +}); + // Use Redis-backed store for global IP rate limiting. Falls back to memory store if Redis unavailable. const windowSeconds = Math.max(1, Math.ceil(config.rateLimitWindowMs / 1000)); let limiterStore: any = undefined; diff --git a/server/src/middleware/apiKeys.ts b/server/src/middleware/apiKeys.ts index a01492c..77a40d7 100644 --- a/server/src/middleware/apiKeys.ts +++ b/server/src/middleware/apiKeys.ts @@ -11,6 +11,12 @@ import { SubscriptionTierName, toTierCode, } from "../models/subscriptionTier"; +import { + Region, + DEFAULT_REGION, + getDbForRegion, + findApiKeyAcrossRegions, +} from "../services/regionRouter"; export const VALID_CHAINS = ["stellar", "evm", "solana", "cosmos"] as const; export type Chain = (typeof VALID_CHAINS)[number]; @@ -30,6 +36,7 @@ export interface ApiKeyConfig { dailyQuotaStroops: number; isSandbox: boolean; allowedChains: Chain[]; + region: Region; } function parseAllowedChains(raw?: string | null): Chain[] { @@ -80,27 +87,22 @@ export async function apiKeyMiddleware( // eslint-disable-next-line no-console console.log("[Redis] Cache Hit for API key:", maskApiKey(apiKey)); - res.locals.apiKey = JSON.parse(cached) as ApiKeyConfig; + const apiKeyConfig = JSON.parse(cached) as ApiKeyConfig; + res.locals.apiKey = apiKeyConfig; + res.locals.db = getDbForRegion(apiKeyConfig.region ?? DEFAULT_REGION); return next(); } } catch (err) { // If Redis fails, fall back to DB/in-memory lookup below. } - // 2) Try DB (Prisma) lookup + // 2) Try DB (Prisma) lookup — searches all configured regional DBs try { - const keyRecord = await prisma.apiKey.findUnique({ - where: { key: apiKey }, - include: { - tenant: { - include: { - subscriptionTier: true, - }, - }, - }, - }); - - if (keyRecord) { + const found = await findApiKeyAcrossRegions(apiKey); + + if (found) { + const { record: keyRecord, region } = found; + // Reject revoked keys immediately if (!keyRecord.active) { return next( @@ -130,6 +132,7 @@ export async function apiKeyMiddleware( dailyQuotaStroops: Number(keyRecord.dailyQuotaStroops), isSandbox: keyRecord.isSandbox ?? false, allowedChains, + region: (keyRecord.tenant?.region as Region | undefined) ?? region, }; // Cache the key for future requests. Non-blocking: don't fail the request on cache errors. @@ -138,6 +141,8 @@ export async function apiKeyMiddleware( ); res.locals.apiKey = apiKeyConfig; + // Attach the correct regional DB client so handlers write to the right region + res.locals.db = getDbForRegion(apiKeyConfig.region); return next(); } } catch (err) { @@ -155,6 +160,7 @@ export async function apiKeyMiddleware( setCachedApiKey(apiKey, JSON.stringify(apiKeyConfig), 300).catch(() => {}); res.locals.apiKey = apiKeyConfig; + res.locals.db = getDbForRegion(apiKeyConfig.region ?? DEFAULT_REGION); next(); } diff --git a/server/src/models/tenantStore.ts b/server/src/models/tenantStore.ts index 55160d4..4d6e87c 100644 --- a/server/src/models/tenantStore.ts +++ b/server/src/models/tenantStore.ts @@ -1,10 +1,12 @@ import { ApiKeyConfig } from "../middleware/apiKeys"; import { SubscriptionTierCode, SubscriptionTierName } from "./subscriptionTier"; +import { Region, DEFAULT_REGION } from "../services/regionRouter"; export interface Tenant { id: string; apiKey: string; name: string; + region: Region; tier: SubscriptionTierCode; tierName: SubscriptionTierName; txLimit: number; @@ -22,6 +24,7 @@ export function syncTenantFromApiKey(apiKeyConfig: ApiKeyConfig): Tenant { const updatedTenant: Tenant = { ...existingTenant, name: apiKeyConfig.name, + region: apiKeyConfig.region ?? existingTenant.region, tier: apiKeyConfig.tier, tierName: apiKeyConfig.tierName, txLimit: apiKeyConfig.txLimit, @@ -38,6 +41,7 @@ export function syncTenantFromApiKey(apiKeyConfig: ApiKeyConfig): Tenant { id: apiKeyConfig.tenantId, apiKey: apiKeyConfig.key, name: apiKeyConfig.name, + region: apiKeyConfig.region ?? DEFAULT_REGION, tier: apiKeyConfig.tier, tierName: apiKeyConfig.tierName, txLimit: apiKeyConfig.txLimit, diff --git a/server/src/services/regionRouter.test.ts b/server/src/services/regionRouter.test.ts new file mode 100644 index 0000000..e79ff41 --- /dev/null +++ b/server/src/services/regionRouter.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// We import the module under test AFTER stubbing env vars so that +// module-level constants pick up the correct values. +// Each test suite resets the module between tests where needed. + +describe("resolveDbUrl", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("returns DATABASE_URL_EU when region is EU and that env var is set", async () => { + vi.stubEnv("DATABASE_URL_EU", "file:./eu.db"); + vi.stubEnv("DATABASE_URL", "file:./default.db"); + const { resolveDbUrl } = await import("./regionRouter"); + expect(resolveDbUrl("EU")).toBe("file:./eu.db"); + }); + + it("returns DATABASE_URL_US when region is US and that env var is set", async () => { + vi.stubEnv("DATABASE_URL_US", "file:./us.db"); + const { resolveDbUrl } = await import("./regionRouter"); + expect(resolveDbUrl("US")).toBe("file:./us.db"); + }); + + it("falls back to DATABASE_URL when no region-specific URL is set", async () => { + vi.stubEnv("DATABASE_URL", "file:./fallback.db"); + const { resolveDbUrl } = await import("./regionRouter"); + expect(resolveDbUrl("EU")).toBe("file:./fallback.db"); + }); + + it("falls back to file:./dev.db when neither regional nor DATABASE_URL is set", async () => { + const { resolveDbUrl } = await import("./regionRouter"); + expect(resolveDbUrl("EU")).toBe("file:./dev.db"); + }); +}); + +describe("DEFAULT_REGION", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("defaults to US when DATABASE_REGION is not set", async () => { + const { DEFAULT_REGION } = await import("./regionRouter"); + expect(DEFAULT_REGION).toBe("US"); + }); + + it("reads DATABASE_REGION from env", async () => { + vi.stubEnv("DATABASE_REGION", "EU"); + const { DEFAULT_REGION } = await import("./regionRouter"); + expect(DEFAULT_REGION).toBe("EU"); + }); + + it("normalises lowercase to uppercase", async () => { + vi.stubEnv("DATABASE_REGION", "eu"); + const { DEFAULT_REGION } = await import("./regionRouter"); + expect(DEFAULT_REGION).toBe("EU"); + }); +}); + +describe("getDbForRegion", () => { + beforeEach(() => { + vi.stubEnv("DATABASE_URL", "file:./dev.db"); + vi.stubEnv("DATABASE_URL_EU", "file:./eu.db"); + vi.stubEnv("DATABASE_URL_US", "file:./us.db"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("returns the same client instance on repeated calls for the same region (pool caching)", async () => { + const { getDbForRegion } = await import("./regionRouter"); + const a = getDbForRegion("EU"); + const b = getDbForRegion("EU"); + expect(a).toBe(b); + }); + + it("returns distinct clients for different regions", async () => { + const { getDbForRegion } = await import("./regionRouter"); + const eu = getDbForRegion("EU"); + const us = getDbForRegion("US"); + expect(eu).not.toBe(us); + }); +}); + +describe("isRegionIsolated", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("returns true when DATABASE_URL_EU is configured", async () => { + vi.stubEnv("DATABASE_URL_EU", "file:./eu.db"); + const { isRegionIsolated } = await import("./regionRouter"); + expect(isRegionIsolated("EU")).toBe(true); + }); + + it("returns false when DATABASE_URL_EU is not configured", async () => { + const { isRegionIsolated } = await import("./regionRouter"); + expect(isRegionIsolated("EU")).toBe(false); + }); + + it("returns false for US when DATABASE_URL_US is absent", async () => { + const { isRegionIsolated } = await import("./regionRouter"); + expect(isRegionIsolated("US")).toBe(false); + }); +}); + +describe("getConfiguredRegions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("always includes the default region", async () => { + vi.stubEnv("DATABASE_REGION", "US"); + const { getConfiguredRegions } = await import("./regionRouter"); + expect(getConfiguredRegions()).toContain("US"); + }); + + it("includes EU when DATABASE_URL_EU is configured", async () => { + vi.stubEnv("DATABASE_URL_EU", "file:./eu.db"); + const { getConfiguredRegions } = await import("./regionRouter"); + expect(getConfiguredRegions()).toContain("EU"); + }); + + it("excludes a region when its URL is not configured and it is not the default", async () => { + vi.stubEnv("DATABASE_REGION", "US"); + // No DATABASE_URL_EU set + const { getConfiguredRegions } = await import("./regionRouter"); + expect(getConfiguredRegions()).not.toContain("EU"); + }); +}); + +describe("findApiKeyAcrossRegions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("returns null for a key that does not exist in the dev database", async () => { + vi.stubEnv("DATABASE_URL", "file:./dev.db"); + const { findApiKeyAcrossRegions } = await import("./regionRouter"); + const result = await findApiKeyAcrossRegions("sk_totally_nonexistent_xyz"); + expect(result).toBeNull(); + }); + + it("does not throw when a regional DB lookup rejects (resilience)", async () => { + // If one region's DB is misconfigured and throws, the function should still + // return null rather than propagating the error to the caller. + vi.stubEnv("DATABASE_URL", "file:./dev.db"); + // Pointing EU at a non-existent file forces a DB error + vi.stubEnv("DATABASE_URL_EU", "file:./nonexistent_eu.db"); + + const { findApiKeyAcrossRegions } = await import("./regionRouter"); + // Should resolve without throwing even if EU DB fails + await expect(findApiKeyAcrossRegions("any-key")).resolves.toBeNull(); + }); + + it("searches all configured regions (both US and EU) when both are set", async () => { + vi.stubEnv("DATABASE_URL_EU", "file:./dev.db"); + vi.stubEnv("DATABASE_URL_US", "file:./dev.db"); + const { findApiKeyAcrossRegions } = await import("./regionRouter"); + // Both point to dev.db which has no matching key — result is null, but no error + const result = await findApiKeyAcrossRegions("sk_multi_region_test"); + expect(result).toBeNull(); + }); +}); diff --git a/server/src/services/regionRouter.ts b/server/src/services/regionRouter.ts new file mode 100644 index 0000000..98b676f --- /dev/null +++ b/server/src/services/regionRouter.ts @@ -0,0 +1,146 @@ +/** + * Data Residency Region Router + * + * Maintains one Prisma client per configured region. The instance's home region + * is set via DATABASE_REGION. Each region's connection string is supplied via + * DATABASE_URL_ (e.g. DATABASE_URL_EU, DATABASE_URL_US). + * + * When an API key is resolved, the middleware calls getDbForRegion() with the + * tenant's stored region so all subsequent DB writes land in the correct + * regional database. + * + * Fallback chain: + * 1. DATABASE_URL_ – region-specific URL + * 2. DATABASE_URL – shared / single-region URL + * 3. file:./dev.db – local dev default + */ + +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; +import { createLogger } from "../utils/logger"; + +const logger = createLogger({ component: "region_router" }); + +export const SUPPORTED_REGIONS = ["US", "EU"] as const; +export type Region = (typeof SUPPORTED_REGIONS)[number]; + +export const DEFAULT_REGION: Region = + (process.env.DATABASE_REGION?.toUpperCase() as Region | undefined) ?? "US"; + +type PrismaClientLike = { [key: string]: any }; + +type PrismaModule = { + PrismaClient: new (options?: { adapter?: any; log?: string[] }) => PrismaClientLike; +}; + +function loadPrismaClient(): PrismaModule["PrismaClient"] { + const mod = require("@prisma/client") as PrismaModule; + return mod.PrismaClient; +} + +function buildClient(url: string): PrismaClientLike { + const PrismaClient = loadPrismaClient(); + const adapter = new PrismaBetterSqlite3({ url }); + return new PrismaClient({ + adapter, + log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], + }); +} + +/** + * Resolve the database URL for a given region. + * + * Priority: + * 1. DATABASE_URL_ (e.g. DATABASE_URL_EU) + * 2. DATABASE_URL + * 3. file:./dev.db + */ +export function resolveDbUrl(region: Region): string { + const regionKey = `DATABASE_URL_${region.toUpperCase()}`; + return ( + process.env[regionKey] ?? + process.env.DATABASE_URL ?? + "file:./dev.db" + ); +} + +// Pool of Prisma clients, keyed by region +const clientPool = new Map(); + +/** + * Return (or lazily create) the Prisma client for the given region. + */ +export function getDbForRegion(region: Region): PrismaClientLike { + const existing = clientPool.get(region); + if (existing) return existing; + + const url = resolveDbUrl(region); + logger.info({ region, url: url.startsWith("file:") ? url : "" }, "Creating DB client for region"); + const client = buildClient(url); + clientPool.set(region, client); + return client; +} + +/** + * Warm up Prisma clients for all configured regions on startup. + * Only creates clients where a region-specific URL is configured. + */ +export function initializeRegionalDbs(): void { + for (const region of SUPPORTED_REGIONS) { + const regionKey = `DATABASE_URL_${region}`; + if (process.env[regionKey] || region === DEFAULT_REGION) { + getDbForRegion(region); + } + } + logger.info( + { defaultRegion: DEFAULT_REGION, configuredRegions: Array.from(clientPool.keys()) }, + "Regional DB pool initialized" + ); +} + +/** + * Search all configured regional DBs for a given API key. + * Returns the first match along with the resolved region, or null if not found. + * + * This is the bootstrap lookup: before we know a tenant's region we try every + * configured region in parallel so we always find the key regardless of which + * regional DB it lives in. + */ +export async function findApiKeyAcrossRegions( + apiKey: string +): Promise<{ record: any; region: Region } | null> { + const regions = SUPPORTED_REGIONS.filter( + (r) => process.env[`DATABASE_URL_${r}`] || r === DEFAULT_REGION + ); + + const results = await Promise.allSettled( + regions.map(async (region) => { + const db = getDbForRegion(region); + const record = await db.apiKey.findUnique({ + where: { key: apiKey }, + include: { tenant: { include: { subscriptionTier: true } } }, + }); + return record ? { record, region } : null; + }) + ); + + for (const result of results) { + if (result.status === "fulfilled" && result.value !== null) { + return result.value; + } + } + return null; +} + +/** + * Return true if a regional-specific DATABASE_URL is configured for the given region, + * meaning this region has its own isolated database. + */ +export function isRegionIsolated(region: Region): boolean { + return !!process.env[`DATABASE_URL_${region.toUpperCase()}`]; +} + +export function getConfiguredRegions(): Region[] { + return SUPPORTED_REGIONS.filter( + (r) => process.env[`DATABASE_URL_${r}`] || r === DEFAULT_REGION + ); +}