diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 7f0157011..3713c23a3 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -56,6 +56,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Generate Prisma client + run: npm run prisma:generate + + - name: Type-check backend + run: npm run build + - name: Validate Prisma schema and migration consistency run: npm run prisma:schema-check diff --git a/backend/openapi.json b/backend/openapi.json index ddaeadfbb..e84421cb3 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -193,6 +193,121 @@ "example": "99.5" } } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "timestamp", + "uptime", + "environment", + "checks" + ], + "properties": { + "status": { + "type": "string", + "example": "healthy" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uptime": { + "type": "number", + "example": 123.4 + }, + "environment": { + "type": "string", + "example": "production" + }, + "checks": { + "type": "object", + "required": [ + "api", + "cache", + "stellarRpc", + "databasePrimary", + "databaseReplica", + "prisma", + "jobs", + "indexer" + ], + "properties": { + "api": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "cache": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "stellarRpc": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "databasePrimary": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "databaseReplica": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "prisma": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "jobs": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + }, + "indexer": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] + } + } + } + } } } }, @@ -240,6 +355,9 @@ "description": "Service healthy", "content": { "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + }, "example": { "status": "healthy", "timestamp": "2024-01-01T00:00:00.000Z", @@ -248,7 +366,8 @@ "checks": { "api": "up", "cache": "up", - "stellarRpc": "up" + "stellarRpc": "up", + "indexer": "up" } } } diff --git a/backend/src/__tests__/cacheInvalidation.test.ts b/backend/src/__tests__/cacheInvalidation.test.ts new file mode 100644 index 000000000..f50f23bc4 --- /dev/null +++ b/backend/src/__tests__/cacheInvalidation.test.ts @@ -0,0 +1,17 @@ +import { registerInvalidationHook, triggerCacheInvalidation } from '../middleware/cache'; + +describe('triggerCacheInvalidation', () => { + it('skips non-array hook results without throwing', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + registerInvalidationHook( + (() => undefined) as unknown as (eventType: string, metadata?: Record) => string[], + ); + registerInvalidationHook( + (() => ['GET:/health']) as (eventType: string, metadata?: Record) => string[], + ); + + expect(() => triggerCacheInvalidation('test.event')).not.toThrow(); + expect(triggerCacheInvalidation('test.event').patternsInvalidated).toContain('GET:/health'); + errorSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/backend/src/__tests__/openapi.test.ts b/backend/src/__tests__/openapi.test.ts index 24309e4e0..2b02dc4e3 100644 --- a/backend/src/__tests__/openapi.test.ts +++ b/backend/src/__tests__/openapi.test.ts @@ -32,6 +32,25 @@ describe('OpenAPI documentation', () => { expect(spec.info.description).toMatch(/Rate limit/i); }); + it('documents the indexer health dependency', () => { + const health = spec.paths['/health'] as { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { $ref: string }; + }; + }; + }; + }; + }; + }; + expect(health.get.responses['200'].content['application/json'].schema.$ref).toBe( + '#/components/schemas/HealthResponse', + ); + }); + it('serves the spec and Swagger UI over HTTP', async () => { const app = express(); setupSwagger(app); diff --git a/backend/src/middleware/cache.ts b/backend/src/middleware/cache.ts index dd2487d34..45e383c2e 100644 --- a/backend/src/middleware/cache.ts +++ b/backend/src/middleware/cache.ts @@ -307,7 +307,10 @@ export function cacheMiddleware(options: CacheOptions) { // ── Invalidation ───────────────────────────────────────────────────────────── -type InvalidationHook = (eventType: string, metadata?: Record) => string[]; +type InvalidationHook = ( + eventType: string, + metadata?: Record, +) => string[] | Promise | Promise; const invalidationHooks: InvalidationHook[] = []; @@ -335,6 +338,23 @@ export function triggerCacheInvalidation( for (const hook of invalidationHooks) { try { const hookPatterns = hook(eventType, metadata); + if (hookPatterns instanceof Promise) { + void hookPatterns.catch((err) => { + console.error( + JSON.stringify({ + level: 'error', + event: 'invalidation_hook_error', + error: err instanceof Error ? err.message : String(err), + }), + ); + }); + continue; + } + + if (!Array.isArray(hookPatterns)) { + throw new TypeError('invalidation hook must return an array of patterns'); + } + if (!Array.isArray(hookPatterns)) { console.error( JSON.stringify({ diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index a8bd1466e..dc7e4bbf5 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -138,6 +138,30 @@ const options: swaggerJsdoc.Options = { shares: { type: 'string', example: '99.5' }, }, }, + HealthResponse: { + type: 'object', + required: ['status', 'timestamp', 'uptime', 'environment', 'checks'], + properties: { + status: { type: 'string', example: 'healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + uptime: { type: 'number', example: 123.4 }, + environment: { type: 'string', example: 'production' }, + checks: { + type: 'object', + required: ['api', 'cache', 'stellarRpc', 'databasePrimary', 'databaseReplica', 'prisma', 'jobs', 'indexer'], + properties: { + api: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + cache: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + stellarRpc: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + databasePrimary: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + databaseReplica: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + prisma: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + jobs: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + indexer: { type: 'string', enum: ['up', 'down', 'degraded', 'unknown'] }, + }, + }, + }, + }, }, }, tags: [ @@ -160,11 +184,13 @@ const options: swaggerJsdoc.Options = { description: 'Service healthy', content: { 'application/json': { + schema: { $ref: '#/components/schemas/HealthResponse' }, example: { status: 'healthy', 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/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 83c7db895..abbbdf8cb 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -717,7 +717,6 @@ router.get('/strategy/cooldown', cacheMiddleware({ ttl: 5000 }), (_req: Request, }); }); -router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), (_req: Request, res: Response) => { router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), validate({ body: VaultStrategyBodySchema }), (req: Request, res: Response) => { const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index d8443c4a5..761136c19 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1379,12 +1379,17 @@ impl YieldVault { let token = Self::token(env.clone()); let price_data = oracle_client.get_price(&token, &token); let max_age = Self::oracle_heartbeat(env.clone()); + let last: Option = env + .storage() + .instance() + .get(&DataKeyExt::Risk(RiskExtKey::LastPx)); let last_price = Self::last_oracle_price(&env); oracle::OracleValidator::validate_price_data( &env, &price_data, max_age, Some(oracle::MAX_PRICE_DEVIATION_BPS), + last.as_ref(), last_price.as_ref(), ) .map_err(|_| VaultError::OracleValidationFailed)?;