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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions fluid-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
10 changes: 10 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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_...
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
1 change: 1 addition & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions server/src/handlers/feeBump.slippage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
8 changes: 8 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -135,6 +136,7 @@ async function initializeAuditLog() {
}

initializeAuditLog();
initializeRegionalDbs();

initializeOFACScreening();
const feeManager = initializeFeeManager(config);
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 20 additions & 14 deletions server/src/middleware/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -30,6 +36,7 @@ export interface ApiKeyConfig {
dailyQuotaStroops: number;
isSandbox: boolean;
allowedChains: Chain[];
region: Region;
}

function parseAllowedChains(raw?: string | null): Chain[] {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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();
}

Expand Down
4 changes: 4 additions & 0 deletions server/src/models/tenantStore.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
171 changes: 171 additions & 0 deletions server/src/services/regionRouter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading