diff --git a/backend/schema-snapshots/get-_health.json b/backend/schema-snapshots/get-_health.json index 31d8f6b4..c62ee234 100644 --- a/backend/schema-snapshots/get-_health.json +++ b/backend/schema-snapshots/get-_health.json @@ -81,6 +81,15 @@ "degraded", "unknown" ] + }, + "indexer": { + "type": "string", + "enum": [ + "up", + "down", + "degraded", + "unknown" + ] } }, "required": [ @@ -90,7 +99,8 @@ "databasePrimary", "databaseReplica", "prisma", - "jobs" + "jobs", + "indexer" ], "additionalProperties": false }, @@ -125,4 +135,4 @@ "sorobanCircuitBreaker" ], "additionalProperties": false -} +} \ No newline at end of file diff --git a/backend/schema-snapshots/get-_ready.json b/backend/schema-snapshots/get-_ready.json index 6e39ca57..ce8460ef 100644 --- a/backend/schema-snapshots/get-_ready.json +++ b/backend/schema-snapshots/get-_ready.json @@ -21,13 +21,17 @@ }, "prisma": { "type": "boolean" + }, + "indexer": { + "type": "boolean" } }, "required": [ "cache", "stellarRpc", "database", - "prisma" + "prisma", + "indexer" ], "additionalProperties": false } @@ -38,4 +42,4 @@ "dependencies" ], "additionalProperties": false -} +} \ No newline at end of file diff --git a/backend/src/__tests__/issues711.test.ts b/backend/src/__tests__/issues711.test.ts index 6bdaa11d..05259d46 100644 --- a/backend/src/__tests__/issues711.test.ts +++ b/backend/src/__tests__/issues711.test.ts @@ -46,6 +46,24 @@ describe('#711 API contract schema snapshots', () => { expect(issues.some((issue) => issue.message === 'field removed')).toBe(true); }); + it('detects newly added required fields as breaking changes', () => { + const baseline = zodToJsonShape(HealthResponseSchema); + const current = JSON.parse(JSON.stringify(baseline)) as typeof baseline; + // Simulate an older snapshot that is missing the 'indexer' field + delete current.properties?.checks?.properties?.indexer; + current.properties!.checks!.required = (current.properties!.checks!.required ?? []).filter( + (k: string) => k !== 'indexer', + ); + + const issues = diffSchemaShapes(baseline, current, 'GET /health'); + expect( + issues.some( + (issue) => + issue.message.includes('new field added') || issue.message.includes('now required'), + ), + ).toBe(true); + }); + it('validates a conforming health payload', () => { const result = validateResponseAgainstSchema('GET /health', { status: 'healthy', @@ -61,6 +79,7 @@ describe('#711 API contract schema snapshots', () => { databaseReplica: 'up', prisma: 'up', jobs: 'up', + indexer: 'up', }, sorobanCircuitBreaker: { state: 'closed', diff --git a/backend/src/__tests__/openApiContractTests.test.ts b/backend/src/__tests__/openApiContractTests.test.ts index a280e737..ddc89dc2 100644 --- a/backend/src/__tests__/openApiContractTests.test.ts +++ b/backend/src/__tests__/openApiContractTests.test.ts @@ -76,7 +76,7 @@ describe('OpenAPI contract: GET /health', () => { it('checks object contains all required dependency keys', async () => { const res = await request(app).get('/health'); - const requiredKeys = ['api', 'cache', 'stellarRpc', 'databasePrimary', 'databaseReplica', 'prisma', 'jobs']; + const requiredKeys = ['api', 'cache', 'stellarRpc', 'databasePrimary', 'databaseReplica', 'prisma', 'jobs', 'indexer']; for (const key of requiredKeys) { expect(res.body.checks).toHaveProperty(key); expect(['up', 'down', 'degraded', 'unknown']).toContain(res.body.checks[key]); @@ -120,7 +120,7 @@ describe('OpenAPI contract: GET /ready', () => { it('dependencies object has boolean values for each key', async () => { const res = await request(app).get('/ready'); const deps = res.body.dependencies as Record; - const expectedKeys = ['cache', 'stellarRpc', 'database', 'prisma']; + const expectedKeys = ['cache', 'stellarRpc', 'database', 'prisma', 'indexer']; for (const key of expectedKeys) { expect(deps).toHaveProperty(key); expect(typeof deps[key]).toBe('boolean'); diff --git a/backend/src/apiContractSnapshots.ts b/backend/src/apiContractSnapshots.ts index bfedf90a..4c35e634 100644 --- a/backend/src/apiContractSnapshots.ts +++ b/backend/src/apiContractSnapshots.ts @@ -240,11 +240,24 @@ export function diffSchemaShapes( issues.push(...diffSchemaShapes(baselineProps[key], currentProps[key], childPath)); } + for (const key of Object.keys(currentProps)) { + if (!(key in baselineProps)) { + issues.push({ path: at(key), message: 'new field added — regenerate snapshots with npm run snapshots:write' }); + continue; + } + } + for (const key of baselineRequired) { if (!currentRequired.has(key)) { issues.push({ path: at(key), message: 'field is no longer required (may be breaking for strict clients)' }); } } + + for (const key of currentRequired) { + if (!baselineRequired.has(key)) { + issues.push({ path: at(key), message: 'field is now required — regenerate snapshots with npm run snapshots:write' }); + } + } } if (baseline.type === 'array' && current.type === 'array') { diff --git a/backend/src/index.ts b/backend/src/index.ts index b6361df6..9625f0c5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2045,9 +2045,6 @@ app.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: R */ app.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { const { walletAddress } = req.body; - if (!walletAddress || typeof walletAddress !== 'string') { - throw new ValidationError('Missing or invalid walletAddress in request body'); - } const added = addAddress(walletAddress); const actor = resolveActingAdminAddress(req); @@ -2084,12 +2081,14 @@ app.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalle */ app.delete('/admin/allowlist/remove', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { const { walletAddress } = req.body; - if (!walletAddress || typeof walletAddress !== 'string') { - throw new ValidationError('Missing or invalid walletAddress in request body'); - } const removed = removeAddress(walletAddress); if (!removed) { - throw new NotFoundError('Wallet address not found in allowlist'); + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Wallet address not found in allowlist', + }); + return; } const actor = resolveActingAdminAddress(req); @@ -2405,12 +2404,22 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: if (!wallet) { req.adminAuditAction = 'admin.impersonate.invalid'; - throw new ValidationError('wallet path parameter is required'); + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'wallet path parameter is required', + }); + return; } if (!hasRequiredApiKeyRole(req, 'super-admin')) { req.adminAuditAction = 'admin.impersonate.denied'; - throw new ForbiddenError('Super-admin role is required for impersonation'); + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation', + }); + return; } if (!sessionId && process.env.IMPERSONATION_SESSION_STORAGE) { @@ -2477,13 +2486,17 @@ app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: } : undefined, }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - throw new InternalError('Failed to build impersonated vault state'); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to build impersonated vault state', + }); } }); @@ -2571,12 +2584,22 @@ app.get('/admin/receipts/:id/verify', validateApiKey, async (req: Request, res: app.post('/admin/api-keys/register', validateApiKey, validate({ body: ApiKeyRegisterSchema }), async (req: Request, res: Response) => { const { key, role: requestedRole } = req.body; if (!key || typeof key !== 'string' || !key.trim()) { - throw new ValidationError('Missing key in request body'); + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Missing key in request body', + }); + return; } const role = normalizeApiKeyRole(requestedRole) || 'admin'; if (role === 'super-admin' && !hasRequiredApiKeyRole(req, 'super-admin')) { - throw new ForbiddenError('Super-admin role is required to register super-admin API keys'); + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to register super-admin API keys', + }); + return; } const normalizedKey = key.trim(); @@ -2865,7 +2888,12 @@ app.patch('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchem const endpoint = updateWebhookEndpoint(req.params.id, req.body || {}); if (!endpoint) { - throw new NotFoundError('Webhook endpoint not found'); + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found', + }); + return; } res.status(200).json({ diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index 754327bd..a8bd1466 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -165,7 +165,9 @@ const options: swaggerJsdoc.Options = { timestamp: '2024-01-01T00:00:00.000Z', uptime: 123.4, environment: 'production', - checks: { api: 'up', cache: 'up', stellarRpc: '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 }, }, }, },