Skip to content
Open
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
14 changes: 12 additions & 2 deletions backend/schema-snapshots/get-_health.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
"degraded",
"unknown"
]
},
"indexer": {
"type": "string",
"enum": [
"up",
"down",
"degraded",
"unknown"
]
}
},
"required": [
Expand All @@ -90,7 +99,8 @@
"databasePrimary",
"databaseReplica",
"prisma",
"jobs"
"jobs",
"indexer"
],
"additionalProperties": false
},
Expand Down Expand Up @@ -125,4 +135,4 @@
"sorobanCircuitBreaker"
],
"additionalProperties": false
}
}
8 changes: 6 additions & 2 deletions backend/schema-snapshots/get-_ready.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@
},
"prisma": {
"type": "boolean"
},
"indexer": {
"type": "boolean"
}
},
"required": [
"cache",
"stellarRpc",
"database",
"prisma"
"prisma",
"indexer"
],
"additionalProperties": false
}
Expand All @@ -38,4 +42,4 @@
"dependencies"
],
"additionalProperties": false
}
}
19 changes: 19 additions & 0 deletions backend/src/__tests__/issues711.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -61,6 +79,7 @@ describe('#711 API contract schema snapshots', () => {
databaseReplica: 'up',
prisma: 'up',
jobs: 'up',
indexer: 'up',
},
sorobanCircuitBreaker: {
state: 'closed',
Expand Down
4 changes: 2 additions & 2 deletions backend/src/__tests__/openApiContractTests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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<string, unknown>;
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');
Expand Down
13 changes: 13 additions & 0 deletions backend/src/apiContractSnapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
66 changes: 47 additions & 19 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
});
}
});

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion backend/src/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
},
Expand Down
Loading