90 lines
2.8 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|