Files
Cibello-app/apps/api/src/routes/ops.ts
T
2026-08-12 20:48:06 +07:00

90 lines
2.8 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import BRAND from "../../../../brand.config.json" with { type: "json" };
import { errors } from "../lib/errors.js";
const CACHE_KEY = "ops:summary:cache";
const CACHE_TS_KEY = "ops:summary:computed_at";
function requireOpsToken(app: FastifyInstance) {
return async (req: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (app.config.NODE_ENV === "production" && req.protocol !== "https") {
return reply
.status(403)
.send({ error: { code: "HTTPS_REQUIRED", message: "Ops-endpointen kräver HTTPS." } });
}
const header = req.headers.authorization ?? "";
const match = /^Bearer\s+(.+)$/.exec(header);
const provided = match?.[1] ?? "";
const expected = app.config.OPS_TOKEN ?? "";
if (!expected || expected.length < 20 || provided.length !== expected.length) {
throw errors.unauthorized();
}
const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) throw errors.unauthorized();
};
}
/**
* Operations summary endpoint: read-only, token-protected, served from cache.
* Heavy computation is done by the REFRESH_OPS_SUMMARY_CACHE worker job.
*/
export async function opsRoutes(app: FastifyInstance) {
const preHandler = [requireOpsToken(app)];
app.get("/ops/v1/health", { preHandler }, async () => ({
ok: true,
app: "cibello",
}));
app.get("/ops/v1/summary", { preHandler }, async (_req, reply) => {
const [json, ts] = await app.redis.mget(CACHE_KEY, CACHE_TS_KEY);
if (!json) {
return reply.status(503).send({
error: {
code: "CACHE_MISS",
message: "Sammanfattningen är inte färdigberäknad än. Vänta på nästa canary-/cache-jobb.",
},
});
}
let summary: Record<string, unknown>;
try {
summary = JSON.parse(json) as Record<string, unknown>;
} catch {
return reply
.status(500)
.send({ error: { code: "CACHE_INVALID", message: "Cachen innehåller ogiltig JSON." } });
}
const enriched = structuredClone(summary);
enriched.gemini = {
anrop_24h: null,
prompt_tokens_24h: null,
output_tokens_24h: null,
total_tokens_24h: null,
total_tokens_manad: null,
kostnad_usd_mikrocent_24h: null,
andel_pa_aamos: 0.0,
aamos_agreement: null,
};
if (
enriched.wall &&
typeof enriched.wall === "object" &&
Array.isArray((enriched.wall as Record<string, unknown>).boards)
) {
for (const board of (enriched.wall as { boards: Array<{ title?: string }> }).boards) {
if (board.title === "{brand}") board.title = BRAND.name;
}
}
return reply.send({
...enriched,
cached_at: ts ?? summary.as_of,
});
});
}