diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 20e8c2e41..e19d3f945 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -675,6 +675,8 @@ model WalletTenantAssociation { @@index([walletAddress, tenantId]) @@index([tenantId]) +} + model IdempotencyKey { id String @id @default(uuid()) keyId String diff --git a/backend/src/__tests__/idempotencyRetention.test.ts b/backend/src/__tests__/idempotencyRetention.test.ts index 6b06d5911..ed518d8ef 100644 --- a/backend/src/__tests__/idempotencyRetention.test.ts +++ b/backend/src/__tests__/idempotencyRetention.test.ts @@ -1,19 +1,67 @@ -import { IdempotencyStore } from '../idempotency'; +import { IdempotencyStore, idempotencyStore } from '../idempotency'; import { getIdempotencyRetentionMetrics, pruneStaleIdempotencyRecords, resetIdempotencyRetentionStateForTests, } from '../idempotencyRetention'; +// In-memory stand-in for the ioredis client; null means Redis is unavailable. +let mockRedis: FakeRedis | null = null; + jest.mock('../rateLimiter', () => ({ redisClientManager: { - isReady: () => false, - getClient: () => null, + isReady: () => mockRedis !== null, + getClient: () => mockRedis, }, })); +class FakeRedis { + readonly store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.store.set(key, value); + return 'OK'; + } + + async del(key: string): Promise { + return this.store.delete(key) ? 1 : 0; + } + + async ttl(): Promise { + return 3600; + } + + async scan(_cursor: string, _match: string, pattern: string): Promise<[string, string[]]> { + const prefix = pattern.replace(/\*$/, ''); + return ['0', [...this.store.keys()].filter((key) => key.startsWith(prefix))]; + } +} + +function entry(createdAt: string) { + return { + statusCode: 200, + body: { ok: true }, + fingerprint: 'fp', + metadata: { + createdAt, + lastAccessedAt: createdAt, + replayCount: 0, + status: 'completed', + }, + }; +} + +const stale = () => new Date(Date.now() - 10_000).toISOString(); +const fresh = () => new Date().toISOString(); + describe('idempotencyRetention', () => { beforeEach(() => { + mockRedis = null; + idempotencyStore.clear(); resetIdempotencyRetentionStateForTests(); process.env.IDEMPOTENCY_KEY_TTL_MS = '1000'; process.env.IDEMPOTENCY_RETENTION_ENABLED = 'true'; @@ -27,23 +75,13 @@ describe('idempotencyRetention', () => { it('prunes stale local idempotency keys', async () => { const store = new IdempotencyStore(1000); - const staleCreatedAt = new Date(Date.now() - 10_000).toISOString(); - - (store as any).localCache.set('stale-key', { - statusCode: 200, - body: { ok: true }, - fingerprint: 'fp', - metadata: { - createdAt: staleCreatedAt, - lastAccessedAt: staleCreatedAt, - replayCount: 0, - status: 'completed', - }, - }); + (store as any).localCache.set('stale-key', entry(stale())); const result = await store.pruneStaleKeys(1000, false); expect(result.localPruned).toBe(1); expect(result.pruned).toBe(1); + expect(result.dryRun).toBe(false); + expect(store.inspectKeys()).toHaveLength(0); }); it('supports dry-run retention sweeps', async () => { @@ -51,4 +89,62 @@ describe('idempotencyRetention', () => { expect(result.dryRun).toBe(true); expect(result.pruned).toBeGreaterThanOrEqual(0); }); + + describe('dry-run mode (Issue #1375)', () => { + it('store dry-run reports stale local keys without deleting them', async () => { + const store = new IdempotencyStore(1000); + (store as any).localCache.set('stale-key', entry(stale())); + (store as any).localCache.set('fresh-key', entry(fresh())); + const evictionsBefore = store.getMetrics().evictions; + + const result = await store.pruneStaleKeys(1000, true); + + expect(result).toEqual({ pruned: 1, localPruned: 1, redisPruned: 0, dryRun: true }); + expect(store.inspectKeys().map((k) => k.key).sort()).toEqual(['fresh-key', 'stale-key']); + expect(store.getMetrics().evictions).toBe(evictionsBefore); + }); + + it('store dry-run reports stale Redis keys without deleting them', async () => { + mockRedis = new FakeRedis(); + mockRedis.store.set('idempotency:stale-redis', JSON.stringify(entry(stale()))); + mockRedis.store.set('idempotency:fresh-redis', JSON.stringify(entry(fresh()))); + const store = new IdempotencyStore(1000); + + const dry = await store.pruneStaleKeys(1000, true); + expect(dry).toEqual({ pruned: 1, localPruned: 0, redisPruned: 1, dryRun: true }); + expect(mockRedis.store.has('idempotency:stale-redis')).toBe(true); + + const live = await store.pruneStaleKeys(1000, false); + expect(live).toEqual({ pruned: 1, localPruned: 0, redisPruned: 1, dryRun: false }); + expect(mockRedis.store.has('idempotency:stale-redis')).toBe(false); + expect(mockRedis.store.has('idempotency:fresh-redis')).toBe(true); + }); + + it('sweep dry-run leaves the store and sweep metrics untouched', async () => { + (idempotencyStore as any).localCache.set('stale-key', entry(stale())); + + const result = await pruneStaleIdempotencyRecords(true); + + expect(result).toEqual({ pruned: 1, localPruned: 1, redisPruned: 0, dryRun: true }); + expect(idempotencyStore.inspectKeys().map((k) => k.key)).toContain('stale-key'); + const metrics = getIdempotencyRetentionMetrics(); + expect(metrics.lastSweepAt).toBeNull(); + expect(metrics.totalPruned).toBe(0); + expect(metrics.lastPrunedCount).toBe(0); + }); + + it('a live sweep after a dry-run prunes the same keys and records metrics', async () => { + (idempotencyStore as any).localCache.set('stale-key', entry(stale())); + + const dry = await pruneStaleIdempotencyRecords(true); + const live = await pruneStaleIdempotencyRecords(false); + + expect(live).toEqual({ ...dry, dryRun: false }); + expect(idempotencyStore.inspectKeys()).toHaveLength(0); + const metrics = getIdempotencyRetentionMetrics(); + expect(metrics.lastSweepAt).not.toBeNull(); + expect(metrics.totalPruned).toBe(1); + expect(metrics.lastPrunedCount).toBe(1); + }); + }); }); diff --git a/backend/src/apiContractSnapshots.ts b/backend/src/apiContractSnapshots.ts index acaa02eb4..0c8afe47d 100644 --- a/backend/src/apiContractSnapshots.ts +++ b/backend/src/apiContractSnapshots.ts @@ -249,7 +249,7 @@ export function diffSchemaShapes( } for (const key of baselineRequired) { - if (!(key in baseline.properties ?? {})) { + if (!(key in (baseline.properties ?? {}))) { issues.push({ path: at(key), message: 'required field missing from snapshot properties (orphaned reference)' }); } if (!(key in currentProps)) { @@ -260,8 +260,9 @@ export function diffSchemaShapes( } for (const key of currentRequired) { - if (!(key in current.properties ?? {})) { + if (!(key in (current.properties ?? {}))) { issues.push({ path: at(key), message: 'required field missing from live schema properties (invalid schema)' }); + } if (!baselineRequired.has(key)) { issues.push({ path: at(key), message: 'field is now required — regenerate snapshots with npm run snapshots:write' }); } diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index 1ab4b0448..0c694a60b 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -22,7 +22,9 @@ */ import crypto from 'crypto'; +import NodeCache from 'node-cache'; import { prisma } from './prisma'; +import { redisClientManager } from './rateLimiter'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; @@ -419,3 +421,353 @@ export function startIdempotencyCleanupTask(intervalMs = 3600000): NodeJS.Timer }); }, intervalMs); } + +// ─── Response Store (Redis + NodeCache) ───────────────────────────────────── +// +// Shared response store used by vault endpoints and the transfer orchestrator. +// Completed responses are persisted to Redis (when available) with a NodeCache +// in-process fallback. Retention sweeps live in idempotencyRetention.ts. + +// ─── Public Types ───────────────────────────────────────────────────────────── + +export interface IdempotentOperationResult { + statusCode: number; + body: T; +} + +/** Metadata attached to every idempotency key entry. */ +export interface IdempotencyStoreKeyMetadata { + /** ISO-8601 timestamp when the key was first stored. */ + createdAt: string; + /** ISO-8601 timestamp of the most recent access (read or write). */ + lastAccessedAt: string; + /** Number of times this key has been replayed (returned cached result). */ + replayCount: number; + /** Current state of the entry. */ + status: 'pending' | 'completed'; +} + +/** Summary returned by GET /admin/idempotency/keys. */ +export interface IdempotencyKeyInfo { + key: string; + metadata: IdempotencyStoreKeyMetadata; +} + +/** Snapshot of store-wide observability counters. */ +export interface IdempotencyMetrics { + hits: number; + conflicts: number; + evictions: number; + activeKeys: number; + pendingKeys: number; +} + +// ─── Internal Types ─────────────────────────────────────────────────────────── + +interface StoredResponse extends IdempotentOperationResult { + fingerprint: string; + metadata: IdempotencyStoreKeyMetadata; +} + +interface PendingOperation { + fingerprint: string; + promise: Promise>; + metadata: IdempotencyStoreKeyMetadata; +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +export class IdempotencyConflictError extends Error { + constructor(message = 'Idempotency key already used for a different request body') { + super(message); + this.name = 'IdempotencyConflictError'; + } +} + +// ─── Redis key prefix ───────────────────────────────────────────────────────── + +const REDIS_PREFIX = 'idempotency:'; + +// ─── Store ──────────────────────────────────────────────────────────────────── + +export class IdempotencyStore { + /** Fallback in-process store used when Redis is unavailable. */ + private readonly localCache: NodeCache; + private readonly pendingResponses = new Map>(); + + // Observability counters + private _hits = 0; + private _conflicts = 0; + private _evictions = 0; + + constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { + const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); + this.localCache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); + this.localCache.on('expired', () => { this._evictions++; }); + } + + // ─── Redis helpers ───────────────────────────────────────────────────────── + + private redisKey(key: string): string { + return `${REDIS_PREFIX}${key}`; + } + + private get redis() { + const client = redisClientManager.getClient(); + return redisClientManager.isReady() && client ? client : null; + } + + private async redisGet(key: string): Promise | null> { + const r = this.redis; + if (!r) return null; + try { + const raw = await r.get(this.redisKey(key)); + return raw ? (JSON.parse(raw) as StoredResponse) : null; + } catch { + return null; + } + } + + private async redisSet(key: string, value: StoredResponse): Promise { + const r = this.redis; + if (!r) return; + try { + const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); + await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); + } catch (err) { + console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); + } + } + + private async redisDel(key: string): Promise { + const r = this.redis; + if (!r) return false; + try { + return (await r.del(this.redisKey(key))) > 0; + } catch { + return false; + } + } + + // ─── Core execute ────────────────────────────────────────────────────────── + + async execute( + key: string, + fingerprint: string, + operation: () => Promise> + ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { + const now = new Date().toISOString(); + + // 1. Check Redis first, then local cache + let completed = await this.redisGet(key); + if (!completed) { + completed = this.localCache.get>(key) ?? null; + } + + if (completed) { + if (completed.fingerprint !== fingerprint) { + this._conflicts++; + throw new IdempotencyConflictError(); + } + this._hits++; + completed.metadata.lastAccessedAt = now; + completed.metadata.replayCount++; + // Refresh in both backends; errors are non-fatal + await this.redisSet(key, completed); + this.localCache.set(key, completed); + return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; + } + + // 2. Currently in-flight + const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; + if (pendingOperation) { + if (pendingOperation.fingerprint !== fingerprint) { + this._conflicts++; + throw new IdempotencyConflictError(); + } + this._hits++; + pendingOperation.metadata.lastAccessedAt = now; + pendingOperation.metadata.replayCount++; + const replayed = await pendingOperation.promise; + return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; + } + + // 3. First execution + const metadata: IdempotencyStoreKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; + + const operationPromise = (async () => { + const result = await operation(); + const stored: StoredResponse = { + ...result, + fingerprint, + metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, + }; + // Persist to Redis (primary) and local cache (fallback/fast-path) + await this.redisSet(key, stored); + this.localCache.set(key, stored, this.ttlMs / 1000); + return stored; + })(); + + this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); + + try { + const stored = await operationPromise; + return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; + } finally { + this.pendingResponses.delete(key); + } + } + + // ─── Inspection ──────────────────────────────────────────────────────────── + + inspectKeys(prefix?: string): IdempotencyKeyInfo[] { + const results: IdempotencyKeyInfo[] = []; + for (const key of this.localCache.keys()) { + if (prefix && !key.startsWith(prefix)) continue; + const entry = this.localCache.get>(key); + if (entry) results.push({ key, metadata: { ...entry.metadata } }); + } + for (const [key, pending] of this.pendingResponses.entries()) { + if (prefix && !key.startsWith(prefix)) continue; + if (!results.some((r) => r.key === key)) { + results.push({ key, metadata: { ...pending.metadata } }); + } + } + return results; + } + + // ─── Targeted deletion ───────────────────────────────────────────────────── + + async deleteKey(key: string): Promise { + const deletedLocal = this.localCache.del(key) > 0; + const deletedPending = this.pendingResponses.delete(key); + const deletedRedis = await this.redisDel(key); + if (deletedLocal || deletedPending || deletedRedis) { + this._evictions++; + return true; + } + return false; + } + + // ─── Global clear (admin only) ───────────────────────────────────────────── + + clear(): void { + const count = this.localCache.keys().length + this.pendingResponses.size; + this._evictions += count; + this.localCache.flushAll(); + this.pendingResponses.clear(); + // Note: Redis keys are prefixed with REDIS_PREFIX; a full Redis FLUSHDB is intentionally + // not issued here to avoid clearing unrelated keys. Use deleteKey() per-key when needed. + } + + // ─── Observability ───────────────────────────────────────────────────────── + + getMetrics(): IdempotencyMetrics { + return { + hits: this._hits, + conflicts: this._conflicts, + evictions: this._evictions, + activeKeys: this.localCache.keys().length, + pendingKeys: this.pendingResponses.size, + }; + } + + // ─── Retention cleanup ───────────────────────────────────────────────────── + + /** + * Removes entries older than `retentionMs` from the local cache and Redis. + * With `dryRun`, only counts the entries that would be removed: nothing is + * deleted and the eviction counter is left untouched (Issue #1375). + */ + + async pruneStaleKeys( + retentionMs: number, + dryRun = false, + ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { + const cutoff = Date.now() - retentionMs; + let localPruned = 0; + let redisPruned = 0; + + for (const key of this.localCache.keys()) { + const entry = this.localCache.get>(key); + if (!entry) continue; + const createdAt = Date.parse(entry.metadata.createdAt); + if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; + if (!dryRun) { + this.localCache.del(key); + this._evictions++; + } + localPruned++; + } + + const r = this.redis; + if (r) { + let cursor = '0'; + do { + const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); + cursor = nextCursor; + for (const redisKey of keys) { + try { + const raw = await r.get(redisKey); + if (!raw) continue; + const entry = JSON.parse(raw) as StoredResponse; + const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); + const ttl = await r.ttl(redisKey); + const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; + if (!isStale) continue; + if (!dryRun) { + await r.del(redisKey); + this._evictions++; + } + redisPruned++; + } catch { + if (!dryRun) { + await r.del(redisKey); + this._evictions++; + } + redisPruned++; + } + } + } while (cursor !== '0'); + } + + return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; + } +} + +// ─── Singleton ──────────────────────────────────────────────────────────────── + +export const idempotencyStore = new IdempotencyStore( + parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) +); + +// ─── Fingerprint helper ─────────────────────────────────────────────────────── + +export function getIdempotencyHashThreshold(): number { + return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); +} + +export function buildIdempotencyFingerprint(payload: unknown): string { + const stable = stableStringify(payload); + const byteLength = Buffer.byteLength(stable, 'utf-8'); + if (byteLength > getIdempotencyHashThreshold()) { + return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; + } + return stable; +} + +function stableStringify(value: unknown): string { + if (value === null) return 'null'; + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (typeof value !== 'object') return JSON.stringify(value); + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${serialized.join(',')}}`; +} + diff --git a/backend/src/idempotencyRetention.ts b/backend/src/idempotencyRetention.ts index d89fa9f91..049d025f1 100644 --- a/backend/src/idempotencyRetention.ts +++ b/backend/src/idempotencyRetention.ts @@ -46,14 +46,34 @@ export function getIdempotencyRetentionMetrics(): IdempotencyRetentionMetrics { }; } +export interface IdempotencyRetentionSweepResult { + /** Total entries pruned (or, in dry-run mode, that would be pruned). */ + pruned: number; + localPruned: number; + redisPruned: number; + dryRun: boolean; +} + +/** + * Runs a retention sweep. With `dryRun`, reports what would be pruned without + * deleting entries or updating the sweep metrics, so it is safe to rehearse + * against production. + */ export async function pruneStaleIdempotencyRecords( dryRun = false, -): Promise<{ pruned: number; dryRun: boolean }> { +): Promise { const startedAt = Date.now(); const policy = getIdempotencyRetentionPolicy(); const result = await idempotencyStore.pruneStaleKeys(policy.retentionMs, dryRun); - if (!dryRun) { + if (dryRun) { + logger.log('info', 'Idempotency retention dry-run completed', { + wouldPrune: result.pruned, + localPruned: result.localPruned, + redisPruned: result.redisPruned, + retentionMs: policy.retentionMs, + }); + } else { retentionState.totalPruned += result.pruned; retentionState.lastPrunedCount = result.pruned; retentionState.lastSweepAt = new Date().toISOString(); @@ -68,7 +88,12 @@ export async function pruneStaleIdempotencyRecords( } } - return { pruned: result.pruned, dryRun }; + return { + pruned: result.pruned, + localPruned: result.localPruned, + redisPruned: result.redisPruned, + dryRun, + }; } export function startIdempotencyRetentionScheduler(): () => void { diff --git a/backend/src/schemaSnapshot.ts b/backend/src/schemaSnapshot.ts index fa2fca475..19a5f8882 100644 --- a/backend/src/schemaSnapshot.ts +++ b/backend/src/schemaSnapshot.ts @@ -74,7 +74,7 @@ export function extractSchemaFromZod(zodSchema: z.ZodType): SchemaDefinitio // Handle object schemas if (zodSchema instanceof z.ZodObject) { - const shape = (zodSchema as z.ZodObject)._shape; + const shape = (zodSchema as z.ZodObject).shape; const properties: Record = {}; const required: string[] = []; diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index dc7e4bbf5..684b510bc 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -190,7 +190,6 @@ const options: swaggerJsdoc.Options = { timestamp: '2024-01-01T00:00:00.000Z', uptime: 123.4, environment: 'production', - checks: { api: 'up', cache: 'up', stellarRpc: 'up', indexer: 'up' }, lastIndexedLedger: 12345678, checks: { api: 'up', cache: 'up', stellarRpc: 'up', databasePrimary: 'up', databaseReplica: 'up', prisma: 'up', jobs: 'up', indexer: 'up' }, sorobanCircuitBreaker: { state: 'closed', failures: 0, retryAfterMs: 0 }, diff --git a/backend/src/tests/idempotency.test.ts b/backend/src/tests/idempotency.test.ts index d8dad4958..1f62461e4 100644 --- a/backend/src/tests/idempotency.test.ts +++ b/backend/src/tests/idempotency.test.ts @@ -131,13 +131,13 @@ describe('Idempotency', () => { }); describe('enforceIdempotency middleware', () => { - function createMockRequest(overrides?: Partial): Partial { + function createMockRequest(overrides: Record = {}): Partial { return { get: (header: string) => undefined, tenantId: 'tenant-123', body: { amount: '1000' }, ...overrides, - }; + } as unknown as Partial; } function createMockResponse(): Partial { diff --git a/backend/src/tests/tenantBoundary.test.ts b/backend/src/tests/tenantBoundary.test.ts index c7477b131..7f2765ab6 100644 --- a/backend/src/tests/tenantBoundary.test.ts +++ b/backend/src/tests/tenantBoundary.test.ts @@ -26,7 +26,7 @@ function createMockRequest(overrides?: Partial): Partial { return undefined; }, ...overrides, - }; + } as unknown as Partial; } function createMockResponse(): Partial { diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index a7d784c66..a0cee0179 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -10,7 +10,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { resourceFromAttributes } from '@opentelemetry/resources'; +import { Resource } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; @@ -62,7 +62,7 @@ export function initTracing(): void { const exporter = new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }); sdk = new NodeSDK({ - resource: resourceFromAttributes({ + resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME, [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', }), diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index abbbdf8cb..7758ce0a5 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -3,7 +3,7 @@ import { emailService } from './emailService'; import { logger } from './middleware/structuredLogging'; import { allowlistMiddleware } from './middleware/allowlist'; import { triggerCacheInvalidation, registerInvalidationHook } from './middleware/cache'; -import { depositsLimiter, depositsUserLimiter } from './rateLimiter'; +import { depositsLimiter, depositsUserLimiter, readsLimiter } from './rateLimiter'; import { cacheMiddleware } from './middleware/cache'; import { idempotencyStore, @@ -728,7 +728,7 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val if (elapsed < cooldownSec) { const retryAfter = cooldownSec - elapsed; res.setHeader('Retry-After', String(retryAfter)); - return res.status(429).json({ + res.status(429).json({ error: 'Too Many Requests', status: 429, code: 'STRATEGY_COOLDOWN_ACTIVE', @@ -736,6 +736,7 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val cooldownRemaining: retryAfter, cooldownTotal: cooldownSec, }); + return; } } @@ -900,7 +901,7 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { const transactions = await prisma.transaction.findMany({ where, - orderBy: { createdAt: 'desc' }, + orderBy: { timestamp: 'desc' }, take: limit + 1, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); @@ -916,7 +917,7 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { status: tx.status, walletAddress: tx.user, explorerUrl: `${EXPLORER_BASE_URL}/${tx.id}`, - timestamp: tx.createdAt.toISOString(), + timestamp: tx.timestamp.toISOString(), })); res.status(200).json({