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
266 changes: 266 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1428,3 +1428,269 @@ enum UserOpStatus {
completed
failed
}

// ─── Issue #470: Atomic Swap / HTLC Models ────────────────────────────────────

enum AtomicSwapStatus {
pending
claimed
refunded
disputed
}

model AtomicSwap {
id String @id @default(uuid())
tenantId String @map("tenant_id")
swapId BigInt @unique @map("swap_id")
sender String
receiver String
tokenA String @map("token_a")
tokenB String @map("token_b")
amountA Decimal @map("amount_a") @db.Decimal(20, 8)
amountB Decimal @map("amount_b") @db.Decimal(20, 8)
hashlock String
timelock BigInt @map("timelock")
disputeDeadline BigInt? @map("dispute_deadline")
status AtomicSwapStatus @default(pending)
feeBps Int @default(30) @map("fee_bps")
feeCollector String? @map("fee_collector")
contractAddress String? @map("contract_address")
network String @default("soroban")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")

secret AtomicSwapSecret?

@@index([tenantId])
@@index([sender])
@@index([receiver])
@@index([status])
@@map("atomic_swaps")
}

model AtomicSwapSecret {
id String @id @default(uuid())
swapId String @unique @map("swap_id")
preimage String
revealedAt DateTime @default(now()) @map("revealed_at")

swap AtomicSwap @relation(fields: [swapId], references: [id], onDelete: Cascade)

@@map("atomic_swap_secrets")
}

// ─── Issue #469: Multi-Signature Treasury Models ─────────────────────────────

enum TreasuryProposalStatus {
pending
approved
executed
rejected
cancelled
expired
}

model TreasuryProposal {
id String @id @default(uuid())
tenantId String @map("tenant_id")
proposalId BigInt @map("proposal_id")
proposer String
description String
target String
amount Decimal @db.Decimal(20, 8)
token String?
calldata String?
status TreasuryProposalStatus @default(pending)
approvalCount Int @default(0) @map("approval_count")
rejectionCount Int @default(0) @map("rejection_count")
threshold Int @default(1)
timelockDelay BigInt @map("timelock_delay")
executeAfter DateTime @map("execute_after")
createdAt DateTime @default(now()) @map("created_at")
executedAt DateTime? @map("executed_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")

approvals TreasuryApproval[]
execution TreasuryExecution?

@@index([tenantId])
@@index([status])
@@index([tenantId, status])
@@map("treasury_proposals")
}

model TreasuryApproval {
id String @id @default(uuid())
proposalId String @map("proposal_id")
signer String
approved Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")

proposal TreasuryProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)

@@unique([proposalId, signer])
@@index([proposalId])
@@map("treasury_approvals")
}

model TreasuryExecution {
id String @id @default(uuid())
proposalId String @unique @map("proposal_id")
txHash String? @map("tx_hash")
executedBy String @map("executed_by")
executedAt DateTime @default(now()) @map("executed_at")
error String?
chain String @default("soroban")

proposal TreasuryProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)

@@map("treasury_executions")
}

// ─── Issue #471: API Key Usage Analytics and Quota Models ─────────────────────

model ApiKey {
id String @id @default(uuid())
tenantId String @map("tenant_id")
keyId String @unique @map("key_id")
description String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")

usage ApiKeyUsage[]
quota ApiKeyQuota?

@@index([tenantId])
@@index([keyId])
@@map("api_keys")
}

model ApiKeyUsage {
id String @id @default(uuid())
tenantId String @map("tenant_id")
keyId String @map("key_id")
endpoint String
method String @default("GET")
statusCode Int @map("status_code")
latencyMs Int @map("latency_ms")
payloadSize Int? @map("payload_size")
ipAddress String? @map("ip_address")
recordedAt DateTime @default(now()) @map("recorded_at")

key ApiKey @relation(fields: [keyId], references: [id], onDelete: Cascade)

@@index([keyId])
@@index([tenantId, recordedAt])
@@index([keyId, recordedAt])
@@index([recordedAt])
@@map("api_key_usage")
}

model ApiKeyQuota {
id String @id @default(uuid())
keyId String @unique @map("key_id")
requestsPerHour Int @default(1000) @map("requests_per_hour")
requestsPerDay Int @default(10000) @map("requests_per_day")
alertAt50Pct Boolean @default(true) @map("alert_at_50_pct")
alertAt80Pct Boolean @default(true) @map("alert_at_80_pct")
alertAt100Pct Boolean @default(true) @map("alert_at_100_pct")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

key ApiKey @relation(fields: [keyId], references: [id], onDelete: Cascade)

@@map("api_key_quotas")
}

// ─── Issue #472: Custom Report Builder Models ────────────────────────────────

enum ReportChartType {
line
bar
pie
table
heatmap
area
}

enum ReportScheduleFrequency {
daily
weekly
monthly
}

model SavedReport {
id String @id @default(uuid())
tenantId String @map("tenant_id")
userId String? @map("user_id")
name String
description String?
metrics String[]
dimensions String[]
filters Json?
chartType ReportChartType @default(line)
dateRange Json?
isFavorite Boolean @default(false) @map("is_favorite")
isPinned Boolean @default(false) @map("is_pinned")
config Json?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")

schedule ScheduledReport?
template ReportTemplate?

@@index([tenantId])
@@index([userId])
@@index([isFavorite])
@@index([tenantId, updatedAt])
@@map("saved_reports")
}

model ScheduledReport {
id String @id @default(uuid())
reportId String @unique @map("report_id")
frequency ReportScheduleFrequency
dayOfWeek Int? @default(1) @map("day_of_week")
dayOfMonth Int? @default(1) @map("day_of_month")
time String @default("09:00")
recipients String[]
lastSentAt DateTime? @map("last_sent_at")
nextSendAt DateTime? @map("next_send_at")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade)

@@index([isActive])
@@index([nextSendAt])
@@map("scheduled_reports")
}

model ReportTemplate {
id String @id @default(uuid())
reportId String @unique @map("report_id")
name String
description String?
metrics String[]
dimensions String[]
filters Json?
chartType ReportChartType @default(line)
dateRange Json?
config Json?
isPublic Boolean @default(false) @map("is_public")
usageCount Int @default(0) @map("usage_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

report SavedReport @relation(fields: [reportId], references: [id], onDelete: Cascade)

@@index([isPublic, usageCount])
@@map("report_templates")
}
17 changes: 17 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ import { gasRouter } from './routes/gas.js';
import { vaultsRouter } from './routes/vaults.js';
import { createConnectionManager } from './websocket/connection-manager.js';
import { getBridgeMonitorService } from './services/bridge-monitor/bridge-monitor.js';
import { swapsRouter } from './routes/swaps.js';
import { treasuryRouter } from './routes/treasury.js';
import { apiKeysRouter } from './routes/api-keys.js';
import { reportsRouter } from './routes/reports.js';
import { apiUsageTracker, checkQuota } from './middleware/api-usage-tracker.js';

// Validate environment variables at startup
validateEnv();
Expand Down Expand Up @@ -381,6 +386,18 @@ app.use('/api/v1/auth/sessions', sessionsRouter);
// HMAC signing key management — Issue #510
app.use('/api/v1/developers/signing-keys', signingKeysRouter);

// API key usage tracking and quota enforcement — Issue #471
app.use('/api/v1/developers/api-keys', apiUsageTracker, apiKeysRouter);

// Atomic swaps / HTLC management — Issue #470
app.use('/api/v1/swaps', swapsRouter);

// Multi-signature timelock treasury management — Issue #469
app.use('/api/v1/treasury', treasuryRouter);

// Custom report builder with saved templates — Issue #472
app.use('/api/v1/reports', reportsRouter);

// Sandbox environment for testing (with relaxed rate limits)
const sandboxRouter = createSandboxRouter(getSandboxManager(), getMockPaymentProcessor(), getTestDataSeeder());
app.use('/api/v1/sandbox', sandboxRateLimiter, sandboxRouter);
Expand Down
93 changes: 93 additions & 0 deletions backend/src/middleware/api-usage-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../lib/prisma.js';

const requestCounts = new Map<string, { hourly: number[]; daily: number[] }>();

function getWindowKey(keyId: string, windowMs: number): string {
return `${keyId}:${Math.floor(Date.now() / windowMs)}`;
}

export async function apiUsageTracker(req: Request, res: Response, next: NextFunction) {
const startTime = Date.now();
const tenantId = (req.headers['x-tenant-id'] as string) ?? 'default';
const keyId = (req.headers['x-api-key'] as string) ?? 'anonymous';
const endpoint = req.originalUrl ?? req.path;
const method = req.method;

res.on('finish', async () => {
const latencyMs = Date.now() - startTime;
const statusCode = res.statusCode;
const payloadSize = parseInt(res.getHeader('content-length') as string) || 0;

try {
await prisma.apiKeyUsage.create({
data: {
tenantId,
keyId,
endpoint,
method,
statusCode,
latencyMs,
payloadSize,
ipAddress: req.ip,
},
});
} catch (err) {
console.warn('[API Usage Tracker] Failed to record usage:', (err as Error).message);
}
});

next();
}

export function checkQuota(req: Request, res: Response, next: NextFunction) {
const keyId = (req.headers['x-api-key'] as string) ?? 'anonymous';
const hourWindow = 3600_000;
const dayWindow = 86400_000;

const hourKey = getWindowKey(keyId, hourWindow);
const dayKey = getWindowKey(keyId, dayWindow);

if (!requestCounts.has(keyId)) {
requestCounts.set(keyId, { hourly: [], daily: [] });
}
const counts = requestCounts.get(keyId)!;

counts.hourly.push(Date.now());
counts.daily.push(Date.now());

const hourAgo = Date.now() - hourWindow;
const dayAgo = Date.now() - dayWindow;

counts.hourly = counts.hourly.filter((t) => t > hourAgo);
counts.daily = counts.daily.filter((t) => t > dayAgo);

const hourlyCount = counts.hourly.length;
const dailyCount = counts.daily.length;

const hourlyLimit = parseInt(req.headers['x-rate-limit-hourly'] as string) || 1000;
const dailyLimit = parseInt(req.headers['x-rate-limit-daily'] as string) || 10000;

if (hourlyCount > hourlyLimit) {
res.setHeader('Retry-After', '3600');
res.status(429).json({
error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Hourly quota exceeded', retryAfter: 3600 },
});
return;
}

if (dailyCount > dailyLimit) {
res.setHeader('Retry-After', '86400');
res.status(429).json({
error: { code: 'DAILY_LIMIT_EXCEEDED', message: 'Daily quota exceeded', retryAfter: 86400 },
});
return;
}

res.setHeader('X-RateLimit-Hourly-Remaining', String(hourlyLimit - hourlyCount));
res.setHeader('X-RateLimit-Daily-Remaining', String(dailyLimit - dailyCount));
res.setHeader('X-RateLimit-Hourly-Limit', String(hourlyLimit));
res.setHeader('X-RateLimit-Daily-Limit', String(dailyLimit));

next();
}
Loading
Loading