From 185602ea335e68a7e7749138f4343a7bb95759ef Mon Sep 17 00:00:00 2001 From: Damola09 Date: Fri, 29 May 2026 12:41:39 +0100 Subject: [PATCH] feat(cache): add Redis caching for SEP-1 responses - Add src/services/sep1-info-cache.service.ts: - TTL: 5 min, stale-while-revalidate grace: 60 s - getOrCompute(): cache-aside with background refresh on stale hit - Graceful Redis failure: swallows errors, always falls back to computeFn - invalidate(): explicit cache bust for config/asset changes - Static accessors (cacheKey, ttlSeconds, staleGraceSeconds) for tests - Refactor info.controller.ts: - Extract buildStellarInfo() for reuse by cache compute function - Make getInfo() async; serve response via sep1InfoCache.getOrCompute() - Redis unavailability is non-fatal; endpoint always returns a response - Update info.controller.test.ts: await async getInfo() in all existing tests - Add sep1-info-cache.service.test.ts: 18 unit tests covering cold miss, fresh hit, stale-while-revalidate, full expiry, Redis failures (get/set/del), TTL write, and invalidation Fixes #368 --- .../api/controllers/info.controller.test.ts | 72 +++---- .../src/api/controllers/info.controller.ts | 42 +++- .../services/sep1-info-cache.service.test.ts | 204 ++++++++++++++++++ .../src/services/sep1-info-cache.service.ts | 160 ++++++++++++++ 4 files changed, 436 insertions(+), 42 deletions(-) create mode 100644 backend/src/services/sep1-info-cache.service.test.ts create mode 100644 backend/src/services/sep1-info-cache.service.ts diff --git a/backend/src/api/controllers/info.controller.test.ts b/backend/src/api/controllers/info.controller.test.ts index 6c505ca..1df09c1 100644 --- a/backend/src/api/controllers/info.controller.test.ts +++ b/backend/src/api/controllers/info.controller.test.ts @@ -63,11 +63,11 @@ describe('Info Controller', () => { }); describe('getInfo - JSON format', () => { - it('should return info in JSON format by default', () => { + it('should return info in JSON format by default', async () => { mockRequest.query = {}; mockRequest.headers = {}; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); expect(jsonMock).toHaveBeenCalled(); const response = jsonMock.mock.calls[0][0]; @@ -76,17 +76,17 @@ describe('Info Controller', () => { expect(response).toHaveProperty('assets'); }); - it('should return JSON when Accept header includes application/json', () => { + it('should return JSON when Accept header includes application/json', async () => { mockRequest.query = {}; mockRequest.headers = { accept: 'application/json' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); expect(jsonMock).toHaveBeenCalled(); }); - it('should include all assets in the response', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include all assets in the response', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.assets).toHaveLength(2); @@ -94,8 +94,8 @@ describe('Info Controller', () => { expect(response.assets[1].code).toBe('XLM'); }); - it('should include asset details with correct properties', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include asset details with correct properties', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; const usdcAsset = response.assets.find((a: any) => a.code === 'USDC'); @@ -109,8 +109,8 @@ describe('Info Controller', () => { expect(usdcAsset).toHaveProperty('fee_percent', 0.001); }); - it('should include fee variations for deposit and withdraw', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include fee variations for deposit and withdraw', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response).toHaveProperty('fee_variations'); @@ -118,34 +118,34 @@ describe('Info Controller', () => { expect(response.fee_variations).toHaveProperty('withdraw'); }); - it('should include only assets that support deposit in fee_variations.deposit', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include only assets that support deposit in fee_variations.deposit', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.fee_variations.deposit).toHaveProperty('USDC'); expect(response.fee_variations.deposit).toHaveProperty('XLM'); }); - it('should include only assets that support withdraw in fee_variations.withdraw', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include only assets that support withdraw in fee_variations.withdraw', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.fee_variations.withdraw).toHaveProperty('USDC'); expect(response.fee_variations.withdraw).not.toHaveProperty('XLM'); }); - it('should include accounts information', () => { - getInfo(mockRequest as Request, mockResponse as Response); + it('should include accounts information', async () => { + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.accounts).toHaveProperty('receiving'); }); - it('should use environment variables for server URLs', () => { + it('should use environment variables for server URLs', async () => { process.env.AUTH_SERVER = 'https://auth.example.com'; process.env.TRANSFER_SERVER = 'https://transfer.example.com'; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.auth_server).toBe('https://auth.example.com'); @@ -154,28 +154,28 @@ describe('Info Controller', () => { }); describe('getInfo - TOML format', () => { - it('should return TOML when format=toml query parameter is set', () => { + it('should return TOML when format=toml query parameter is set', async () => { mockRequest.query = { format: 'toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/toml'); expect(sendMock).toHaveBeenCalled(); }); - it('should return TOML when Accept header includes text/toml', () => { + it('should return TOML when Accept header includes text/toml', async () => { mockRequest.headers = { accept: 'text/toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/toml'); expect(sendMock).toHaveBeenCalled(); }); - it('should include required fields in TOML output', () => { + it('should include required fields in TOML output', async () => { mockRequest.query = { format: 'toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const tomlOutput = sendMock.mock.calls[0][0]; expect(tomlOutput).toContain('version ='); @@ -185,20 +185,20 @@ describe('Info Controller', () => { expect(tomlOutput).toContain('url = "http://localhost:3002"'); }); - it('should include accounts section in TOML output', () => { + it('should include accounts section in TOML output', async () => { mockRequest.query = { format: 'toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const tomlOutput = sendMock.mock.calls[0][0]; expect(tomlOutput).toContain('[accounts]'); expect(tomlOutput).toContain('receiving ='); }); - it('should include assets section in TOML output', () => { + it('should include assets section in TOML output', async () => { mockRequest.query = { format: 'toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const tomlOutput = sendMock.mock.calls[0][0]; expect(tomlOutput).toContain('[[assets]]'); @@ -206,10 +206,10 @@ describe('Info Controller', () => { expect(tomlOutput).toContain('code = "XLM"'); }); - it('should include fee variations section in TOML output', () => { + it('should include fee variations section in TOML output', async () => { mockRequest.query = { format: 'toml' }; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const tomlOutput = sendMock.mock.calls[0][0]; expect(tomlOutput).toContain('[fee_variations.deposit]'); @@ -218,30 +218,30 @@ describe('Info Controller', () => { }); describe('getInfo - environment variables', () => { - it('should use default values when environment variables are not set', () => { + it('should use default values when environment variables are not set', async () => { delete process.env.AUTH_SERVER; delete process.env.FEDERATION_SERVER; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.horizon_url).toBeDefined(); expect(response.signing_key).toBeDefined(); }); - it('should filter out undefined optional fields', () => { + it('should filter out undefined optional fields', async () => { delete process.env.FEDERATION_SERVER; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.federation_server).toBeUndefined(); }); - it('should use TRANSFER_SERVER_SEP24 environment variable or construct default', () => { + it('should use TRANSFER_SERVER_SEP24 environment variable or construct default', async () => { process.env.TRANSFER_SERVER_SEP24 = 'https://sep24.example.com'; - getInfo(mockRequest as Request, mockResponse as Response); + await getInfo(mockRequest as Request, mockResponse as Response); const response = jsonMock.mock.calls[0][0]; expect(response.transfer_server_sep24).toBe('https://sep24.example.com'); diff --git a/backend/src/api/controllers/info.controller.ts b/backend/src/api/controllers/info.controller.ts index ef00a08..b20405a 100644 --- a/backend/src/api/controllers/info.controller.ts +++ b/backend/src/api/controllers/info.controller.ts @@ -2,6 +2,8 @@ import { Request, Response } from 'express'; import { ASSETS, getIssuer } from '../../config/assets'; import { stellarService } from '../../services/stellar.service'; import { NETWORKS } from '../../config/networks'; +import { sep1InfoCache } from '../../services/sep1-info-cache.service'; +import logger from '../../utils/logger'; export interface StellarAsset { code: string; @@ -42,11 +44,12 @@ export interface StellarInfo { }; } -export const getInfo = (req: Request, res: Response): Response => { - const format = req.query.format as string; - const acceptHeader = req.headers.accept || ''; - const isToml = format === 'toml' || acceptHeader.includes('text/toml') || acceptHeader.includes('application/toml'); - +/** + * Builds the StellarInfo payload from environment variables and static asset + * configuration. Extracted so it can be called both from the HTTP handler + * and from the cache-aside compute function. + */ +function buildStellarInfo(): StellarInfo { const currentNetwork = stellarService.getNetwork(); const networkConfig = NETWORKS[currentNetwork]; @@ -57,7 +60,7 @@ export const getInfo = (req: Request, res: Response): Response => { .map(a => [a.code, { min_amount: a.minAmount, max_amount: a.maxAmount, fee_fixed: a.feeFixed, fee_percent: a.feePercent, fee_minimum: a.feeMinimum }]) ); - const stellarInfo: StellarInfo = { + return { version: '1.0.0', network: currentNetwork.toLowerCase(), federation_server: process.env.FEDERATION_SERVER, @@ -97,6 +100,33 @@ export const getInfo = (req: Request, res: Response): Response => { withdraw: feeVariationEntries('withdraw'), }, }; +} + +/** + * GET /.well-known/stellar.toml / GET /info + * + * Returns the SEP-1 anchor info payload. The response is served from a + * Redis-backed cache (TTL: 5 min, stale-while-revalidate: 60 s) so that + * high-frequency polling clients do not cause repeated env-var reads or + * CPU overhead. Redis unavailability is handled gracefully — the endpoint + * falls back to computing the payload fresh on every request. + */ +export const getInfo = async (req: Request, res: Response): Promise => { + const format = req.query.format as string; + const acceptHeader = req.headers.accept || ''; + const isToml = format === 'toml' || acceptHeader.includes('text/toml') || acceptHeader.includes('application/toml'); + + let stellarInfo: StellarInfo; + try { + stellarInfo = await sep1InfoCache.getOrCompute(buildStellarInfo) as StellarInfo; + } catch (err) { + // getOrCompute already swallows Redis errors; this catches buildStellarInfo + // failures (e.g., missing SIGNING_KEY) and lets the error propagate normally. + logger.error('SEP-1 info generation failed', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + throw err; + } const filteredInfo = Object.fromEntries( Object.entries(stellarInfo).filter(([, v]) => v !== undefined) diff --git a/backend/src/services/sep1-info-cache.service.test.ts b/backend/src/services/sep1-info-cache.service.test.ts new file mode 100644 index 0000000..cf70f3e --- /dev/null +++ b/backend/src/services/sep1-info-cache.service.test.ts @@ -0,0 +1,204 @@ +import { Sep1InfoCacheService } from './sep1-info-cache.service'; + +// Minimal fake Redis client used across all tests +function makeRedis(overrides: Partial> = {}) { + return makeFakeRedis(overrides); +} + +function makeFakeRedis(overrides: Partial<{ + get: jest.Mock; + set: jest.Mock; + del: jest.Mock; + expire: jest.Mock; +}> = {}) { + return { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + del: jest.fn().mockResolvedValue(1), + expire: jest.fn().mockResolvedValue(1), + ...overrides, + }; +} + +type InfoPayload = { version: string; network: string }; + +function makePayload(network = 'testnet'): InfoPayload { + return { version: '1.0.0', network }; +} + +describe('Sep1InfoCacheService', () => { + let redis: ReturnType; + let cache: Sep1InfoCacheService; + + beforeEach(() => { + jest.clearAllMocks(); + redis = makeRedis(); + cache = new Sep1InfoCacheService(redis as any); + }); + + // ── getOrCompute ──────────────────────────────────────────────────────────── + + describe('getOrCompute', () => { + it('calls computeFn and caches the result on a cold miss', async () => { + const compute = jest.fn().mockReturnValue(makePayload()); + + const result = await cache.getOrCompute(compute); + + expect(compute).toHaveBeenCalledTimes(1); + expect(result.network).toBe('testnet'); + // Should have written to Redis + expect(redis.set).toHaveBeenCalled(); + }); + + it('returns the cached value without calling computeFn on a fresh hit', async () => { + const entry = { value: makePayload('mainnet'), cachedAt: Date.now() }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + + const compute = jest.fn().mockReturnValue(makePayload()); + + const result = await cache.getOrCompute(compute); + + expect(compute).not.toHaveBeenCalled(); + expect(result.network).toBe('mainnet'); + }); + + it('serves stale entry and schedules a background refresh', async () => { + // cachedAt is just past TTL but within grace window + const pastTtl = Date.now() - (Sep1InfoCacheService.ttlSeconds + 30) * 1000; + const entry = { value: makePayload('stale-net'), cachedAt: pastTtl }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + + const compute = jest.fn().mockReturnValue(makePayload('fresh-net')); + + const result = await cache.getOrCompute(compute); + + // Stale value returned immediately + expect(result.network).toBe('stale-net'); + // computeFn is NOT called synchronously + expect(compute).not.toHaveBeenCalled(); + }); + + it('recomputes when the entry is fully expired (past TTL + grace)', async () => { + const fullyExpired = Date.now() - + (Sep1InfoCacheService.ttlSeconds + Sep1InfoCacheService.staleGraceSeconds + 10) * 1000; + const entry = { value: makePayload('expired-net'), cachedAt: fullyExpired }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + + const compute = jest.fn().mockReturnValue(makePayload('new-net')); + + const result = await cache.getOrCompute(compute); + + expect(compute).toHaveBeenCalledTimes(1); + expect(result.network).toBe('new-net'); + }); + + it('falls back to computeFn when Redis throws', async () => { + redis.get.mockRejectedValue(new Error('Redis connection refused')); + const compute = jest.fn().mockReturnValue(makePayload('fallback-net')); + + const result = await cache.getOrCompute(compute); + + expect(compute).toHaveBeenCalledTimes(1); + expect(result.network).toBe('fallback-net'); + }); + + it('does not throw when Redis write fails after a cache miss', async () => { + redis.set.mockRejectedValue(new Error('Redis write error')); + const compute = jest.fn().mockReturnValue(makePayload()); + + await expect(cache.getOrCompute(compute)).resolves.not.toThrow(); + }); + }); + + // ── get ───────────────────────────────────────────────────────────────────── + + describe('get', () => { + it('returns null on a cold cache', async () => { + expect(await cache.get()).toBeNull(); + }); + + it('returns the value when entry is within TTL', async () => { + const entry = { value: makePayload(), cachedAt: Date.now() }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + expect(await cache.get()).not.toBeNull(); + }); + + it('returns null when entry is fully expired past grace window', async () => { + const fullyExpired = Date.now() - + (Sep1InfoCacheService.ttlSeconds + Sep1InfoCacheService.staleGraceSeconds + 5) * 1000; + const entry = { value: makePayload(), cachedAt: fullyExpired }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + expect(await cache.get()).toBeNull(); + }); + + it('returns null when Redis throws', async () => { + redis.get.mockRejectedValue(new Error('Redis down')); + expect(await cache.get()).toBeNull(); + }); + }); + + // ── isStale ───────────────────────────────────────────────────────────────── + + describe('isStale', () => { + it('returns false on a cold cache', async () => { + expect(await cache.isStale()).toBe(false); + }); + + it('returns false when entry is still fresh', async () => { + const entry = { value: makePayload(), cachedAt: Date.now() }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + expect(await cache.isStale()).toBe(false); + }); + + it('returns true when entry is past TTL but within grace window', async () => { + const pastTtl = Date.now() - (Sep1InfoCacheService.ttlSeconds + 10) * 1000; + const entry = { value: makePayload(), cachedAt: pastTtl }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + expect(await cache.isStale()).toBe(true); + }); + + it('returns false when entry is fully expired past grace window', async () => { + const fullyExpired = Date.now() - + (Sep1InfoCacheService.ttlSeconds + Sep1InfoCacheService.staleGraceSeconds + 5) * 1000; + const entry = { value: makePayload(), cachedAt: fullyExpired }; + redis.get.mockResolvedValue(JSON.stringify(entry)); + expect(await cache.isStale()).toBe(false); + }); + }); + + // ── set ───────────────────────────────────────────────────────────────────── + + describe('set', () => { + it('writes a CacheEntry to Redis with an extended TTL', async () => { + await cache.set(makePayload()); + + expect(redis.set).toHaveBeenCalledWith( + Sep1InfoCacheService.cacheKey, + expect.stringContaining('"version":"1.0.0"') + ); + expect(redis.expire).toHaveBeenCalledWith( + Sep1InfoCacheService.cacheKey, + Sep1InfoCacheService.ttlSeconds + Sep1InfoCacheService.staleGraceSeconds + ); + }); + + it('does not throw when Redis write fails', async () => { + redis.set.mockRejectedValue(new Error('disk full')); + await expect(cache.set(makePayload())).resolves.toBeUndefined(); + }); + }); + + // ── invalidate ─────────────────────────────────────────────────────────────── + + describe('invalidate', () => { + it('deletes the cache key', async () => { + await cache.invalidate(); + expect(redis.del).toHaveBeenCalledWith(Sep1InfoCacheService.cacheKey); + }); + + it('does not throw when Redis delete fails', async () => { + redis.del.mockRejectedValue(new Error('Redis down')); + await expect(cache.invalidate()).resolves.toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/services/sep1-info-cache.service.ts b/backend/src/services/sep1-info-cache.service.ts new file mode 100644 index 0000000..8e0ab1d --- /dev/null +++ b/backend/src/services/sep1-info-cache.service.ts @@ -0,0 +1,160 @@ +import { redis } from '../lib/redis'; +import { RedisService } from './redis.service'; +import logger from '../utils/logger'; + +/** + * Cache key used for the SEP-1 info / stellar.toml response. + * The version suffix makes it trivial to invalidate all cached entries + * globally when the schema changes (bump the version). + */ +const CACHE_KEY = 'sep1:info:v1'; + +/** + * How long (in seconds) a cached SEP-1 response is considered fresh. + * SEP-1 data is derived from environment variables and static asset + * configuration, so a 5-minute TTL balances freshness with Redis load. + */ +const TTL_SECONDS = 300; + +/** + * Additional grace window (in seconds) during which a stale entry is + * served while the cache is being refreshed in the background. + * This prevents a thundering-herd problem when the TTL expires under load. + */ +const STALE_GRACE_SECONDS = 60; + +interface CacheEntry { + value: T; + cachedAt: number; +} + +export class Sep1InfoCacheService { + private readonly redisService: RedisService; + + constructor(redisClient = redis) { + this.redisService = new RedisService(redisClient); + } + + /** + * Returns the raw CacheEntry from Redis, or null on miss / Redis error. + */ + private async getCacheEntry(): Promise | null> { + try { + return await this.redisService.getJSON>(CACHE_KEY); + } catch (err) { + logger.warn('SEP-1 info cache read failed, falling back to fresh generation', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + return null; + } + } + + /** + * Cache-aside helper: returns a cached value when fresh, triggers a + * background refresh on stale, or calls `computeFn` on full miss. + * + * Redis errors are swallowed — `computeFn` is always the safe fallback. + */ + async getOrCompute(computeFn: () => T): Promise { + const entry = await this.getCacheEntry(); + + if (entry) { + const ageSeconds = (Date.now() - entry.cachedAt) / 1000; + + if (ageSeconds <= TTL_SECONDS) { + logger.debug('SEP-1 info cache hit', { ageSeconds: Math.round(ageSeconds) }); + return entry.value; + } + + if (ageSeconds <= TTL_SECONDS + STALE_GRACE_SECONDS) { + // Serve stale immediately, refresh in background to avoid latency spike. + logger.debug('SEP-1 info stale-while-revalidate', { ageSeconds: Math.round(ageSeconds) }); + setImmediate(() => { + Promise.resolve() + .then(() => this.set(computeFn())) + .catch(err => { + logger.warn('SEP-1 background cache refresh failed', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + }); + }); + return entry.value; + } + } + + // Full cache miss or expired past grace window — compute synchronously. + const value = computeFn(); + await this.set(value); + return value; + } + + /** + * Returns a cached SEP-1 info payload when one exists and is still fresh. + * Returns null on cache miss or when Redis is unavailable. + */ + async get(): Promise { + const entry = await this.getCacheEntry(); + if (!entry) return null; + const ageSeconds = (Date.now() - entry.cachedAt) / 1000; + if (ageSeconds <= TTL_SECONDS + STALE_GRACE_SECONDS) return entry.value; + return null; + } + + /** + * Returns true when the cached entry exists but has passed its TTL. + */ + async isStale(): Promise { + const entry = await this.getCacheEntry(); + if (!entry) return false; + const ageSeconds = (Date.now() - entry.cachedAt) / 1000; + return ageSeconds > TTL_SECONDS && ageSeconds <= TTL_SECONDS + STALE_GRACE_SECONDS; + } + + /** + * Stores a fresh SEP-1 info payload. + * TTL is set to TTL + grace so Redis evicts entries that are fully expired. + */ + async set(value: T): Promise { + try { + const entry: CacheEntry = { value, cachedAt: Date.now() }; + await this.redisService.setJSON(CACHE_KEY, entry, TTL_SECONDS + STALE_GRACE_SECONDS); + logger.debug('SEP-1 info cache written', { ttlSeconds: TTL_SECONDS }); + } catch (err) { + // Cache write failures are non-fatal — the response was already computed. + logger.warn('SEP-1 info cache write failed', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + /** + * Removes the cached entry, forcing the next request to recompute from source. + * Call this whenever environment configuration or asset definitions change. + */ + async invalidate(): Promise { + try { + await this.redisService.del(CACHE_KEY); + logger.info('SEP-1 info cache invalidated'); + } catch (err) { + logger.warn('SEP-1 info cache invalidation failed', { + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + /** Exposed for testing. */ + static get cacheKey(): string { + return CACHE_KEY; + } + + static get ttlSeconds(): number { + return TTL_SECONDS; + } + + static get staleGraceSeconds(): number { + return STALE_GRACE_SECONDS; + } +} + +/** Singleton used by the info controller. */ +export const sep1InfoCache = new Sep1InfoCacheService();