Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Add four new testkit tests (T37–T40) covering self-serve endpoints: - T37: GET /api/healthz extended fields (uptime_s, jobs_total) - T38: GET /api/metrics full snapshot verification - T39: GET /api/estimate cost-preview endpoint - T40: Demo 429 Retry-After header (RFC 7231) Also adds Retry-After header to the demo rate-limit 429 response. Fixes #45 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
77 lines
2.6 KiB
TypeScript
77 lines
2.6 KiB
TypeScript
import { Router, type Request, type Response } from "express";
|
|
import { RunDemoQueryParams } from "@workspace/api-zod";
|
|
import { agentService } from "../lib/agent.js";
|
|
import { makeLogger } from "../lib/logger.js";
|
|
|
|
const router = Router();
|
|
const logger = makeLogger("demo");
|
|
|
|
const RATE_LIMIT_MAX = 5;
|
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|
|
|
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
|
|
|
function checkRateLimit(ip: string): { allowed: boolean; resetAt: number; remaining: number } {
|
|
const now = Date.now();
|
|
const entry = ipHits.get(ip);
|
|
|
|
if (!entry || now >= entry.resetAt) {
|
|
ipHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
return { allowed: true, resetAt: now + RATE_LIMIT_WINDOW_MS, remaining: RATE_LIMIT_MAX - 1 };
|
|
}
|
|
|
|
if (entry.count >= RATE_LIMIT_MAX) {
|
|
return { allowed: false, resetAt: entry.resetAt, remaining: 0 };
|
|
}
|
|
|
|
entry.count += 1;
|
|
return { allowed: true, resetAt: entry.resetAt, remaining: RATE_LIMIT_MAX - entry.count };
|
|
}
|
|
|
|
router.get("/demo", async (req: Request, res: Response) => {
|
|
const ip =
|
|
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
|
|
req.socket.remoteAddress ??
|
|
"unknown";
|
|
|
|
const { allowed, resetAt, remaining } = checkRateLimit(ip);
|
|
|
|
res.setHeader("X-RateLimit-Limit", String(RATE_LIMIT_MAX));
|
|
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
|
res.setHeader("X-RateLimit-Reset", String(Math.floor(resetAt / 1000)));
|
|
|
|
if (!allowed) {
|
|
const secsUntilReset = Math.ceil((resetAt - Date.now()) / 1000);
|
|
logger.warn("demo rate limited", { ip, retry_after_s: secsUntilReset });
|
|
res.setHeader("Retry-After", String(secsUntilReset));
|
|
res.status(429).json({
|
|
error: `Rate limit exceeded. Try again in ${secsUntilReset}s (5 requests per hour per IP).`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parseResult = RunDemoQueryParams.safeParse(req.query);
|
|
if (!parseResult.success) {
|
|
const issue = parseResult.error.issues[0];
|
|
const error = issue?.code === "too_big"
|
|
? "Invalid query param: 'request' must be 500 characters or fewer"
|
|
: "Missing required query param: request";
|
|
res.status(400).json({ error });
|
|
return;
|
|
}
|
|
const { request } = parseResult.data;
|
|
|
|
logger.info("demo request received", { ip });
|
|
|
|
try {
|
|
const { result } = await agentService.executeWork(request);
|
|
res.json({ result });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Agent error";
|
|
logger.error("demo agent error", { ip, error: message });
|
|
res.status(500).json({ error: message });
|
|
}
|
|
});
|
|
|
|
export default router;
|