Skip to content
Merged
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
28 changes: 28 additions & 0 deletions src/metrics/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,39 @@ export function resetAdminMetrics(): void {
adminDuration.reset();
}

const apisLatencyDuration = new client.Histogram({
name: 'apis_request_duration_seconds',
help: 'Latency of /api/apis requests in seconds (FWC26 #893)',
labelNames: ['route', 'method', 'status_code'],
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

export function recordApisLatency(
method: string,
statusCode: number,
durationMs: number,
): void {
apisLatencyDuration.observe(
{
route: '/api/apis',
method: method.toUpperCase(),
status_code: String(statusCode),
},
durationMs / 1000,
);
}

export function resetApisMetrics(): void {
apisLatencyDuration.reset();
}

export {
billingDeductDuration,
refreshTokenDuration,
maintenanceDuration,
creditsDuration,
adminDuration,
subscriptionsLatencyDuration,
apisLatencyDuration,
};

43 changes: 43 additions & 0 deletions src/middleware/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,46 @@ export function createExportsCorsMiddleware(): (
middleware(req, res, next);
};
}

/**
* Apis-route CORS middleware factory.
*
* Reads the `APIS_CORS_ALLOWED_ORIGINS` environment variable on first
* use (lazy) so unit tests that mutate `process.env` after module load
* continue to work. The factory returns the same middleware instance on
* every subsequent request to avoid re-parsing the allowlist per request;
* to pick up runtime changes the operator must restart the process.
*
* Defaults to:
* - `allowCredentials: true` — authenticated POST/PATCH endpoints under /api/apis.
* - `maxAgeSeconds: 600` — 10 minute preflight cache.
*
* If `APIS_CORS_ALLOWED_ORIGINS` is unset/empty every cross-origin
* request to the apis route is denied (deny by default).
*/
export function createApisCorsMiddleware(): (
req: Request,
res: Response,
next: NextFunction,
) => void {
let middleware: ReturnType<typeof createCorsAllowlistMiddleware> | null =
null;

return (req: Request, res: Response, next: NextFunction): void => {
if (!middleware) {
const allowedOrigins = parseAllowedOrigins(
process.env.APIS_CORS_ALLOWED_ORIGINS,
);
logger.info('[cors] apis allowlist loaded', {
originCount: allowedOrigins.length,
});
middleware = createCorsAllowlistMiddleware({
allowedOrigins,
allowCredentials: true,
maxAgeSeconds: DEFAULT_MAX_AGE_SECONDS,
});
}
middleware(req, res, next);
};
}

3 changes: 2 additions & 1 deletion src/routes/apis.cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function buildApp(repo: InMemoryApiRepository) {
apiRepository: repo,
developerRepository,
cache: new ListingsCache({ ttlMs: 0 }), // TTL=0 → immediate expiry, never serves from cache
corsMiddleware: (_req, _res, next) => next(),
}),
);
app.use(errorHandler);
Expand Down Expand Up @@ -309,7 +310,7 @@ describe('GET /api/apis — cursor pagination', () => {
});

it('treats empty cursor string as first page (cursor-based)', async () => {
const res = await request(buildFixtureApp()).get('/api/apis?cursor=');
const res = await request(buildFixtureApp()).get('/api/apis?cursor=&limit=2');

expect(res.status).toBe(200);
expect(res.body.meta).toHaveProperty('hasMore');
Expand Down
4 changes: 3 additions & 1 deletion src/routes/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface ApisRouterDeps {
cache?: ListingsCache;
/** Optional rate limit middleware for the public API routes. */
rateLimitMiddleware?: ReturnType<typeof createRateLimitMiddleware>;
/** Optional CORS middleware for the API routes. Defaults to env-driven createApisCorsMiddleware. */
corsMiddleware?: ReturnType<typeof createApisCorsMiddleware>;
/** Persists audit rows for state-changing calls. Defaults to the pg-backed service. */
auditService?: AuditService;
}
Expand Down Expand Up @@ -99,7 +101,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
maxRequests: 60,
});

const apisCors = createApisCorsMiddleware();
const apisCors = deps.corsMiddleware ?? createApisCorsMiddleware();

router.use(apisCors);
router.use(rateLimitMiddleware);
Expand Down
Loading