// Plattformstjänsten — självhostad backend för Guidad Felsökning. // // Multi-tenant: varje organisation är en egen tenant. Registrering skapar // en organisation med en systemadministratör; admin skapar övriga // användare (tekniker/arbetsledare) i sin organisation. All ärendedata // är organisationsknuten — API:t släpper aldrig data över gränsen. // // Händelseloggen är append-only även i databasen (triggers) — den här // tjänsten exponerar medvetet inga update/delete-operationer. // // Miljövariabler: // DATABASE_URL Postgres-anslutning (krävs) // JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs) // REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta) // INTEGRATION_NYCKEL 32 byte (hex/base64) — krypterar kundernas leverantörsuppgifter // TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*") // TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät // ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration // FAKTURERING_NYCKEL utfärdarens nyckel; utan den kan ingen faktura utfärdas // SUPPORT_NYCKEL vår egen supports nyckel; krävs för att svara och sätta status // PERSONNYCKEL_HUVUD 32 byte (hex/base64) — kuverterar personnycklarna så att // en återställd databasdump inte innehåller läsbara nycklar // FORSEGLING_NYCKEL HMAC-nyckel för kedjeförseglingen vid avslut; hålls utanför // databasen. Utan den skrivs kedjan men avslut förseglas inte — // varnas vid start OCH per avslut, aldrig tyst (TÜV-2 T-15) // PORT default 8080 import { createServer } from "node:http"; import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { lookup } from "node:dns/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import pg from "pg"; import { innehallsHash, mediatypGiltig, valjLager } from "./bilagor.mjs"; import { avsluta, logga, mätvärde, spårFrån, starta, traceparent } from "./observation.mjs"; import { tillPost } from "./handelser.mjs"; import { grinda, grindaArendetyp } from "./grind.mjs"; import { ALLA_METODIKER } from "./metodiker.mjs"; import { oversikt as statistikOversikt } from "./statistik.mjs"; import { KATEGORIER, UTGAENDE, protokollTillHandelser, signeraLeverans } from "./integration.mjs"; import { enrading, sammanfatta } from "./sammanfattning.mjs"; import { fakturabeteckning, fakturastatus, fakturera, granskaPeriod, kreditera } from "./fakturering.mjs"; import { STATUSAR, granskaAnmalan, sammanhang as byggSammanhang, supportbeteckning, supportstatus } from "./support.mjs"; import { NIVAER, abonnemangstillstand, behorighet, besked, forfallenFakturering, nivaFor } from "./abonnemang.mjs"; import { fakturaPdf } from "./fakturapdf.mjs"; import { STANDARD, valjSprak, t } from "./sprak/index.mjs"; import { forsegla, grund, lank, provaForsegling, verifiera as verifieraKedja } from "./kedja.mjs"; import { MASKERAT, gallringsdatum, skyddaHändelse, öppnaHändelse, } from "./personuppgifter.mjs"; // API-first: OpenAPI-specen är en versionerad artefakt och serveras live. const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8"); // ECM Knowledge Library: regelpaketet är serverägd konfiguration — inte // appkod. Uppdateras genom att byta filen (ECM_REGLER_FIL kan peka på en // ConfigMap-mount i klustret) och starta om tjänsten; klienterna hämtar // det nya paketet vid nästa inloggning/sidladdning. const ECM_REGLER_EGEN_FIL = !!process.env.ECM_REGLER_FIL; const ECM_REGLER = readFileSync( process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"), "utf8", ); // Regelpaketet avgör vad systemet accepterar som ett compliant ärende. // Den som kan skriva till namnrymden kunde tidigare ändra vad "compliant" // betyder, utan signatur och utan godkännandespår (QUALITET M-6). // // Paketet verifieras nu mot en HMAC med ECM_REGLER_NYCKEL. Saknas nyckeln // körs paketet i granskningsläge: det används, men varje spårbarhetspaket // märks som osignerat, och driften larmar. Att vägra starta hade varit // renare men gjort en säkerhetsförbättring till ett driftavbrott för alla // befintliga installationer — det är så säkerhetsfunktioner blir // avstängda. const ECM_REGLER_NYCKEL = process.env.ECM_REGLER_NYCKEL ?? ""; const ECM_REGLER_SIGNATUR = process.env.ECM_REGLER_SIGNATUR ?? ""; function regelpaketetsStatus() { // Ett EXTERNT paket måste vara signerat (TÜV T-9). Det inbyggda // standardpaketet följer med bilden och behöver ingen signatur — det // som behöver en är det någon har monterat in, för det är precis vad // angreppet innebär. Tidigare gällde samma milda utfall för bägge, så // en installation som aldrig konfigurerade signering fick ingen // integritetskontroll alls och ingen felsignal heller. if (ECM_REGLER_EGEN_FIL && (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR)) return "osignerat externt"; if (!ECM_REGLER_NYCKEL || !ECM_REGLER_SIGNATUR) return "osignerat"; const väntad = createHmac("sha256", ECM_REGLER_NYCKEL).update(ECM_REGLER).digest("hex"); const a = Buffer.from(ECM_REGLER_SIGNATUR); const b = Buffer.from(väntad); return a.length === b.length && timingSafeEqual(a, b) ? "signerat" : "ogiltig signatur"; } const REGELPAKET_STATUS = regelpaketetsStatus(); if (REGELPAKET_STATUS !== "signerat") { logga("varning", "regelpaketets signatur", { status: REGELPAKET_STATUS, // Ett ogiltigt paket är värre än inget: det betyder att någon bytt // filen utan att kunna signera den. konsekvens: REGELPAKET_STATUS === "ogiltig signatur" ? "Paketet används INTE — inbyggt standardpaket gäller." : REGELPAKET_STATUS === "osignerat externt" ? "Ett externt paket saknar signatur — avslut är spärrat tills det signeras." : "Paketet används men märks som osignerat i spårbarheten.", }); } // Register över märkesspecifika kopplingar — data, inte kod. Nya // leverantörer läggs till i filen (eller via ConfigMap-mount) utan att // applikationen byggs om. const INTEGRATIONER = JSON.parse( readFileSync( process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"), "utf8", ), ); const PORT = Number(process.env.PORT ?? 8080); const MAX_KROPP = 4 * 1024 * 1024; // Bilagor får vara större än en händelse — ett videoklipp med ljud är // evidens som inte går att skala ned hur långt som helst. const MAX_BILAGA = 32 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; const ROLLER = ["tekniker", "arbetsledare", "admin"]; // ---- Utfärdaren av fakturor ------------------------------------------- // // En organisations administratör får INTE utfärda sin egen faktura, och // definitivt inte bokföra den som betald. Rollerna i systemet // (tekniker/arbetsledare/admin) beskriver arbetet i en verkstad — ingen // av dem är motpart i avtalet. // // Utfärdaren är den som driver installationen, och den identifieras med // en egen nyckel i stället för med ett konto. Saknas nyckeln kan ingen // faktura utfärdas alls: fakturering fallerar stängt, av samma skäl som // ett ärende utan evidens inte kan avslutas. const FAKTURERING_NYCKEL = process.env.FAKTURERING_NYCKEL ?? ""; // Förseglingsnyckeln hålls utanför databasen av samma skäl som // PERSONNYCKEL_HUVUD: det den skyddar mot är den som HAR databasen. // Utan nyckel skrivs kedjan ändå — den skyddar mot radändring — men // avslut förseglas inte, och verifieringen säger det rakt ut i stället // för att låtsas. const FORSEGLING_NYCKEL = process.env.FORSEGLING_NYCKEL ?? ""; // Vår egen support. Verkstaden anmäler och svarar i sitt eget ärende; // att sätta status är vår sida av bordet, och den skiljs med en egen // nyckel av samma skäl som utfärdaren av fakturor gör det. const SUPPORT_NYCKEL = process.env.SUPPORT_NYCKEL ?? ""; function nyckelStammer(given, forvantad) { if (!forvantad) return false; const a = Buffer.from(String(given ?? "")); const b = Buffer.from(forvantad); // Jämförelsen är tidskonstant, och längden jämförs först eftersom // timingSafeEqual kastar på olika längd. return a.length === b.length && timingSafeEqual(a, b); } function arUtfardare(req) { return nyckelStammer(req.headers["x-fakturering"], FAKTURERING_NYCKEL); } function arSupport(req) { return nyckelStammer(req.headers["x-support"], SUPPORT_NYCKEL); } const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); // Poolens uttömning är den vanligaste orsaken till att allt blir // långsamt samtidigt utan att någon enskild fråga är långsam. setInterval(() => { mätvärde("Databaskopplingar", pool.totalCount, "Count", { Tjänst: "plattform" }, { lediga: pool.idleCount, väntande: pool.waitingCount, }); }, 60_000).unref(); // Var bilagornas innehåll hamnar. Felkonfigurerat s3-läge failar här, // vid start, i stället för vid första uppladdningen. const BILAGELAGER = valjLager(process.env, pool); // Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften // serveras klienten från samma domän som API:t, så listan kan hållas kort. // TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" — // utelämnad betyder "*" (öppet), vilket bara hör hemma i utveckling. const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "") .split(",") .map((u) => u.trim()) .filter(Boolean); function ursprungFor(req) { const ursprung = req.headers.origin; // Tom lista betydde tidigare "*" — alltså öppet som standard (TÜV T-10). // Nu betyder den samma ursprung: inget CORS-huvud alls, vilket är vad // klusterdriften behöver, eftersom klienten serveras från samma domän. // Vill någon öppna får den skriva "*" och mena det. if (TILLATNA_URSPRUNG.length === 0) return null; if (TILLATNA_URSPRUNG.includes("*")) return "*"; return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0]; } // Enkel takt-begränsning för den publika beslutsendpointen (per // delningskod, i minnet). Räcker för en enda pod; bakom flera repliker // kompletteras den av databasspärren "ett beslut per förslag". const BESLUT_TAK = 5; const BESLUT_FONSTER_MS = 60_000; const beslutsForsok = new Map(); function forTataForsok(kod) { const nu = Date.now(); const forsok = (beslutsForsok.get(kod) ?? []).filter((t) => nu - t < BESLUT_FONSTER_MS); forsok.push(nu); beslutsForsok.set(kod, forsok); // Enkel städning så kartan inte växer obegränsat. if (beslutsForsok.size > 5000) beslutsForsok.clear(); return forsok.length > BESLUT_TAK; } // ---- Takt-begränsning på inloggning ----------------------------------- // // Den i minnet (beslutsvägen ovan) räcker för en pod. Inloggningen skalar // till flera repliker och behöver därför en gemensam räknare — den ligger // i databasen. Två spärrar: per konto (skyddar en enskild användare) och // per källa (stoppar den som betar av många konton från samma håll). const INLOGG_FONSTER = "15 minutes"; const INLOGG_TAK_KONTO = 10; const INLOGG_TAK_KALLA = 30; function kallaFor(req) { // Bakom ingressen står klientens adress först i X-Forwarded-For. const vidarebefordrad = req.headers["x-forwarded-for"]; const forsta = typeof vidarebefordrad === "string" ? vidarebefordrad.split(",")[0].trim() : ""; return (forsta || req.socket?.remoteAddress || "").slice(0, 64); } async function inloggningSparrad(epost, kalla) { const rad = await pool.query( `select count(*) filter (where epost = $1) as konto, count(*) filter (where kalla = $2 and $2 <> '') as kalla from inloggningsforsok where lyckades = false and tidpunkt > now() - interval '${INLOGG_FONSTER}'`, [epost, kalla], ); const { konto, kalla: franKalla } = rad.rows[0]; return Number(konto) >= INLOGG_TAK_KONTO || Number(franKalla) >= INLOGG_TAK_KALLA; } async function loggaForsok(epost, kalla, lyckades) { await pool.query( `insert into inloggningsforsok (epost, kalla, lyckades) values ($1, $2, $3)`, [epost, kalla, lyckades], ); // Städa bort det som inte längre kan påverka någon spärr. Billigt nog // att göra i skrivvägen och slipper ett schemalagt jobb. if (Math.random() < 0.02) { await pool.query(`delete from inloggningsforsok where tidpunkt < now() - interval '1 day'`); } } // ---- JWT (HS256, utan beroenden) -------------------------------------- const b64url = (data) => Buffer.from(data).toString("base64url"); export function skapaJwt(anspr, hemlighet) { const huvud = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); const kropp = b64url(JSON.stringify(anspr)); const signatur = createHmac("sha256", hemlighet).update(`${huvud}.${kropp}`).digest("base64url"); return `${huvud}.${kropp}.${signatur}`; } export function verifieraJwt(token, hemlighet) { const delar = token.split("."); if (delar.length !== 3) return null; const forvantad = createHmac("sha256", hemlighet).update(`${delar[0]}.${delar[1]}`).digest("base64url"); const a = Buffer.from(delar[2]); const b = Buffer.from(forvantad); if (a.length !== b.length || !timingSafeEqual(a, b)) return null; try { const anspr = JSON.parse(Buffer.from(delar[1], "base64url").toString("utf8")); // Utgångstid KRÄVS (TÜV T-11). Tidigare kontrollerades exp bara när // det fanns, vilket gjorde en token utan exp evig. Alla tokens som // utfärdas idag bär en — men den som en gång slipper igenom utan // skulle aldrig sluta gälla, och det är inte ett fel man upptäcker. if (typeof anspr.exp !== "number") return null; if (anspr.exp * 1000 < Date.now()) return null; return anspr; } catch { return null; } } // ---- Integrationsuppgifter: kryptering i vila ------------------------ // // Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM. // Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och // finns bara i driftens hemlighetshantering. Saknas nyckeln kan // integrationer varken sparas eller användas — fail closed. function integrationsNyckel() { const ra = process.env.INTEGRATION_NYCKEL; if (!ra) return null; const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64"); return buf.length === 32 ? buf : null; } export function kryptera(klartext, nyckel) { const iv = randomBytes(12); const chiffer = createCipheriv("aes-256-gcm", nyckel, iv); const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]); return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`; } export function dekryptera(paket, nyckel) { const [iv, tagg, data] = paket.split("."); const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64")); dechiffer.setAuthTag(Buffer.from(tagg, "base64")); return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8"); } // Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att // ett värde finns och dess sista tecken. export function maskera(varde) { if (typeof varde !== "string" || varde.length === 0) return ""; if (varde.length <= 4) return "••••"; return `••••${varde.slice(-4)}`; } function leverantorsDef(id) { return INTEGRATIONER.leverantorer.find((l) => l.id === id); } // ---- Delningsfilter: tillåtelselista, inte nekalista ------------------ // // Vilka händelsetyper som får lämna verkstaden är en integritetsgräns. // Med en nekalista blir varje NY händelsetyp automatiskt synlig för // kunden tills någon kommer ihåg att neka den — fel håll att fela åt. // Här listas i stället uttryckligen vad som får delas; allt annat är // internt tills det aktivt släpps fram. Testet i delning.test.ts kräver // att varje händelsetyp i domänmodellen är klassificerad. export const DELBART_KUND = [ "objekt_identifierat", "arendetyp_satt", "felbeskrivning", "fraga_besvarad", "kontroll_utford", "observation", "matvarde", "foto", "video", "kommentar", "inaktivitet_forklarad", "overlamning", "historik_kontrollerad", "matarstallning", "reproducering", "felorsak", "atgardsforslag", "kundbeslut", "atgard_utford", "kvalitetskontroll", "export_skapad", // ALVA-RULE-200 · Teknikerns varför delas med kunden. // // Det är den enda raden som besvarar "varför kostade det här vad det // kostade", och den enda en försäkringsbedömare faktiskt behöver. // Att hålla den intern vore att bygga funktionen och sedan gömma den. // // Fältet "uteslutet" nämner hypoteser som förkastats. Det är inte // samma sak som att dela en öppen hypotes: en utesluten misstanke, // redovisad som utesluten, är stärkande — "vi kontrollerade hjullagret // och det var helt" är precis vad kunden betalade för att få veta. "slutsats", "arende_avslutat", ]; // Extern partner (försäkringsbolag, tillverkare) ser dessutom hypoteser // — alltid märkta som ej verifierade. export const DELBART_PARTNER = [...DELBART_KUND, "hypotes"]; // Aldrig utanför organisationen: arbetsledning, arbetsmaterial och // underlag som kan läsas som konstateranden. export const ENDAST_INTERNT = ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"]; export function synligaTyper(niva) { if (niva === "intern") return null; // full insyn — ingen filtrering return niva === "partner" ? DELBART_PARTNER : DELBART_KUND; } // ---- Hjälpare --------------------------------------------------------- /** * CORS-huvuden, eller inga alls. * * `res.ursprung` är null när ingen ursprungslista är konfigurerad, och då * ska huvudet UTELÄMNAS — inte sättas till "*" och inte till strängen * "null". Utan den här hjälparen fanns beslutet på sex ställen, och det * räckte att ett av dem föll tillbaka på "*" för att TÜV T-10 skulle vara * öppen igen. */ function korsHuvuden(res) { return res.ursprung ? { "Access-Control-Allow-Origin": res.ursprung, Vary: "Origin" } : {}; } function svara(res, status, kropp) { res.writeHead(status, { "Content-Type": "application/json", // Ursprunget sätts en gång per anrop i hanteraren nedan. ...korsHuvuden(res), "Access-Control-Allow-Headers": "authorization, content-type", // ---- Säkerhetshuvuden (TÜV-2-härdning) ------------------------------ // API:t svarar JSON och bara JSON. `nosniff` gör att en webbläsare // aldrig gissar om det; `no-store` håller loggar och fakturor borta // från mellanliggande cachar — ett API vars svar är personuppgifter // får inte bli en cacheträff; `frame-ancestors` stänger inbäddning. // HSTS sätts även om TLS termineras framför oss: huvudet är // verkningslöst över http och rätt över https, så det kostar inget // att alltid tala sanning. "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "frame-ancestors 'none'", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", }); res.end(JSON.stringify(kropp)); } async function lasKropp(req) { const bitar = []; let storlek = 0; for await (const bit of req) { storlek += bit.length; if (storlek > MAX_KROPP) throw new Error("för stor kropp"); bitar.push(bit); } return JSON.parse(Buffer.concat(bitar).toString("utf8")); } async function lasBinart(req, tak) { const bitar = []; let storlek = 0; for await (const bit of req) { storlek += bit.length; if (storlek > tak) throw new Error("för stor kropp"); bitar.push(bit); } return Buffer.concat(bitar); } // Delningskod. Förkastningsurval i stället för modulo: 256 är inte jämnt // delbart med 36, så `b % 36` gör de fyra första tecknen ~14 % vanligare. // Entropiförlusten är liten, men en snedfördelad kod i en säkerhetsväg är // inte något man vill behöva förklara i en granskning (QUALITET m-1). function nyKod(langd = 16) { const tecken = "abcdefghijklmnopqrstuvwxyz0123456789"; const tak = 256 - (256 % tecken.length); // 252 — värden däröver kastas const ut = []; while (ut.length < langd) { for (const b of crypto.randomBytes(langd)) { if (b < tak && ut.length < langd) ut.push(tecken[b % tecken.length]); } } return ut.join(""); } /** * Organisationens dokumentationsspråk. * * Organisationen och inte användaren, därför att grindens hinder hamnar * i ärendets logg och i rapporten. En verkstad i Tyskland med en polsk * tekniker ska ha ett gemensamt dokumentationsspråk — rapporten ska * inte byta språk beroende på vem som råkade stänga ärendet. * * Faller tillbaka på engelska, tyst. Att neka ett avslut för att * språkinställningen inte gick att läsa vore att låta en etikett stoppa * arbetet. */ async function orgSprak(pool, orgId) { try { const rader = await pool.query(`select installningar->>'sprak' as sprak from organisationer where id = $1`, [ orgId, ]); return valjSprak({ organisation: rader.rows[0]?.sprak }); } catch { return STANDARD; } } /** * Avslutsspärren. Läser hela loggen ur databasen, lägger de inkommande * händelserna ovanpå och utvärderar grinden mot det samlade underlaget — * annars skulle ett avslut i samma anrop som evidensen felaktigt nekas. */ /** * Skriver händelser till loggen MED kedjelänk (ALVA-SPEC-070). * * All skrivning till felsokning_handelser går genom den här funktionen. * En händelse som skrivs vid sidan av kedjan är ett hål i beviset, så * det får inte finnas en bekväm väg förbi. * * Ordningen säkras med radlås på ärendet: två samtidiga batchar mot * samma ärende serialiseras, annars hade båda läst samma "senaste länk" * och kedjan forkat — en fork ser ut som manipulation utan att vara det, * och en verifiering som larmar falskt avfärdas snart som trasig. * * Länkens digest tas över den LAGRADE händelsen — efter kryptering — * inte över klientens klartext. Det är avsiktligt: verifieringen ska * kunna räkna om digesten ur databasen för all framtid, och den lagrade * formen är den enda som aldrig ändras. Krypto-shredding förstör * nycklar, inte rader, så kedjan överlever en radering. * * @returns {rot} kedjans sista länk efter batchen */ async function skrivKedjat(arendeId, poster) { const db = await pool.connect(); try { await db.query("begin"); // Radlåset serialiserar kedjan per ärende. Ärendet finns alltid här — // org-gränsen är redan prövad av anroparen. await db.query(`select 1 from felsokning_arenden where id = $1 for update`, [arendeId]); const sista = await db.query( `select sekvens, kedjehash from felsokning_handelser where arende_id = $1 and sekvens is not null order by sekvens desc limit 1`, [arendeId], ); let sekvens = Number(sista.rows[0]?.sekvens ?? 0); let h = sista.rows[0]?.kedjehash ?? grund(arendeId); let skrivna = 0; for (const p of poster) { const digest = innehallsHash(kanoniskt(p.handelse)); // Tidpunkten normaliseras HÄR, till exakt den form verifieringen // räknar om ur databasen (millisekunders ISO). Alla nuvarande // vägar går genom tillPost som redan ger den formen — men länken // får inte vila på att det förblir sant. En kedja som bryts av ett // tidsformat är ett falsklarm, och falsklarm dödar förtroendet // för verifieringen (revision 3, m-1). const tid = new Date(p.tidpunkt).toISOString(); const nastaH = lank(h, { id: p.id, tidpunkt: tid, anvandare: p.anvandare, digest }); const r = await db.query( `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, klientdigest, sekvens, kedjehash) values ($1, $2, $3, $4, $5, $6, $7, $8) on conflict (arende_id, id) do nothing`, [p.id, arendeId, tid, p.anvandare, p.handelse, p.klientdigest ?? null, sekvens + 1, nastaH], ); // En dubblett skrivs inte och får därför inte heller flytta kedjan: // länken ovan förbrukas bara när raden faktiskt sattes in. if (r.rowCount > 0) { sekvens += 1; h = nastaH; skrivna += 1; } } // Avslut förseglar kedjan: roten och en HMAC med nyckeln som aldrig // finns i databasen. Skrivs i samma transaktion som avslutshändelsen — // ett avslut utan försegling ska inte kunna uppstå av en krasch // mittemellan. if (poster.some((p) => p.handelse?.typ === "arende_avslutat")) { const nu = new Date().toISOString(); if (FORSEGLING_NYCKEL) { await db.query( `update felsokning_arenden set kedjerot = $2, forsegling = $3, forseglad = $4 where id = $1 and forsegling is null`, [arendeId, h, forsegla(FORSEGLING_NYCKEL, arendeId, h, nu), nu], ); } else { logga("varning", "avslut utan försegling", { arende: arendeId, konsekvens: "Kedjan är skriven men roten är inte förseglad. Sätt FORSEGLING_NYCKEL.", }); } } await db.query("commit"); return { rot: h, skrivna, dubbletter: poster.length - skrivna }; } catch (fel) { await db.query("rollback").catch(() => {}); throw fel; } finally { db.release(); } } /** * Kedje- och förseglingsstatus för ETT ärende (ALVA-SPEC-070). * * En enda implementation, delad av verifierings-endpointen och * kedjesvepet. TÜV-2 T-14 handlade om en kontroll som fanns vid en grind * men inte vid resursen — samma läxa gäller verifiering: två * implementationer av "är kedjan hel?" kommer att svara olika den dag * det betyder något. */ async function kedjestatus(arendeId) { const rader = await pool.query( `select id, tidpunkt, anvandare, handelse, sekvens, kedjehash from felsokning_handelser where arende_id = $1 order by sekvens nulls first, tidpunkt, id`, [arendeId], ); const kedjade = rader.rows.filter((r) => r.sekvens != null); const tillLank = (r) => ({ id: r.id, tidpunkt: new Date(r.tidpunkt).toISOString(), anvandare: r.anvandare, digest: innehallsHash(kanoniskt(r.handelse)), kedjehash: r.kedjehash, }); const kedja = verifieraKedja(arendeId, kedjade.map(tillLank)); const okedjade = rader.rows.length - kedjade.length; const arende = await pool.query( `select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`, [arendeId], ); const a = arende.rows[0] ?? {}; let forsegling = "saknas"; let efterForsegling = null; if (a.forsegling) { if (!FORSEGLING_NYCKEL) { forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; } else { const idx = kedjade.findIndex((r) => r.kedjehash === a.kedjerot); // Prefixet prövas för sig: ett brott EFTER förseglingspunkten gör // inte prefixet mindre bevisat — brottet pekas ut för sig. const prefixOk = idx >= 0 && verifieraKedja(arendeId, kedjade.slice(0, idx + 1).map(tillLank)).ok; if ( prefixOk && provaForsegling(FORSEGLING_NYCKEL, arendeId, a.kedjerot, new Date(a.forseglad).toISOString(), a.forsegling) ) { forsegling = "giltig"; efterForsegling = kedjade.length - (idx + 1); } else { forsegling = "OGILTIG"; } } } return { ok: kedja.ok, rot: kedja.rot, brott: kedja.brott ?? null, kedjade: kedjade.length, okedjade, forsegling, efterForsegling, }; } async function grindHinder(pool, arendeId, nya, sprak = STANDARD) { const rader = await pool.query( `select h.handelse, a.metodik_id from felsokning_handelser h join felsokning_arenden a on a.id = h.arende_id where h.arende_id = $1 order by h.sekvens nulls first, h.tidpunkt, h.id`, [arendeId], ); const handelser = [...rader.rows.map((r) => r.handelse), ...nya.map((p) => p.handelse)]; const metodik = ALLA_METODIKER.find((m) => m.id === rader.rows[0]?.metodik_id) ?? ALLA_METODIKER.at(-1); let regelpaket; if (REGELPAKET_STATUS === "ogiltig signatur") { return [{ id: "regelpaket", nyckel: "grind.regelpaket", rubrik: t(sprak, "grind.regelpaket") }]; } if (REGELPAKET_STATUS === "osignerat externt") { return [ { id: "regelpaket", nyckel: "grind.regelpaket.osignerat", rubrik: t(sprak, "grind.regelpaket.osignerat"), // Driftanvisning till den som satt upp servern, inte text till // teknikern. Den står därför på engelska och översätts inte. detalj: "Set ECM_REGLER_NYCKEL and ECM_REGLER_SIGNATUR, or remove ECM_REGLER_FIL.", }, ]; } try { regelpaket = JSON.parse(ECM_REGLER); } catch { // Ett trasigt regelpaket får aldrig tolkas som "inga extra krav". return [{ id: "regelpaket", nyckel: "grind.regelpaket", rubrik: t(sprak, "grind.regelpaket") }]; } return [...grinda(handelser, metodik, sprak), ...grindaArendetyp(handelser, regelpaket, sprak)]; } // ---- Personuppgifter, gallring och åtkomstlogg ------------------------- /** * Hämtar eller skapar nyckeln för ett subjekt. Ett subjekt är normalt ett * fordon (regnr eller VIN) — det som en raderingsbegäran pekar ut. */ /** * Blindat index över en fordonsidentifierare. * * Samma fordon ger alltid samma värde, men värdet går inte att vända * tillbaka till ett registreringsnummer utan nyckeln. Det är det som gör * en raderingsbegäran genomförbar över hela fordonets historik trots att * identifieraren är krypterad i loggen (QUALITET C-3). */ function blindaIdentifierare(ident) { const nyckel = process.env.INTEGRATION_NYCKEL ?? process.env.JWT_SECRET ?? ""; return createHmac("sha256", nyckel) .update(String(ident).trim().toUpperCase().replace(/\s+/g, "")) .digest("hex"); } // ---- Kuvertkryptering av personnycklarna (TÜV T-3) -------------------- // // Nycklarna låg i klartext i samma databas, samma kluster och samma // backuper som den chiffertext de skyddade. Krypto-shredding blev då att // radera en rad bredvid de data den skulle göra oåtkomliga — och // återställningstestet slog uttryckligen fast att nycklarna följer med en // återställning. Testet har rätt för katastrofåterställning; det är samma // faktum läst åt andra hållet som bröt raderingslöftet. // // Nycklarna lagras nu KUVERTERADE under en huvudnyckel som inte finns i // databasen. En återställd dump ger nycklar som inte går att öppna utan // den, vilket flyttar raderingsgarantin från "en rad är borta" till "en // nyckel utanför databasen krävs". // // VAD SOM ÅTERSTÅR, uttryckligen: en backup tagen FÖRE en radering, plus // huvudnyckeln, återställer fortfarande uppgifterna. Att stänga det helt // kräver en nyckel per subjekt i en KMS där förstörelsen är // oåterkallelig. Kuverteringen är förberedelsen för det — därför ligger // in- och uppackningen i egna funktioner och inte inline. const PERSONNYCKEL_HUVUD = (() => { const rå = process.env.PERSONNYCKEL_HUVUD ?? ""; if (!rå) return null; const b = /^[0-9a-fA-F]{64}$/.test(rå) ? Buffer.from(rå, "hex") : Buffer.from(rå, "base64"); if (b.length !== 32) throw new Error("PERSONNYCKEL_HUVUD måste vara 32 byte (hex eller base64)."); return b; })(); /** Kuvertformat: v1..., allt base64url. */ function kuvertera(nyckel) { if (!PERSONNYCKEL_HUVUD) return nyckel; const iv = randomBytes(12); const c = createCipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, iv); const ut = Buffer.concat([c.update(nyckel), c.final()]); return Buffer.from( `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${ut.toString("base64url")}`, "utf8", ); } function oppnaKuvert(lagrad) { const text = Buffer.from(lagrad).toString("utf8"); // En nyckel som lagrades före kuverteringen är råa byte. Den läses som // förut — annars hade uppgraderingen gjort all befintlig historik // oläsbar, vilket är samma skada som en felaktig radering. if (!text.startsWith("v1.")) return Buffer.from(lagrad); if (!PERSONNYCKEL_HUVUD) { throw new Error("Personnycklarna är kuverterade men PERSONNYCKEL_HUVUD saknas."); } const [, iv, tagg, data] = text.split("."); const d = createDecipheriv("aes-256-gcm", PERSONNYCKEL_HUVUD, Buffer.from(iv, "base64url")); d.setAuthTag(Buffer.from(tagg, "base64url")); return Buffer.concat([d.update(Buffer.from(data, "base64url")), d.final()]); } async function personnyckel(orgId, subjekt) { const rad = await pool.query( `insert into personnycklar (organisation_id, subjekt, nyckel) values ($1, $2, $3) on conflict (organisation_id, subjekt) do update set subjekt = excluded.subjekt returning id, nyckel, radering_begard`, [orgId, subjekt, kuvertera(randomBytes(32))], ); return { ...rad.rows[0], nyckel: oppnaKuvert(rad.rows[0].nyckel) }; } /** Nycklar som fortfarande finns, för att öppna en logg vid läsning. */ async function nycklarFor(orgId) { const rader = await pool.query( `select id, nyckel from personnycklar where organisation_id = $1`, [orgId], ); // En nyckel som inte går att öppna hoppas över — den fäller inte de // andra. // // Fyndet kom ur integrationstestet: `oppnaKuvert` kastade, och eftersom // den här funktionen laddar HELA organisationens nycklar gjorde en enda // kuverterad nyckel varje annat ärende i organisationen oläsbart när // huvudnyckeln saknades. En delvis migrerad organisation blev alltså // helt stum, av ett fel som bara gällde ett ärende. // // Att hoppa över är dessutom rätt utfall i sak: en nyckel som inte går // att öppna är precis vad krypto-shredding lämnar efter sig, och // öppnaHändelse maskerar då fälten — vilket är vad som ska hända. // // Men tyst får det inte vara. Saknad huvudnyckel är ett // konfigurationsfel, och ett sådant ska synas i driften. const nycklar = new Map(); let ejOppnade = 0; for (const r of rader.rows) { try { nycklar.set(r.id, oppnaKuvert(r.nyckel)); } catch { ejOppnade += 1; } } if (ejOppnade > 0) { logga("varning", "personnycklar kunde inte öppnas", { organisation: orgId, antal: ejOppnade, konsekvens: "Berörda fält maskeras. Kontrollera PERSONNYCKEL_HUVUD.", }); } return nycklar; } /** * Läslogg. Skrivningar loggades redan; läsningar gjorde det inte, vilket * gjorde det omöjligt att svara på vem som sett en kunds uppgifter * (QUALITET M-4). Misslyckas loggningen svarar vi ändå — en trasig * revisionslogg får inte bli ett driftavbrott, men den ska synas. */ function loggaAtkomst(req, res, { org, anvandare, arende, delningskod }) { pool .query( `insert into atkomstlogg (organisation_id, anvandare_id, arende_id, vag, kalla, delningskod) values ($1, $2, $3, $4, $5, $6)`, [org ?? null, anvandare ?? null, arende ?? null, req.url?.slice(0, 500) ?? "", kallaFor(req), delningskod ?? null], ) .catch((fel) => logga("fel", "åtkomstlogg misslyckades", { spårId: res.spår.spårId, orsak: fel?.message })); } /** * Levererar en utgående händelse till organisationens prenumeranter. * * Leveransen är signerad och sker i bakgrunden: en mottagare som är nere * får inte hindra teknikern från att arbeta. Utfallet skrivs på * prenumerationen, så ett trasigt mottagarsystem syns i inställningarna * i stället för att tyst sluta få data (ALVA-SPEC-021). */ function leverera(orgId, handelse, nyttolast) { pool .query( `select id, url, hemlighet_krypt from prenumerationer where organisation_id = $1 and aktiv and $2 = any(handelser)`, [orgId, handelse], ) .then(async ({ rows }) => { for (const p of rows) { const kropp = JSON.stringify({ handelse, tid: new Date().toISOString(), ...nyttolast }); const t = Math.floor(Date.now() / 1000); let status = "ok"; try { const svar = await fetch(p.url, { method: "POST", headers: { "content-type": "application/json", "alva-signatur": signeraLeverans(kropp, dekryptera(p.hemlighet_krypt), t, createHmac), "alva-handelse": handelse, }, body: kropp, signal: AbortSignal.timeout(10_000), }); if (!svar.ok) status = `HTTP ${svar.status}`; } catch (fel) { status = fel?.message?.slice(0, 200) ?? "okänt fel"; } await pool.query( `update prenumerationer set senast_levererad = now(), senaste_status = $2 where id = $1`, [p.id, status], ); } }) .catch((fel) => logga("fel", "leverans misslyckades", { handelse, orsak: fel?.message })); } function kravAuth(req, hemlighet) { const auth = req.headers.authorization ?? ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; return token ? verifieraJwt(token, hemlighet) : null; } // En giltig signatur räcker inte. Kontot måste fortfarande vara aktivt, // och token-versionen måste stämma med kontots — annars har den // återkallats. Ett uppslag på primärnyckeln per anrop, vilket gör // återkallelsen omedelbar i stället för att gälla vid nästa utgång. async function kontoGiltigt(anspr) { const rad = await pool.query( `select aktiv, token_version from anvandare where id = $1 and organisation_id = $2`, [anspr.sub, anspr.org], ); if (rad.rowCount === 0) return false; if (rad.rows[0].aktiv === false) return false; return (anspr.tv ?? 0) === rad.rows[0].token_version; } // ---- Fakturanummer utan luckor ---------------------------------------- // // En sequence hade varit billigare, men den lämnar luckor: numret dras // även när transaktionen rullas tillbaka. En nummerserie med hål i är // inte ett bokföringsunderlag, den är en lista. // // Låset serialiserar utfärdandet mot alla repliker — inte bara mot den // här processen, vilket är skillnaden mot att räkna i minnet. Fakturor // utfärdas sällan, så kostnaden är teoretisk. async function medFakturanummer(arbete) { const klient = await pool.connect(); try { await klient.query("begin"); await klient.query("lock table fakturor in exclusive mode"); const rad = await klient.query(`select coalesce(max(nummer), 0) + 1 as nasta from fakturor`); const resultat = await arbete(klient, Number(rad.rows[0].nasta)); await klient.query("commit"); return resultat; } catch (fel) { await klient.query("rollback").catch(() => {}); throw fel; } finally { klient.release(); } } // Supportnummer, samma resonemang som fakturanumret: låset serialiserar // mot alla repliker, och en sequence hade lämnat luckor vid rollback. // Serien är dock inte ett bokföringsunderlag, så luckor vore uthärdliga — // det är enhetligheten som motiverar samma lösning, inte kravet. async function medSupportnummer(arbete) { const klient = await pool.connect(); try { await klient.query("begin"); await klient.query("lock table supportarenden in exclusive mode"); const rad = await klient.query(`select coalesce(max(nummer), 0) + 1 as nasta from supportarenden`); const resultat = await arbete(klient, Number(rad.rows[0].nasta)); await klient.query("commit"); return resultat; } catch (fel) { await klient.query("rollback").catch(() => {}); throw fel; } finally { klient.release(); } } /** * Abonnemangets nuvarande nivå och tillstånd. * * Nivån är senaste nivåhändelsen; saknas den är den Free. Tillståndet * härleds ur organisationens obetalda, förfallna fakturor. */ async function abonnemanget(orgId, nu = new Date()) { const rad = await pool.query( `select a.registrerad, a.senast_fakturerad, (select varde from abonnemangshandelser where organisation_id = a.organisation_id and typ = 'niva' order by skapad desc limit 1) as niva, (select varde from abonnemangshandelser where organisation_id = a.organisation_id and typ = 'fakturaepost' order by skapad desc limit 1) as fakturaepost from abonnemang a where a.organisation_id = $1`, [orgId], ); if (rad.rowCount === 0) return null; const fakturor = await pool.query( `select f.beteckning, f.forfaller, f.totalt, coalesce(json_agg(json_build_object('typ', h.typ)) filter (where h.id is not null), '[]') as handelser from fakturor f left join fakturahandelser h on h.faktura_id = f.id where f.organisation_id = $1 group by f.id`, [orgId], ); const status = abonnemangstillstand( fakturor.rows.map((f) => ({ ...f, status: fakturastatus(f.handelser) })), nu, ); const niva = nivaFor(rad.rows[0].niva ?? "free"); return { ...rad.rows[0], niva: niva.id, nivanamn: niva.namn, ...status, behorighet: behorighet(status.tillstand), besked: besked(status), }; } // Fakturan med sitt härledda tillstånd. Statusen finns inte i tabellen // och kan inte finnas där: raden är oföränderlig. async function fakturamedStatus(id) { const rad = await pool.query( `select f.id, f.organisation_id, f.beteckning, f.dokument, coalesce(json_agg(json_build_object('typ', h.typ)) filter (where h.id is not null), '[]') as handelser from fakturor f left join fakturahandelser h on h.faktura_id = f.id where f.id = $1 group by f.id`, [id], ); if (rad.rowCount === 0) return null; return { ...rad.rows[0], status: fakturastatus(rad.rows[0].handelser) }; } /** * Kanonisk JSON: nycklar i bokstavsordning, hela vägen ned. * * Två anrop med samma innehåll men olika fältordning är samma händelse, * och ska ge samma avtryck. JSON.stringify ensam bevarar * insättningsordningen och hade gjort ordningen till en skillnad. */ function kanoniskt(värde) { if (Array.isArray(värde)) return `[${värde.map(kanoniskt).join(",")}]`; if (värde && typeof värde === "object") { // Nycklar med undefined SLÄPPS, precis som JSON.stringify gör. // // Kedjesvepet hittade felet första natten det fanns: protokollvägen // bygger händelser med `enhet: plocka(...) ?? undefined`, och den // här funktionen serialiserade nyckeln som null medan jsonb — som // får objektet via JSON.stringify — släppte den. Skriv-digest och // läs-digest skilde sig, och varje leverantörshändelse bröt sin // egen kedjelänk. Klienthändelser gick fria av en slump: JSON.parse // kan inte producera undefined. Kanonisk form måste vara EXAKT den // form som överlever databasens rundresa, inte en nästan likadan. return `{${Object.keys(värde) .filter((n) => värde[n] !== undefined) .sort() .map((n) => `${JSON.stringify(n)}:${kanoniskt(värde[n])}`) .join(",")}}`; } return JSON.stringify(värde ?? null); } /** * Mätdonets fakta hämtas ur registret, inte ur anropet (TÜV T-2). * * Evidensgraden E4 kräver att det går att visa VAD som mätte och att * instrumentet var kalibrerat. Bägge fälten kom tidigare från klienten * och lagrades oprövade — en organisation utan ett enda registrerat * mätdon kunde skicka kalibrering 2099-12-31 och få E4. * * Ett okänt mätdon avvisas. Ett UTGÅNGET avvisas inte: mätningen gjordes, * och att vägra spara den vore att radera evidens. I stället följer * registrets datum med, och graderingen faller till E1 på registrets * uppgift i stället för på klientens. */ async function mätdonsFakta(organisationId, handelse) { if (handelse?.typ !== "matvarde") return { handelse }; // Utan angivet mätdon är det teknikerns avläsning av en siffra. Det är // ett giltigt mätvärde — bara inte ett spårbart. Påstådd beteckning // eller kalibrering utan mätdon tas bort: den kan inte styrkas. if (!handelse.matdonId) { const { matdonBeteckning, matdonKalibreradTill, kalibreradVidMatning, ...rest } = handelse; return { handelse: rest }; } const rad = await pool.query( `select beteckning, kalibrerad_till from matdon where organisation_id = $1 and id::text = $2 and aktiv`, [organisationId, String(handelse.matdonId)], ); if (rad.rowCount === 0) { return { fel: `Okänt mätdon: ${handelse.matdonId}. Registrera instrumentet innan mätvärdet sparas.` }; } const härledd = { ...handelse, matdonBeteckning: rad.rows[0].beteckning }; // Ett mätdon utan kalibreringsdatum i registret får inget datum alls — // inte klientens. Fältet utelämnas, och graderingen faller till E1. delete härledd.matdonKalibreradTill; // Om kalibreringen GÄLLDE vid mottagandet stämplas här, medan det // fortfarande är ett faktum och inte en fråga till klockan. Grinden // och säkerhetstaket läser stämpeln, aldrig datumet mot "nu": samma // logg ska ge samma utfall om tio år (TÜV-2 T-13). Klientens eget // påstående om fältet skrevs just över av spridningen ovan — det är // avsikten, inte en bieffekt. härledd.kalibreradVidMatning = false; if (rad.rows[0].kalibrerad_till) { härledd.matdonKalibreradTill = new Date(rad.rows[0].kalibrerad_till).toISOString().slice(0, 10); härledd.kalibreradVidMatning = härledd.matdonKalibreradTill >= new Date().toISOString().slice(0, 10); } return { handelse: härledd }; } // Verifierar att ärendet tillhör användarens organisation. async function arendeIOrg(arendeId, organisationId) { const rader = await pool.query( `select 1 from felsokning_arenden where id = $1 and organisation_id = $2`, [arendeId, organisationId], ); return rader.rowCount > 0; } // Adresser som aldrig får nås utifrån ett kundkonfigurerat uppslag: // loopback, privata nät, link-local (inkl. molnens metadatatjänst), // CGNAT och IPv6-motsvarigheterna. export function arPrivatAdress(adress) { const v4 = adress.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (v4) { const [a, b] = v4.slice(1).map(Number); return ( a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224 ); } const v6 = adress.toLowerCase().replace(/^\[|\]$/g, ""); if (v6 === "::1" || v6 === "::") return true; // Unika lokala adresser (fc00::/7), link-local (fe80::/10) och // IPv4-mappade adresser som ::ffff:127.0.0.1. if (/^f[cd]/.test(v6) || /^fe[89ab]/.test(v6)) return true; const mappad = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); return mappad ? arPrivatAdress(mappad[1]) : false; } // Slår upp värdnamnet och avgör om något av svaren pekar inåt. Namn som // resolvar till interna adresser fångas också — inte bara IP-literaler. export async function pekarInat(url, slaUpp = lookup) { let vard; try { vard = new URL(url).hostname; } catch { return "ogiltig URL"; } const bar = vard.replace(/^\[|\]$/g, ""); if (/^[\d.]+$/.test(bar) || bar.includes(":")) { return arPrivatAdress(bar) ? bar : null; } if (bar === "localhost" || bar.endsWith(".localhost") || bar.endsWith(".internal") || bar.endsWith(".local")) { return bar; } try { const traffar = await slaUpp(bar, { all: true }); const intern = traffar.find((t) => arPrivatAdress(t.address)); return intern ? intern.address : null; } catch { // Namnet går inte att slå upp — låt anropet självt misslyckas i // stället för att påstå något om var det pekar. return null; } } // Generiskt uppslag mot en leverantör. All variation ligger i registret // (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs- // specifika kodgrenar. export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) { const u = def.uppslag ?? {}; const mall = uppgifter[u.urlFalt ?? "bas_url"]; if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) { return { ok: false, fel: "Bas-URL saknas eller är ogiltig." }; } let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare)); // Bas-URL:en sätts av kundens administratör men anropet görs av vår // server. Utan spärr blir det en väg in i klustrets interna nät och // molnets metadatatjänst (169.254.169.254) — tenantens administratör // är inte infrastrukturens ägare. Interna mål tillåts bara när driften // uttryckligen öppnat för det (verkstäder med OEM-server på egna nätet). if (process.env.TILLAT_INTERNA_UPPSLAG !== "true") { const internt = await pekarInat(url); if (internt) return { ok: false, fel: `Bas-URL:en pekar på en intern adress (${internt}) och tillåts inte.` }; } const headers = { Accept: "application/json" }; if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`; if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt]; if (u.auth === "basic") { const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`; headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`; } if (u.auth === "query") { url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`; } try { const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) }); if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` }; const data = await svarFran.json(); const fordon = {}; for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) { const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data); if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim(); } if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." }; return { ok: true, fordon }; } catch (fel) { return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." }; } } // ---- Server ----------------------------------------------------------- // Lämnar ut innehållet — men bara efter att det kontrollerats mot // hashen i loggen. Stämmer det inte säger vi det rakt ut i stället för // att visa en bild som kan ha bytts ut. async function skickaBilaga(res, rad) { const data = await res.spann.mät("bilaga_las", () => BILAGELAGER.hamta(rad.hash)); if (!data) return svara(res, 404, { error: "Innehållet saknas i lagringen." }); if (innehallsHash(data) !== rad.hash) { logga("fel", "bilagans innehåll stämmer inte med hashen i loggen", { hash: rad.hash }); return svara(res, 409, { error: "Innehållet stämmer inte med det som dokumenterades." }); } res.writeHead(200, { "Content-Type": rad.mediatyp, "Content-Length": data.length, // Innehållsadresserat — samma id ger alltid samma bytes. "Cache-Control": "private, max-age=31536000, immutable", ...korsHuvuden(res), }); return res.end(data); } export function skapaServer() { function loggaIn(res, rad, hemlighet) { const nu = Math.floor(Date.now() / 1000); const token = skapaJwt( { sub: rad.id, namn: rad.namn, org: rad.organisation_id, roll: rad.roll, // Bärs med så att en återkallelse gör token ogiltig direkt. tv: rad.token_version ?? 0, // Modellanropen kan stängas av per organisation (QUALITET C-4). // Flaggan bärs i token så att orkestern kan neka utan att själv // behöva databasåtkomst — den vet redan vem som frågar. ai: rad.ai_tillaten !== false, iat: nu, exp: nu + TOKEN_LIVSTID_S, }, hemlighet, ); return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn }); } return createServer(async (req, res) => { res.ursprung = ursprungFor(req); // Spåret följer med genom hela kedjan. Kommer inget huvud in startar // vi ett nytt — de flesta anrop kommer utifrån. res.spår = spårFrån(req.headers.traceparent); res.spann = starta("plattform", res.spår); res.setHeader("traceparent", traceparent(res.spår)); res.on("finish", () => { avsluta(res.spann, { status: res.statusCode, // Ärende- och bilage-id ersätts så att vägen blir en dimension // med rimligt antal värden i stället för en per ärende. väg: (req.url ?? "/").split("?")[0].replace(/\/[A-Za-z0-9_-]{8,}/g, "/:id"), extra: { metod: req.method }, }); }); if (req.method === "OPTIONS") { res.writeHead(204, { ...korsHuvuden(res), "Access-Control-Allow-Headers": "authorization, content-type", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", }); return res.end(); } const url = new URL(req.url ?? "/", "http://intern"); const vag = url.pathname; try { if (req.method === "GET" && vag === "/halsa") { return svara(res, 200, { status: "ok" }); } if (req.method === "GET" && vag === "/api/openapi.yaml") { res.writeHead(200, { "Content-Type": "application/yaml; charset=utf-8", ...korsHuvuden(res), }); return res.end(OPENAPI); } const hemlighet = process.env.JWT_SECRET; if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." }); // -- Registrering: skapar organisation + systemadministratör -- if (req.method === "POST" && vag === "/api/auth/registrera") { if (process.env.REGISTRERING_OPPEN === "false") { return svara(res, 403, { error: "Registrering är stängd — kontakta er administratör." }); } const { epost, losenord, namn, organisation } = await lasKropp(req); if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) { return svara(res, 400, { error: "Ange organisation, namn, e-post och lösenord (minst 8 tecken).", }); } const klientDb = await pool.connect(); try { await klientDb.query("begin"); const org = await klientDb.query( `insert into organisationer (namn) values ($1) returning id, namn`, [organisation.trim()], ); const rad = await klientDb.query( `insert into anvandare (organisation_id, epost, losen_hash, namn, roll) values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, 'admin') on conflict (epost) do nothing returning id, namn, organisation_id, roll, token_version`, [org.rows[0].id, epost.trim(), losenord, namn.trim()], ); if (rad.rowCount === 0) { await klientDb.query("rollback"); return svara(res, 409, { error: "E-postadressen är redan registrerad." }); } // Abonnemanget startar på Free vid registreringen. Ingen provperiod // som tyst börjar kosta — kontot stannar på Free tills någon väljer // annat (ALVA-PROC-0002). await klientDb.query( `insert into abonnemang (organisation_id) values ($1) on conflict do nothing`, [org.rows[0].id], ); await klientDb.query( `insert into abonnemangshandelser (organisation_id, typ, varde) values ($1, 'niva', 'free')`, [org.rows[0].id], ); await klientDb.query("commit"); return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet); } catch (fel) { await klientDb.query("rollback"); throw fel; } finally { klientDb.release(); } } if (req.method === "POST" && vag === "/api/auth/logga-in") { const { epost, losenord } = await lasKropp(req); const normaliserad = (epost ?? "").trim().toLowerCase(); const kalla = kallaFor(req); if (await inloggningSparrad(normaliserad, kalla)) { return svara(res, 429, { error: "För många misslyckade försök — vänta en stund och försök igen." }); } const rader = await pool.query( `select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn, coalesce((o.installningar->>'ai_tillaten')::boolean, true) as ai_tillaten from anvandare a join organisationer o on o.id = a.organisation_id where a.epost = $1 and a.losen_hash = crypt($2, a.losen_hash)`, [normaliserad, losenord ?? ""], ); if (rader.rowCount === 0) { await loggaForsok(normaliserad, kalla, false); return svara(res, 401, { error: "Fel e-post eller lösenord." }); } // Ett avaktiverat konto räknas som misslyckat försök: annars blir // svarstiden ett sätt att lista ut vilka konton som finns. if (rader.rows[0].aktiv === false) { await loggaForsok(normaliserad, kalla, false); return svara(res, 403, { error: "Kontot är avstängt — kontakta er administratör." }); } await loggaForsok(normaliserad, kalla, true); return loggaIn(res, rader.rows[0], hemlighet); } // -- Publik delning (Live Share via delningskod) -- // Behörighetsnivån styr filtreringen på serversidan: // kund – det kunddelbara (inga kategoribyten, hypoteser, AI-dialog) // partner – försäkringsbolag/tillverkare: även hypoteser (tydligt märkta) // intern – full insyn const delad = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)$/); if (req.method === "GET" && delad) { let arendeId = null; let niva = "kund"; const delning = await pool.query( `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, [delad[1]], ); if (delning.rowCount > 0) { arendeId = delning.rows[0].arende_id; niva = delning.rows[0].niva; } else { // Bakåtkompatibelt: ärendets ursprungliga delningskod = kundnivå. const viaArende = await pool.query( `select id from felsokning_arenden where delningskod = $1`, [delad[1]], ); if (viaArende.rowCount > 0) arendeId = viaArende.rows[0].id; } if (!arendeId) return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); const arende = await pool.query( `select id, nummer, skapad from felsokning_arenden where id = $1`, [arendeId], ); const synliga = synligaTyper(niva); const handelser = synliga ? await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 and handelse->>'typ' = any($2) order by sekvens nulls first, tidpunkt, id`, [arendeId, synliga], ) : await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 order by sekvens nulls first, tidpunkt, id`, [arendeId], ); return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva }); } // Bilaga via delningslänk. Bilden får bara hämtas om den hör till // en händelse som nivån faktiskt får se — annars vore det en väg // runt delningsfiltret. const delatBilaga = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/bilagor\/([A-Za-z0-9_-]+)$/); if (req.method === "GET" && delatBilaga) { const delning = await pool.query( `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, [delatBilaga[1]], ); if (delning.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." }); const synliga = synligaTyper(delning.rows[0].niva); const rad = await pool.query( `select b.hash, b.mediatyp from bilagor b where b.id = $1 and b.arende_id = $2 and exists ( select 1 from felsokning_handelser h where h.arende_id = b.arende_id and h.handelse->>'bilagaId' = b.id and ($3::text[] is null or h.handelse->>'typ' = any($3)) )`, [delatBilaga[2], delning.rows[0].arende_id, synliga], ); if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." }); return skickaBilaga(res, rad.rows[0]); } // -- Publikt kundgodkännande (den enda skrivande publika vägen) -- // // Kunden svarar på ett åtgärdsförslag via sin delningslänk. Spärrar: // 1. endast delningar på kundnivå (partner/intern får inte svara // åt kunden), och aldrig återkallade // 2. det måste finnas ett åtgärdsförslag att svara på // 3. ett beslut per ärende — svaret kan inte ändras i efterhand // 4. takt-begränsning per kod // 5. beslutet får bara vara godkant/avbojt/delvis + kort kommentar; // inget annat kan skrivas till loggen den här vägen const beslutVag = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/beslut$/); if (req.method === "POST" && beslutVag) { const kod = beslutVag[1]; if (forTataForsok(kod)) return svara(res, 429, { error: "För många försök — vänta en stund." }); const delning = await pool.query( `select arende_id, niva from delningar where kod = $1 and aterkallad is null`, [kod], ); if (delning.rowCount === 0 || delning.rows[0].niva !== "kund") { return svara(res, 404, { error: "Delningen är inte tillgänglig." }); } const arendeId = delning.rows[0].arende_id; const { beslut, kommentar } = await lasKropp(req); if (!["godkant", "avbojt", "delvis"].includes(beslut)) { return svara(res, 400, { error: "Ogiltigt beslut." }); } if (kommentar !== undefined && (typeof kommentar !== "string" || kommentar.length > 500)) { return svara(res, 400, { error: "Kommentaren är för lång." }); } const forslag = await pool.query( `select 1 from felsokning_handelser where arende_id = $1 and handelse->>'typ' = 'atgardsforslag' limit 1`, [arendeId], ); if (forslag.rowCount === 0) { return svara(res, 409, { error: "Det finns inget åtgärdsförslag att svara på." }); } const tidigare = await pool.query( `select 1 from felsokning_handelser where arende_id = $1 and handelse->>'typ' = 'kundbeslut' limit 1`, [arendeId], ); if (tidigare.rowCount > 0) { return svara(res, 409, { error: "Ett besked är redan registrerat — kontakta verkstaden." }); } const handelse = { typ: "kundbeslut", beslut, kanal: "Delningslänk", ...(kommentar?.trim() ? { kommentar: kommentar.trim() } : {}), }; // Kedjat som allt annat — kundens besked är den händelse som // oftast åberopas i en tvist, och just den fick inte stå utanför. await skrivKedjat(arendeId, [ { id: `kb-${nyKod()}`, tidpunkt: new Date().toISOString(), anvandare: "Kund via delningslänk", handelse, }, ]); return svara(res, 200, { ok: true }); } // ---- Fakturering: utfärdarens sida (ALVA-PROC-0001) ------------- // // Ligger före inloggningsspärren därför att utfärdaren inte är en // användare i någon organisation. Nyckeln är hela behörigheten, och // varje väg nedan kräver den innan den läser något. if (req.method === "POST" && vag === "/api/fakturor") { if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); const kropp = await lasKropp(req); const { organisation_id, period, utfardad } = kropp; // Beloppet tas aldrig emot. Kommer det ändå är det ett tecken på // att anroparen tror sig kunna sätta det, och det ska avvisas // högt i stället för att tigas ihjäl — samma hållning som mot // okända fält i händelseschemat. const forbjudna = ["rader", "netto", "moms", "totalt", "belopp", "status"].filter((n) => n in kropp); if (forbjudna.length > 0) { return svara(res, 400, { error: `Beloppet härleds ur organisationens tillstånd och kan inte anges (${forbjudna.join(", ")}).`, }); } const periodfel = granskaPeriod(period); if (periodfel) return svara(res, 400, { error: periodfel }); const utfardadDag = utfardad ?? new Date().toISOString().slice(0, 10); if (Number.isNaN(Date.parse(utfardadDag))) return svara(res, 400, { error: "Utfärdandedatum är ogiltigt." }); const org = await pool.query(`select id, namn, installningar from organisationer where id = $1`, [ organisation_id, ]); if (org.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." }); // Underlaget hämtas ur organisationens faktiska tillstånd, inte // ur anropet: de konton som verkligen kan logga in, och de // moduler som verkligen är påslagna. En summa som härleds kan // inte säga emot verkligheten. const aktiva = await pool.query( `select count(*)::int as antal from anvandare where organisation_id = $1 and aktiv`, [organisation_id], ); const installningar = org.rows[0].installningar ?? {}; const faktura = await medFakturanummer(async (klient, nummer) => { const dokument = fakturera({ nummer, org: { namn: org.rows[0].namn, moduler: installningar.moduler ?? [] }, aktiva: aktiva.rows[0].antal, period, utfardad: utfardadDag, }); const rad = await klient.query( `insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, dokument) values ($1, $2, $3, $4, $5, $6, $7, $8) returning id`, [ organisation_id, nummer, dokument.beteckning, dokument.utfardad, dokument.forfaller, dokument.valuta, dokument.totalt, JSON.stringify(dokument), ], ); return { id: rad.rows[0].id, ...dokument }; }); logga("info", "faktura utfärdad", { beteckning: faktura.beteckning, organisation: organisation_id }); return svara(res, 201, faktura); } // Betalning registreras av en människa när pengarna kommit in. // Systemet påstår aldrig av sig självt att något är betalt. // // Ingen update: fakturaraden är oföränderlig. Betalningen är en // egen post, och statusen härleds ur posterna. const betaldVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/betald$/); if (req.method === "POST" && betaldVag) { if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); const { betaldatum, referens } = await lasKropp(req); if (typeof referens !== "string" || referens.trim().length < 3) { return svara(res, 400, { error: "En betalningsreferens krävs — annars går betalningen inte att spåra." }); } const dag = betaldatum ?? new Date().toISOString().slice(0, 10); if (Number.isNaN(Date.parse(dag))) return svara(res, 400, { error: "Betaldatum är ogiltigt." }); const nuvarande = await fakturamedStatus(betaldVag[1]); if (!nuvarande) return svara(res, 404, { error: "Fakturan finns inte." }); // Enkelriktat: en betald faktura betalas inte igen, och en // krediterad faktura betalas inte alls. if (nuvarande.status !== "utfardad") { return svara(res, 409, { error: `Fakturan är redan ${nuvarande.status}.` }); } await pool.query( `insert into fakturahandelser (faktura_id, typ, intraffade, uppgift, registrerad_av) values ($1, 'betald', $2, $3, $4)`, [betaldVag[1], dag, referens.trim(), "Utfärdaren"], ); return svara(res, 200, { beteckning: nuvarande.beteckning, status: "betald" }); } // Rättelse. En utfärdad faktura ändras aldrig — den bemöts av en // kreditfaktura som pekar tillbaka, och den kräver ett skäl. const krediteraVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/kreditera$/); if (req.method === "POST" && krediteraVag) { if (!arUtfardare(req)) return svara(res, 403, { error: "Kräver utfärdarens nyckel." }); const { orsak, utfardad } = await lasKropp(req); const dag = utfardad ?? new Date().toISOString().slice(0, 10); if (Number.isNaN(Date.parse(dag))) return svara(res, 400, { error: "Utfärdandedatum är ogiltigt." }); const nuvarande = await fakturamedStatus(krediteraVag[1]); if (!nuvarande) return svara(res, 404, { error: "Fakturan finns inte." }); if (nuvarande.status === "krediterad") return svara(res, 409, { error: "Fakturan är redan krediterad." }); // Skälet granskas av modulen, inte här: kravet på ett granskbart // varför hör till faktureringen, inte till transportlagret. const { kredit, fel } = kreditera(nuvarande.dokument, { nummer: 0, utfardad: dag, orsak }); if (fel) return svara(res, 400, { error: fel }); const skapad = await medFakturanummer(async (klient, nummer) => { const dokument = { ...kredit, beteckning: fakturabeteckning(nummer) }; const rad = await klient.query( `insert into fakturor (organisation_id, nummer, beteckning, utfardad, forfaller, valuta, totalt, krediterar, dokument) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id`, [ nuvarande.organisation_id, nummer, dokument.beteckning, dokument.utfardad, dokument.forfaller, dokument.valuta, dokument.totalt, nuvarande.id, JSON.stringify(dokument), ], ); // Krediteringen noteras också på originalet, så dess status // följer med utan att raden rörs. await klient.query( `insert into fakturahandelser (faktura_id, typ, intraffade, uppgift, registrerad_av) values ($1, 'krediterad', $2, $3, $4)`, [nuvarande.id, dag, orsak.trim(), "Utfärdaren"], ); return { id: rad.rows[0].id, ...dokument }; }); logga("info", "faktura krediterad", { krediterar: nuvarande.beteckning, ny: skapad.beteckning }); return svara(res, 201, skapad); } // -- Skyddade endpoints (organisationsknutna) -- const anspr = kravAuth(req, hemlighet); if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." }); if (!(await kontoGiltigt(anspr))) { return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." }); } // ---- Abonnemang (ALVA-PROC-0002) -------------------------------- if (req.method === "GET" && vag === "/api/abonnemang") { const a = await abonnemanget(anspr.org); if (!a) return svara(res, 404, { error: "Inget abonnemang är registrerat." }); return svara(res, 200, { ...a, nivaer: NIVAER }); } if (req.method === "POST" && vag === "/api/abonnemang/niva") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { niva } = await lasKropp(req); if (!NIVAER.some((n) => n.id === niva)) return svara(res, 400, { error: "Okänd nivå." }); // Nivån är en HÄNDELSE. Vilken nivå som gällde när en faktura // utfärdades måste gå att svara på i efterhand, och en kolumn som // skrivits över kan inte svara. await pool.query( `insert into abonnemangshandelser (organisation_id, typ, varde, satt_av) values ($1, 'niva', $2, $3)`, [anspr.org, niva, anspr.sub], ); logga("info", "abonnemangsnivå ändrad", { organisation: anspr.org, niva }); return svara(res, 200, { ok: true, niva }); } if (req.method === "POST" && vag === "/api/abonnemang/fakturaepost") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { epost } = await lasKropp(req); // Tom sträng är ett giltigt val: kunden hämtar fakturan själv. const rensad = String(epost ?? "").trim(); if (rensad && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(rensad)) { return svara(res, 400, { error: "Ogiltig e-postadress." }); } await pool.query( `insert into abonnemangshandelser (organisation_id, typ, varde, satt_av) values ($1, 'fakturaepost', $2, $3)`, [anspr.org, rensad, anspr.sub], ); return svara(res, 200, { ok: true, epost: rensad }); } // Fakturan som PDF. Kunden hämtar den själv — utskicket per e-post // är ett tillägg, inte den enda vägen till sitt eget underlag. const pdfVag = vag.match(/^\/api\/fakturor\/([0-9a-fA-F-]{36})\/pdf$/); if (req.method === "GET" && pdfVag) { const rad = await pool.query( `select organisation_id, beteckning, dokument from fakturor where id = $1`, [pdfVag[1]], ); if (rad.rowCount === 0 || rad.rows[0].organisation_id !== anspr.org) { return svara(res, 404, { error: "Fakturan är inte tillgänglig." }); } const pdf = fakturaPdf(rad.rows[0].dokument); res.writeHead(200, { "Content-Type": "application/pdf", "Content-Length": pdf.length, "Content-Disposition": `attachment; filename="${rad.rows[0].beteckning}.pdf"`, ...korsHuvuden(res), }); return res.end(pdf); } // ---- Support och felanmälan (ALVA-PROC-0050) ------------------- // // ALLA inloggade får anmäla, inklusive teknikern. En tröskel här // ger färre anmälningar, inte färre fel — och de fel som inte // anmäls är de som lever längst. if (vag === "/api/support") { if (req.method === "GET") { const rader = await pool.query( `select s.id, s.beteckning, s.arende_id, s.typ, s.rubrik, s.beskrivning, s.sammanhang, s.skapad, a.namn as anmald_av, coalesce(json_agg(json_build_object( 'typ', i.typ, 'text', i.text, 'status', i.status, 'fran', i.fran, 'skapad', i.skapad) order by i.skapad) filter (where i.id is not null), '[]') as inlagg from supportarenden s left join anvandare a on a.id = s.skapad_av left join supportinlagg i on i.support_id = s.id where s.organisation_id = $1 group by s.id, a.namn order by s.nummer desc limit 200`, [anspr.org], ); return svara(res, 200, { arenden: rader.rows.map(({ inlagg, ...r }) => ({ ...r, inlagg, // Statusen lagras inte, den härleds. Se supportstatus(). status: supportstatus(inlagg), })), }); } if (req.method === "POST") { const kropp = await lasKropp(req); const fel = granskaAnmalan(kropp); if (fel) return svara(res, 400, { error: fel }); // Ärendet måste vara vårt om det anges. En anmälan är inte en // väg runt organisationsgränsen. if (kropp.arendeId && !(await arendeIOrg(kropp.arendeId, anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } // Sammanhanget HÄRLEDS. Klienten får skicka tekniska fält, men // byggSammanhang() släpper bara igenom de kända — ett fordon // eller en kund kan inte smugglas in i en supportanmälan. const kontext = byggSammanhang({ ...kropp.sammanhang, arendeId: kropp.arendeId, sparId: res.spår.spårId, plattformsversion: kropp.sammanhang?.plattformsversion, }); const skapat = await medSupportnummer(async (klient, nummer) => { const rad = await klient.query( `insert into supportarenden (organisation_id, arende_id, nummer, beteckning, typ, rubrik, beskrivning, sammanhang, skapad_av) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id, beteckning, skapad`, [ anspr.org, kropp.arendeId ?? null, nummer, supportbeteckning(nummer), kropp.typ, kropp.rubrik.trim(), kropp.beskrivning.trim(), JSON.stringify(kontext), anspr.sub, ], ); return rad.rows[0]; }); logga("info", "supportärende skapat", { beteckning: skapat.beteckning, typ: kropp.typ, organisation: anspr.org, spårId: res.spår.spårId, }); return svara(res, 201, { ...skapat, status: "mottagen" }); } } // Svar och statusbyten. Verkstaden får svara i sitt eget ärende; // status sätts bara av vår support, med egen nyckel. const supportInlagg = vag.match(/^\/api\/support\/([0-9a-fA-F-]{36})\/inlagg$/); if (req.method === "POST" && supportInlagg) { const { text, status } = await lasKropp(req); const agare = await pool.query( `select organisation_id from supportarenden where id = $1`, [supportInlagg[1]], ); if (agare.rowCount === 0 || agare.rows[0].organisation_id !== anspr.org) { return svara(res, 404, { error: "Supportärendet är inte tillgängligt." }); } if (status !== undefined) { if (!arSupport(req)) return svara(res, 403, { error: "Status sätts av supporten." }); if (!STATUSAR.includes(status)) return svara(res, 400, { error: "Okänd status." }); } if (typeof text !== "string" || text.trim().length < 2) { return svara(res, 400, { error: "Skriv något." }); } await pool.query( `insert into supportinlagg (support_id, typ, text, status, fran, skapad_av) values ($1, $2, $3, $4, $5, $6)`, [ supportInlagg[1], status !== undefined ? "status" : "svar", text.trim(), status ?? null, arSupport(req) ? "Support" : anspr.namn, arSupport(req) ? null : anspr.sub, ], ); return svara(res, 200, { ok: true }); } // Organisationens egna fakturor. Läsning, aldrig mer: kunden ser // vad den ska betala och varför, men utfärdar inget och bokför // inget. Kommersiella uppgifter — därför administratör. if (req.method === "GET" && vag === "/api/fakturor") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const rader = await pool.query( `select f.id, f.beteckning, f.utfardad, f.forfaller, f.valuta, f.totalt, k.beteckning as krediterar, f.dokument, coalesce(json_agg(json_build_object('typ', h.typ, 'intraffade', h.intraffade)) filter (where h.id is not null), '[]') as handelser from fakturor f left join fakturor k on k.id = f.krediterar left join fakturahandelser h on h.faktura_id = f.id where f.organisation_id = $1 group by f.id, k.beteckning order by f.nummer desc`, [anspr.org], ); return svara(res, 200, { fakturor: rader.rows.map(({ handelser, dokument, ...f }) => ({ ...f, // Statusen lagras inte, den härleds. Se fakturastatus(). status: fakturastatus(handelser), rader: dokument.rader, netto: dokument.netto, moms: dokument.moms, momssats: dokument.momssats, period: dokument.period, betalningssatt: dokument.betalningssatt, })), }); } // Användarhantering: endast systemadministratör, endast egen org. if (vag === "/api/anvandare") { // Läsning: admin + arbetsledare (behövs för omfördelning). // Skapande: endast admin. if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); if (req.method === "GET") { const rader = await pool.query( `select id, epost, namn, roll, aktiv from anvandare where organisation_id = $1 order by namn`, [anspr.org], ); return svara(res, 200, { anvandare: rader.rows }); } if (req.method === "POST") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { epost, losenord, namn, roll } = await lasKropp(req); if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) { return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." }); } const rad = await pool.query( `insert into anvandare (organisation_id, epost, losen_hash, namn, roll) values ($1, lower($2), crypt($3, gen_salt('bf', 12)), $4, $5) on conflict (epost) do nothing returning id, epost, namn, roll, aktiv`, [anspr.org, epost.trim(), losenord, namn.trim(), roll], ); if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." }); return svara(res, 200, rad.rows[0]); } } // Stäng av eller öppna ett konto. Att stänga av höjer också // token-versionen, så pågående sessioner upphör direkt — annars // vore avstängningen verkningslös i upp till tolv timmar. const kontoVag = vag.match(/^\/api\/anvandare\/([0-9a-fA-F-]{36})\/(avaktivera|aktivera)$/); if (req.method === "POST" && kontoVag) { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const [, id, atgard] = kontoVag; if (id === anspr.sub) { return svara(res, 400, { error: "Du kan inte stänga av ditt eget konto." }); } const aktivera = atgard === "aktivera"; const rad = await pool.query( `update anvandare set aktiv = $3, token_version = token_version + case when $3 then 0 else 1 end where id = $1 and organisation_id = $2 returning id, namn, aktiv`, [id, anspr.org, aktivera], ); if (rad.rowCount === 0) return svara(res, 404, { error: "Användaren finns inte." }); return svara(res, 200, rad.rows[0]); } // Logga ut på alla enheter — den egna vägen ut när en telefon // tappats bort. Höjer den egna token-versionen. if (req.method === "POST" && vag === "/api/auth/logga-ut-alla") { await pool.query( `update anvandare set token_version = token_version + 1 where id = $1 and organisation_id = $2`, [anspr.sub, anspr.org], ); return svara(res, 200, { ok: true }); } // -- Bilagor -- // Innehållet ligger utanför händelsen; loggen bär referensen och // innehållets hash. Uppladdningen sker före händelsen skrivs, så // en händelse aldrig pekar på något som inte finns. const laddaUppVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/bilagor$/); if (req.method === "POST" && laddaUppVag) { if (!(await arendeIOrg(laddaUppVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } const mediatyp = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase(); if (!mediatypGiltig(mediatyp)) { return svara(res, 415, { error: "Endast bilder och videoklipp kan laddas upp." }); } let data; try { data = await lasBinart(req, MAX_BILAGA); } catch { return svara(res, 413, { error: `Bilagan är för stor (max ${MAX_BILAGA / 1024 / 1024} MB).` }); } if (data.length === 0) return svara(res, 400, { error: "Bilagan är tom." }); const hash = innehallsHash(data); await res.spann.mät("bilaga_skriv", () => BILAGELAGER.spara(hash, data)); const id = `bil-${nyKod()}`; await pool.query( `insert into bilagor (id, organisation_id, arende_id, hash, mediatyp, storlek, laddad_av) values ($1, $2, $3, $4, $5, $6, $7)`, [id, anspr.org, laddaUppVag[1], hash, mediatyp, data.length, anspr.sub], ); // Hashen går tillbaka till klienten och hamnar i händelsen — // därmed står den i den append-only-skyddade loggen. return svara(res, 200, { id, hash, mediatyp, storlek: data.length }); } const bilagaVag = vag.match(/^\/api\/bilagor\/([A-Za-z0-9_-]+)$/); if (req.method === "GET" && bilagaVag) { const rad = await pool.query( `select hash, mediatyp from bilagor where id = $1 and organisation_id = $2`, [bilagaVag[1], anspr.org], ); if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan finns inte." }); return skickaBilaga(res, rad.rows[0]); } // ECM Knowledge Library: aktuellt regelpaket för inloggade klienter. if (req.method === "GET" && vag === "/api/ecm/regler") { res.setHeader("X-Regelpaket-Status", REGELPAKET_STATUS); res.writeHead(200, { "Content-Type": "application/json", ...korsHuvuden(res), }); return res.end(ECM_REGLER); } // -- Märkesspecifika kopplingar (integrationer) -- // Registret läses av alla inloggade (så inställningssidan kan visa // vilka leverantörer som finns); uppgifterna hanteras endast av // systemadministratören och returneras alltid maskerade. if (req.method === "GET" && vag === "/api/integrationer/leverantorer") { return svara(res, 200, INTEGRATIONER); } if (vag === "/api/integrationer") { if (req.method === "GET") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const nyckel = integrationsNyckel(); const rader = await pool.query( `select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status from integrationer where organisation_id = $1 order by leverantor`, [anspr.org], ); const integrationer = rader.rows.map((rad) => { const def = leverantorsDef(rad.leverantor); let uppgifter = {}; try { if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel)); } catch { // Fel nyckel eller manipulerad rad — visa inga värden. } const maskerade = {}; for (const falt of def?.falt ?? []) { const varde = uppgifter[falt.nyckel]; maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? ""); } return { leverantor: rad.leverantor, namn: def?.namn ?? rad.leverantor, aktiv: rad.aktiv, uppdaterad: rad.uppdaterad, senast_testad: rad.senast_testad, senaste_status: rad.senaste_status, uppgifter: maskerade, }; }); return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel }); } if (req.method === "POST") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const nyckel = integrationsNyckel(); if (!nyckel) { return svara(res, 503, { error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.", }); } const { leverantor, uppgifter, aktiv } = await lasKropp(req); const def = leverantorsDef(leverantor); if (!def) return svara(res, 400, { error: "Okänd leverantör." }); if (!uppgifter || typeof uppgifter !== "object") { return svara(res, 400, { error: "Uppgifter saknas." }); } // Endast leverantörens definierade fält sparas, och varje fält // måste ha ett värde — inga tomma nycklar i vila. const rena = {}; for (const falt of def.falt) { const varde = uppgifter[falt.nyckel]; if (typeof varde !== "string" || !varde.trim()) { return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` }); } if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." }); rena[falt.nyckel] = varde.trim(); } await pool.query( `insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv) values ($1, $2, $3, $4) on conflict (organisation_id, leverantor) do update set uppgifter_krypt = excluded.uppgifter_krypt, aktiv = excluded.aktiv, uppdaterad = now(), senast_testad = null, senaste_status = null`, [anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false], ); return svara(res, 200, { ok: true }); } } const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/); if (req.method === "DELETE" && integrationVag) { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [ anspr.org, integrationVag[1], ]); return svara(res, 200, { ok: true }); } // Uppslag mot märkesspecifik koppling. Anropet görs alltid av // servern — kundens leverantörsnycklar når aldrig webbläsaren. const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/); if (req.method === "POST" && uppslagVag) { const nyckel = integrationsNyckel(); const def = leverantorsDef(uppslagVag[1]); if (!def) return svara(res, 400, { error: "Okänd leverantör." }); if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." }); const { identifierare } = await lasKropp(req); if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) { return svara(res, 400, { error: "Ogiltig identifierare." }); } const rad = await pool.query( `select uppgifter_krypt from integrationer where organisation_id = $1 and leverantor = $2 and aktiv = true`, [anspr.org, uppslagVag[1]], ); if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." }); let uppgifter; try { uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel)); } catch { return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." }); } const resultat = await res.spann.mät("leverantorsuppslag", () => gorUppslag(def, uppgifter, identifierare.trim().toUpperCase()), ); await pool.query( `update integrationer set senast_testad = now(), senaste_status = $3 where organisation_id = $1 and leverantor = $2`, [anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)], ); if (!resultat.ok) return svara(res, 502, { error: resultat.fel }); return svara(res, 200, { fordon: resultat.fordon }); } // Organisationens inställningar: vad som visas när ett ärende // startas (objekttyper, identifieringsmetoder). Alla inloggade // läser; endast systemadministratören ändrar. if (req.method === "GET" && vag === "/api/organisation") { const rader = await pool.query( `select namn, installningar from organisationer where id = $1`, [anspr.org], ); if (rader.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." }); return svara(res, 200, rader.rows[0]); } if (req.method === "POST" && vag === "/api/organisation/installningar") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { objekttyper, identifieringsmetoder, ai_tillaten, sprak } = await lasKropp(req); const giltigLista = (lista) => Array.isArray(lista) && lista.length > 0 && lista.length <= 50 && lista.every((v) => typeof v === "string" && v.length <= 100); if (!giltigLista(objekttyper) || !giltigLista(identifieringsmetoder)) { return svara(res, 400, { error: "Ange minst en objekttyp och en identifieringsmetod." }); } await pool.query( `update organisationer set installningar = $2 where id = $1`, [ anspr.org, JSON.stringify({ objekttyper, identifieringsmetoder, // Modellanropen kan stängas av helt. Metodikmotorn fungerar // ensam, så en organisation som inte kan acceptera // överföringen till modelleverantören kan ändå använda // produkten (QUALITET C-4). ai_tillaten: ai_tillaten !== false, // Dokumentationsspråket. Ett okänt värde blir engelska i // stället för ett fel: språket är en etikett, och en // felstavad landskod ska inte hindra en organisation från // att spara sina objekttyper. sprak: valjSprak({ organisation: sprak }), }), ], ); return svara(res, 200, { ok: true }); } // Fordonshistorik: organisationens tidigare ärenden på samma objekt // (regnr/VIN), med dokumenterade felorsaker — pre-diagnostikens // historiksteg och orsakskedjan. Organisationsgränsen gäller alltid. const fordonVag = vag.match(/^\/api\/fordon\/([^/]+)\/historik$/); if (req.method === "GET" && fordonVag) { const ident = decodeURIComponent(fordonVag[1]).trim().toUpperCase(); if (!ident) return svara(res, 400, { error: "Identifierare saknas." }); const rader = await pool.query( `select a.id, a.nummer, a.skapad, coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat, (array_agg(h.handelse->>'text') filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning, coalesce(json_agg(json_build_object( 'avvikelse', h.handelse->>'avvikelse', 'orsaker', h.handelse->'orsaker', 'atgard', h.handelse->>'atgard')) filter (where h.handelse->>'typ' = 'felorsak'), '[]') as felorsaker from felsokning_arenden a join felsokning_handelser h on h.arende_id = a.id where a.organisation_id = $1 and a.identifierare_index = $2 group by a.id order by a.skapad desc limit 20`, // Sökningen går mot det BLINDADE indexet, inte mot loggen. // Identifieraren är krypterad i vila (krypto-shredding), så en // jämförelse mot handelse->'objekt'->>'identifierare' kunde // aldrig träffa — historiken svarade tomt på varje fordon, och // gjorde det med 200. Indexet finns just för den här frågan. [anspr.org, blindaIdentifierare(ident)], ); return svara(res, 200, { arenden: rader.rows }); } // Flottdata: felorsaksstatistik per orsakskategori över hela // organisationen (arbetsledare/admin) — återkommande fel blir // synliga när orsakerna är strukturerad data. if (req.method === "GET" && vag === "/api/statistik/felorsaker") { if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") { return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); } const rader = await pool.query( `select orsak, count(*)::int as antal from felsokning_arenden a join felsokning_handelser h on h.arende_id = a.id, jsonb_array_elements_text(h.handelse->'orsaker') as orsak where a.organisation_id = $1 and h.handelse->>'typ' = 'felorsak' group by orsak order by antal desc`, [anspr.org], ); return svara(res, 200, { orsaker: rader.rows }); } // Organisationsöversikt för arbetsledare/admin: alla ärenden med // status, deltagande tekniker och sammanfattning — härlett ur // händelseloggen, aldrig lagrat separat. if (req.method === "GET" && vag === "/api/oversikt") { if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") { return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." }); } const rader = await pool.query( `select a.id, a.nummer, a.skapad, a.delningskod, a.metodik_id, count(h.id)::int as antal_handelser, min(h.tidpunkt) as forsta, max(h.tidpunkt) as senaste, coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat, (array_agg(h.handelse->'objekt'->>'beskrivning') filter (where h.handelse->>'typ' = 'objekt_identifierat'))[1] as objekt, (array_agg(h.handelse->>'text') filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning, (array_agg(coalesce(h.handelse->>'ansvarig', h.handelse->>'till') order by h.tidpunkt desc) filter (where h.handelse->>'typ' in ('ansvarig_satt', 'overlamning') and coalesce(h.handelse->>'ansvarig', h.handelse->>'till') is not null))[1] as ansvarig, (array_agg(h.anvandare order by h.tidpunkt asc))[1] as skapare, array_agg(distinct h.anvandare) filter (where h.anvandare is not null) as tekniker from felsokning_arenden a left join felsokning_handelser h on h.arende_id = a.id where a.organisation_id = $1 group by a.id order by max(h.tidpunkt) desc nulls last limit 200`, [anspr.org], ); return svara(res, 200, { arenden: rader.rows }); } if (req.method === "GET" && vag === "/api/arenden") { const rader = await pool.query( `select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden where organisation_id = $1 order by skapad desc limit 200`, [anspr.org], ); return svara(res, 200, { arenden: rader.rows }); } if (req.method === "POST" && vag === "/api/arenden") { // Låst abonnemang stoppar NYA ärenden. Det rör aldrig historiken — // se abonnemang.mjs: ärendeloggen är verkstadens underlag i en // tvist som gäller DERAS kund, och den tredje parten har ingen del // i vår obetalda faktura. const ab = await abonnemanget(anspr.org); if (ab && !ab.behorighet.nyttArende) { return svara(res, 402, { error: ab.besked, tillstand: ab.tillstand }); } const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req); if (typeof id !== "string" || typeof nummer !== "number" || !skapad) { return svara(res, 400, { error: "Ogiltigt ärende." }); } // Ärende-id sätts av klienten och är lika förutsägbart som // händelse-id var (TÜV T-1, samma attack en nivå upp). Ärenderaden // kan inte skopas per organisation utan att främmande nycklar från // händelser och bilagor blir sammansatta, så gränsen dras här i // stället: ett id som redan tillhör en ANNAN organisation är ett // fel, inte en tyst icke-händelse. Utan det här blev org A:s // ärende aldrig skapat och varje efterföljande synk svarade 404. const skapat = await pool.query( `insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av) values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing returning id`, [id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub], ); if (skapat.rowCount === 0) { const agare = await pool.query(`select organisation_id from felsokning_arenden where id = $1`, [id]); if (agare.rows[0]?.organisation_id !== anspr.org) { logga("varning", "ärende-id upptaget av annan organisation", { arende: id, organisation: anspr.org, anvandare: anspr.sub, }); return svara(res, 409, { error: "Ärendets identitet är upptagen. Ärendet skapades inte — välj ett nytt id.", }); } } return svara(res, 200, { ok: true }); } // Delningslänkar: skapa/lista per ärende, återkalla per kod. // Verkstaden kontrollerar alltid delningen (organisationskravet). const delningarVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/delningar$/); if (delningarVag) { if (!(await arendeIOrg(delningarVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } if (req.method === "GET") { const rader = await pool.query( `select kod, niva, skapad, aterkallad from delningar where arende_id = $1 order by skapad desc`, [delningarVag[1]], ); return svara(res, 200, { delningar: rader.rows }); } if (req.method === "POST") { const { niva } = await lasKropp(req); if (!["kund", "partner", "intern"].includes(niva)) { return svara(res, 400, { error: "Ogiltig nivå." }); } const kod = nyKod(); await pool.query( `insert into delningar (kod, arende_id, niva, skapad_av) values ($1, $2, $3, $4)`, [kod, delningarVag[1], niva, anspr.sub], ); return svara(res, 200, { kod, niva }); } } // ---- ALVA-REP-0100 · Analysunderlag ------------------------------ // // En enda källa för både portalens analysvy och kvartalsrapporten, // så att skärmen och rapporten aldrig visar olika siffror för samma // period. Det är den vanligaste orsaken till att ingen litar på en // rapport. if (req.method === "GET" && vag.startsWith("/api/statistik/oversikt")) { if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); const rader = await pool.query( `select a.id, a.nummer, coalesce(json_agg(json_build_object('tidpunkt', h.tidpunkt, 'handelse', h.handelse) order by h.tidpunkt) filter (where h.id is not null), '[]') as handelser from felsokning_arenden a left join felsokning_handelser h on h.arende_id = a.id where a.organisation_id = $1 group by a.id, a.nummer`, [anspr.org], ); const nycklar = await nycklarFor(anspr.org); const arenden = rader.rows.map((r) => ({ ...r, handelser: r.handelser.map((p) => ({ ...p, handelse: öppnaHändelse(p.handelse, nycklar) })), })); return svara(res, 200, statistikOversikt(arenden)); } // ---- ALVA-SPEC-021 · Prenumerationer ----------------------------- if (vag === "/api/integration/prenumerationer") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); if (req.method === "GET") { const rader = await pool.query( `select id, namn, url, handelser, aktiv, senast_levererad, senaste_status from prenumerationer where organisation_id = $1 order by namn`, [anspr.org], ); return svara(res, 200, { prenumerationer: rader.rows }); } if (req.method === "POST") { const { namn, url, handelser, hemlighet } = await lasKropp(req); if (typeof namn !== "string" || !namn.trim() || typeof url !== "string") { return svara(res, 400, { error: "namn och url krävs." }); } // Samma SSRF-gräns som leverantörsuppslagen: en prenumeration // får inte peka in i klustret. if (await pekarInat(url)) { return svara(res, 400, { error: "Adressen pekar mot ett internt nät." }); } if (!Array.isArray(handelser) || handelser.some((h) => !(h in UTGAENDE))) { return svara(res, 400, { error: "handelser måste vara kända händelsetyper.", kanda: Object.keys(UTGAENDE) }); } // QUALITY-AUDIT-2 · M-9. Servern genererade tidigare en // hemlighet, krypterade den, sparade den — och lämnade bara // ut id:t. Den gick därefter inte att nå: GET väljer avsiktligt // inte kolumnen. Mottagaren kunde alltså aldrig verifiera // signaturen, och enda utvägen var att sluta kontrollera den — // vilket gör en signerad kanal osignerad, alltså raka motsatsen // till kontrollens syfte. // // Den genererade hemligheten lämnas nu ut en gång, vid // skapandet, med besked om att den inte visas igen. const egen = typeof hemlighet === "string" && hemlighet.length >= 16; const nyHemlighet = egen ? hemlighet : nyKod(32); const rad = await pool.query( `insert into prenumerationer (organisation_id, namn, url, hemlighet_krypt, handelser) values ($1, $2, $3, $4, $5) returning id`, [anspr.org, namn.trim(), url, kryptera(nyHemlighet), handelser], ); return svara(res, 200, { id: rad.rows[0].id, ...(egen ? {} : { hemlighet: nyHemlighet, anmarkning: "Hemligheten visas bara nu. Spara den i mottagarsystemet innan svaret stängs.", }), }); } } // ---- ALVA-PROC-0030 · Sammanfattning ----------------------------- // // Härledd, inte genererad. En sammanfattning som en bedömare läser // blir en del av beslutsunderlaget — är den genererad måste den // granskas mot loggen varje gång, och då är den ingen genväg. const sammanfattningVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/sammanfattning$/); if (req.method === "GET" && sammanfattningVag) { if (!(await arendeIOrg(sammanfattningVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } const rader = await pool.query( `select tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 order by sekvens nulls first, tidpunkt, id`, [sammanfattningVag[1]], ); const nycklar = await nycklarFor(anspr.org); const arende = { id: sammanfattningVag[1], handelser: rader.rows.map((r) => ({ ...r, handelse: öppnaHändelse(r.handelse, nycklar) })), }; loggaAtkomst(req, res, { org: anspr.org, anvandare: anspr.sub, arende: sammanfattningVag[1] }); return svara(res, 200, { ...sammanfatta(arende), enrading: enrading(arende) }); } // ---- ALVA-SPEC-020 · Integrationsgränssnitt ---------------------- if (req.method === "GET" && vag === "/api/integration/kategorier") { return svara(res, 200, { kategorier: KATEGORIER, handelser: UTGAENDE }); } // Inkommande diagnosprotokoll. Blir evidens, inte en bilaga — men // härkomsten följer med i varje händelse, så ett värde som kommit // utifrån aldrig ser ut som något teknikern själv mätt. const protokollVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/protokoll$/); if (req.method === "POST" && protokollVag) { if (!(await arendeIOrg(protokollVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } const { protokoll, profil, kalla } = await lasKropp(req); if (!protokoll || !profil || typeof kalla !== "string" || !kalla.trim()) { return svara(res, 400, { error: "protokoll, profil och kalla krävs." }); } const handelser = protokollTillHandelser(protokoll, profil, kalla.trim().slice(0, 120)); if (handelser.length === 0) { return svara(res, 422, { error: "Profilen gav inga händelser ur protokollet.", atgard: "Kontrollera att profilens sökvägar matchar leverantörens format.", }); } // QUALITY-AUDIT-2 · M-8. Tre fel på samma väg, alla med samma // följd: evidens försvann utan att någon fick veta det. // // Tyst validering `if (fel) continue` slängde händelsen och // rapporterade en siffra. Ett instrument som // laddade upp tolv avläsningar och fick fem // avvisade fick 200 OK och kunde inte ta reda // på vilka fem eller varför. // // Id-krock `prot-${Date.now()}-${i}` är unikt bara // inom ett anrop i en millisekund. Två // samtidiga uppladdningar gav samma id och // `on conflict do nothing` slukade den andra // — medan räknaren ändå räknade den. // // Ingen transaktion Ett fel vid rad 8 av 12 lämnade åtta rader // skrivna och ärendet med ett halvinläst // protokoll utan markering. // // Id:t härleds nu ur innehållet, vilket också gör en omtagning // av samma protokoll idempotent i stället för dubblerande. const nyckel = await personnyckel(anspr.org, protokollVag[1]); const avvisade = []; const nedgraderade = []; const poster = []; for (const [i, h] of handelser.entries()) { const avtryck = crypto .createHash("sha256") .update(`${protokollVag[1]}${kalla.trim()}${i}${JSON.stringify(h)}`) .digest("hex") .slice(0, 32); const { post, fel } = tillPost({ id: `prot-${avtryck}`, handelse: h }, anspr); if (fel) { avvisade.push({ index: i, typ: h.typ, orsak: fel }); continue; } // Registret gäller även leverantörer (TÜV-2 T-14). Vägen gick // tidigare förbi mätdonsFakta, så en leverantörsprofil kunde // stämpla in vilket matdonId som helst — T-2 återuppstånden // genom sidodörren. Skillnaden mot teknikerns väg: ett okänt // instrument AVVISAS inte, för en webhook kan inte registrera // det på plats och att kasta evidensen vore värre. Värdet // behålls men matdonspåståendena stryks — graderingen faller // till E1, och nedgraderingen redovisas i svaret i stället // för att ske i tysthet. const uppslag = await mätdonsFakta(anspr.org, post.handelse); if (uppslag.fel) { const { matdonId, matdonBeteckning, matdonKalibreradTill, kalibreradVidMatning, ...utan } = post.handelse; post.handelse = utan; nedgraderade.push({ index: i, orsak: uppslag.fel }); } else { post.handelse = uppslag.handelse; } post.handelse = skyddaHändelse(post.handelse, nyckel.id, nyckel.nyckel); poster.push(post); } // Kedjat via samma väg som allt annat, och räkningen kommer ur // transaktionen som gjorde jobbet — inte ur två count-frågor // runtomkring, där en samtidig skrivning från någon annan // hamnade i vår siffra (revision 3, m-2). const { skrivna, dubbletter } = await skrivKedjat(protokollVag[1], poster); // Delvis lyckad inläsning är inte 200. Mottagaren ska behöva // hantera att något inte kom in. return svara(res, avvisade.length > 0 ? 207 : 200, { kalla, skrivna, dubbletter, avvisade, nedgraderade, }); } // ---- Radering (dataskyddsförordningen art. 17) ------------------ // // Krypto-shredding: nyckeln förstörs, loggen står kvar. Vad som // raderas är identifieringen — inte protokollet över vad som // kontrollerades, av vem och när. Det är den enda konstruktion där // bevisvärdet överlever en raderingsbegäran. if (req.method === "POST" && vag === "/api/radering") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const { subjekt, bekraftelse } = await lasKropp(req); if (typeof subjekt !== "string" || !subjekt.trim()) { return svara(res, 400, { error: "Ange vilket fordon eller vilken kund som avses." }); } // Raderingen går inte att ångra. En bekräftelse som upprepar // subjektet gör det svårt att göra av misstag och lätt att göra // med avsikt. if (bekraftelse !== subjekt) { return svara(res, 400, { error: "Bekräftelsen måste upprepa subjektet exakt. Raderingen går inte att ångra." }); } // Begäran gäller normalt ett fordon. Alla dess ärenden hittas via // det blindade indexet; ett enskilt ärende-id fungerar också. const arenden = await pool.query( `select id from felsokning_arenden where organisation_id = $1 and (identifierare_index = $2 or id = $3)`, [anspr.org, blindaIdentifierare(subjekt), subjekt], ); const berorda = { rows: [{ antal: arenden.rowCount }] }; const bort = await pool.query( `delete from personnycklar where organisation_id = $1 and subjekt = any($2::text[]) returning id`, [anspr.org, arenden.rows.map((r) => r.id)], ); if (bort.rowCount === 0) { return svara(res, 404, { error: "Inget skyddat underlag finns för det subjektet." }); } // Raderingen loggas — men loggen får inte själv bära uppgiften. await pool.query( `insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden) values ($1, $2, now(), $3, $4)`, [anspr.org, innehallsHash(Buffer.from(subjekt)), anspr.namn, berorda.rows[0].antal], ); logga("info", "radering verkställd", { spårId: res.spår.spårId, org: anspr.org, antalArenden: berorda.rows[0].antal, }); return svara(res, 200, { ok: true, arenden: berorda.rows[0].antal, maskeras_som: MASKERAT }); } if (req.method === "GET" && vag === "/api/radering") { if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); const rader = await pool.query( `select subjekt_hash, begard, verkstalld, begard_av, antal_arenden from raderingar where organisation_id = $1 order by verkstalld desc limit 200`, [anspr.org], ); return svara(res, 200, { raderingar: rader.rows }); } // ---- Åtkomstlogg ----------------------------------------------- if (req.method === "GET" && vag === "/api/atkomstlogg") { if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); const rader = await pool.query( `select l.tidpunkt, l.arende_id, l.vag, l.delningskod, a.namn as anvandare from atkomstlogg l left join anvandare a on a.id = l.anvandare_id where l.organisation_id = $1 order by l.tidpunkt desc limit 500`, [anspr.org], ); return svara(res, 200, { atkomster: rader.rows }); } // ---- Mätdon ----------------------------------------------------- // // Ett mätvärde rankas som E4 — hög evidens. Utan kalibrerat // instrument är det inte lägre evidens utan ingen evidens alls // (QUALITET M-1). Registret gör påståendet kontrollerbart. if (vag === "/api/matdon") { if (req.method === "GET") { const rader = await pool.query( `select id, beteckning, serienummer, kalibrerad_till, aktiv, (kalibrerad_till is not null and kalibrerad_till >= current_date) as giltig from matdon where organisation_id = $1 and aktiv order by beteckning`, [anspr.org], ); return svara(res, 200, { matdon: rader.rows }); } if (req.method === "POST") { if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); const { beteckning, serienummer, kalibrerad_till } = await lasKropp(req); if (typeof beteckning !== "string" || !beteckning.trim() || typeof serienummer !== "string" || !serienummer.trim()) { return svara(res, 400, { error: "Beteckning och serienummer krävs." }); } if (kalibrerad_till && Number.isNaN(Date.parse(kalibrerad_till))) { return svara(res, 400, { error: "Kalibreringsdatum är ogiltigt." }); } const rad = await pool.query( `insert into matdon (organisation_id, beteckning, serienummer, kalibrerad_till) values ($1, $2, $3, $4) on conflict (organisation_id, serienummer) do update set beteckning = excluded.beteckning, kalibrerad_till = excluded.kalibrerad_till, aktiv = true returning id`, [anspr.org, beteckning.trim(), serienummer.trim(), kalibrerad_till || null], ); return svara(res, 200, { id: rad.rows[0].id }); } } const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/); if (req.method === "POST" && aterkalla) { const rad = await pool.query( `update delningar d set aterkallad = now() from felsokning_arenden a where d.kod = $1 and d.arende_id = a.id and a.organisation_id = $2 and d.aterkallad is null returning d.kod`, [aterkalla[1], anspr.org], ); if (rad.rowCount === 0) return svara(res, 404, { error: "Delningen är inte tillgänglig." }); return svara(res, 200, { ok: true }); } // ---- Kedjeverifiering (ALVA-SPEC-070) --------------------------- // // Räknar om varje länk ur det som faktiskt står i databasen och // jämför med de lagrade. Svaret pekar ut FÖRSTA brottet — allt // efter det är följdfel. // // Digesten räknas ur den lagrade händelsen, så en ändrad rad // upptäcks även om kolumnen kedjehash lämnats orörd. Det är hela // skillnaden mot att lita på triggern: verifieringen frågar inte // databasen om den är ärlig, den räknar. const kedjeVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/kedja$/); if (req.method === "GET" && kedjeVag) { if (!(await arendeIOrg(kedjeVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } const status = await kedjestatus(kedjeVag[1]); // Vad svaret bevisar och inte: innehållet är oförändrat sedan // mottagandet. Ingenting om tiden före serverns klocka, ingenting // om sanningshalten. Texten står i svaret så att den följer med // in i varje rapport som citerar det. return svara(res, 200, { ...status, bevisar: "Content unchanged since receipt, in the recorded order. Nothing about the time before receipt, nothing about accuracy.", }); } // ---- Kedjesvepet (TÜV-2-härdning) ------------------------------- // // Verifieringen fanns men anropades bara vid tvist — ett brott // kunde alltså stå oupptäckt i åratal och upptäckas i det sämsta // tänkbara ögonblicket. Svepet går igenom organisationens samtliga // ärenden och pekar ut varje brott och varje ogiltig försegling. // Körs av arbetsledare/admin på begäran, och av driften nattligen // via `node server.mjs --kedjesvep` (alla organisationer, avslutar // med felkod vid brott så cron-larmet är gratis). if (req.method === "POST" && vag === "/api/kedjesvep") { if (anspr.roll === "tekniker") { return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); } const alla = await pool.query( `select id from felsokning_arenden where organisation_id = $1 order by nummer`, [anspr.org], ); const brutna = []; const ogiltigaForseglingar = []; let oforseglade = 0; for (const rad of alla.rows) { const status = await kedjestatus(rad.id); if (!status.ok) brutna.push({ arende: rad.id, brott: status.brott }); if (status.forsegling === "OGILTIG") ogiltigaForseglingar.push(rad.id); if (status.forsegling === "saknas" && status.kedjade > 0) oforseglade += 1; } if (brutna.length > 0 || ogiltigaForseglingar.length > 0) { logga("varning", "kedjesvep fann brott", { organisation: anspr.org, brutna: brutna.length, ogiltigaForseglingar: ogiltigaForseglingar.length, }); } return svara(res, 200, { arenden: alla.rows.length, brutna, ogiltigaForseglingar, oforseglade, }); } const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/); if (handelserVag) { // Organisationsgränsen: ärendet måste tillhöra användarens org. if (!(await arendeIOrg(handelserVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } if (req.method === "GET") { const rader = await pool.query( `select id, tidpunkt, anvandare, handelse from felsokning_handelser where arende_id = $1 order by sekvens nulls first, tidpunkt, id`, [handelserVag[1]], ); const nycklar = await nycklarFor(anspr.org); loggaAtkomst(req, res, { org: anspr.org, anvandare: anspr.sub, arende: handelserVag[1] }); return svara(res, 200, { handelser: rader.rows.map((r) => ({ ...r, handelse: öppnaHändelse(r.handelse, nycklar) })), }); } if (req.method === "POST") { const { handelser } = await lasKropp(req); if (!Array.isArray(handelser) || handelser.length > 500) { return svara(res, 400, { error: "Ogiltig händelselista." }); } res.spann.spår.antalHandelser = handelser.length; // Härkomst och form avgörs här, inte av anroparen. Se // services/gemensam/handelser.mjs och QUALITET C-1/M-3. // Identifierande fält krypteras med en nyckel per fordon. // Radering sker sedan genom att nyckeln förstörs — loggen // förblir intakt, identifieringen försvinner (QUALITET C-3). const nyckel = await personnyckel(anspr.org, handelserVag[1]); const attSkriva = []; for (const post of handelser) { const { post: giltig, fel } = tillPost(post, anspr); if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` }); // Mätdonet slås upp i registret i stället för att tros på // (TÜV T-2). Beteckning och kalibrering HÄRLEDS därifrån — // annars var E4 ett påstående klienten gjorde om sig själv. const uppslag = await mätdonsFakta(anspr.org, giltig.handelse); if (uppslag.fel) return svara(res, 400, { error: uppslag.fel }); giltig.handelse = uppslag.handelse; // Avtrycket tas av det klienten skickade, före serverns fält // och före krypteringen — se kollisionsprövningen nedan. giltig.klientdigest = innehallsHash(kanoniskt(post.handelse)); giltig.handelse = skyddaHändelse(giltig.handelse, nyckel.id, nyckel.nyckel); attSkriva.push(giltig); } // Avslut passerar kvalitetsgrinden på servern. Grinden fanns // tidigare bara i webbläsaren, vilket gjorde hela ECM till ett // råd i stället för en spärr (QUALITET C-2). if (attSkriva.some((p) => p.handelse.typ === "arende_avslutat")) { const hinder = await grindHinder(pool, handelserVag[1], attSkriva, await orgSprak(pool, anspr.org)); if (hinder.length > 0) { return svara(res, 409, { error: "Ärendet kan inte avslutas — kvalitetsgrinden är inte passerad.", hinder, }); } } // Gallringsdatum sätts vid avslut utifrån ärendetypen. // Retention är ett juridiskt ställningstagande, inte en teknisk // detalj: ett garantiärende måste kunna visas upp under hela // garantitiden, ett kontantärende inte (QUALITET C-3). Ett // ärende utan datum gallras aldrig automatiskt — det försiktiga // utfallet, eftersom för tidig gallring inte går att ångra. const avslut = attSkriva.find((p) => p.handelse.typ === "arende_avslutat"); if (avslut) { const typrad = await pool.query( `select handelse->>'arendetyp' as typ from felsokning_handelser where arende_id = $1 and handelse->>'typ' = 'arendetyp_satt' order by tidpunkt desc limit 1`, [handelserVag[1]], ); await pool.query(`update felsokning_arenden set gallras_efter = $2 where id = $1`, [ handelserVag[1], gallringsdatum(typrad.rows[0]?.typ, avslut.tidpunkt), ]); } // Blindat index över fordonet, så en raderingsbegäran kan nå // hela historiken utan att identifieraren lagras i klartext. const objekt = attSkriva.find((p) => p.handelse.typ === "objekt_identifierat"); if (objekt) { const ident = handelser.find((h) => h?.handelse?.typ === "objekt_identifierat")?.handelse?.objekt ?.identifierare; if (ident) { await pool.query(`update felsokning_arenden set identifierare_index = $2 where id = $1`, [ handelserVag[1], blindaIdentifierare(ident), ]); } } // ---- Kollisioner: tyst bara när de är ofarliga (TÜV T-1) ---- // // Nyckeln är numera skopad till ärendet, så en annan organisation // kan inte längre ockupera id. Kvar finns den ofarliga // omsändningen — och den inte fullt så ofarliga: samma id, annat // innehåll. Skillnaden avgörs på klientens avtryck, eftersom // varken raden eller nyttolasten går att jämföra (serverns // tidsstämpel skiljer sig, chiffertexten är ny varje gång). // // Hela satsen prövas före första skrivningen. En delvis skriven // batch som sedan avvisas vore värre än båda utfallen. const befintliga = await pool.query( `select id, klientdigest from felsokning_handelser where arende_id = $1 and id = any($2)`, [handelserVag[1], attSkriva.map((p) => p.id)], ); const krockar = []; for (const rad of befintliga.rows) { res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1; const ny = attSkriva.find((p) => p.id === rad.id); // Saknat avtryck = raden skrevs före den här kolumnen fanns. // Då kan innehållet inte bedömas, och tystnad är det försiktiga // utfallet: append-only skyddar raden som redan står där. if (rad.klientdigest && rad.klientdigest !== ny.klientdigest) { krockar.push({ id: rad.id, typ: ny.handelse.typ }); } } if (krockar.length > 0) { logga("varning", "händelse-id återanvänt med annat innehåll", { arende: handelserVag[1], organisation: anspr.org, anvandare: anspr.sub, antal: krockar.length, }); return svara(res, 409, { error: "Ett eller flera händelse-id finns redan i ärendet med annat innehåll. " + "Ingenting skrevs — loggen kan inte skrivas över.", krockar, }); } // Append-only och kedjat: dubbletter skrivs inte över, triggern // stoppar allt annat, och varje insatt händelse får sin länk. // Kollisionen är redan bedömd ofarlig ovan. await skrivKedjat(handelserVag[1], attSkriva); // Utgående integrationer underrättas efter att loggen skrivits, // aldrig före: en mottagare ska aldrig kunna se en händelse som // inte finns i loggen. for (const p of attSkriva) { if (p.handelse.typ === "arende_avslutat") { leverera(anspr.org, "arende.avslutat", { arende: handelserVag[1] }); } if (p.handelse.typ === "slutsats") { leverera(anspr.org, "arende.slutsats", { arende: handelserVag[1] }); } if (p.handelse.typ === "foto" || p.handelse.typ === "video") { leverera(anspr.org, "media.tillagt", { arende: handelserVag[1], typ: p.handelse.typ }); } } return svara(res, 200, { ok: true }); } } return svara(res, 404, { error: "Okänd resurs." }); } catch (fel) { // Spår-id:t i raden gör att hela kedjan går att hitta i Logs // Insights utifrån larmet. if (fel?.message === "för stor kropp") { return svara(res, 413, { error: "Kroppen är för stor." }); } logga("fel", "förfrågan misslyckades", { spårId: res.spår.spårId, väg: vag, metod: req.method, orsak: fel?.message ?? String(fel), }); return svara(res, 500, { error: "Förfrågan misslyckades." }); } }); } // Driftläget: `node server.mjs --kedjesvep` verifierar varje kedja i // varje organisation och avslutar med felkod vid brott. Ingen server // startas — det här är cron-jobbet, och ett cron-jobb som råkar öppna // en lyssnare är en drifthändelse ingen bett om. Utskriften är en enda // JSON-rad per organisation: det format en loggpipeline redan äter. if (process.argv.includes("--kedjesvep")) { const orgar = await pool.query(`select id, namn from organisationer order by namn`); let brott = 0; for (const org of orgar.rows) { const alla = await pool.query( `select id from felsokning_arenden where organisation_id = $1`, [org.id], ); const brutna = []; const ogiltiga = []; for (const rad of alla.rows) { const status = await kedjestatus(rad.id); if (!status.ok) brutna.push({ arende: rad.id, brott: status.brott }); if (status.forsegling === "OGILTIG") ogiltiga.push(rad.id); } brott += brutna.length + ogiltiga.length; console.log( JSON.stringify({ svep: "kedja", organisation: org.id, arenden: alla.rows.length, brutna, ogiltigaForseglingar: ogiltiga }), ); } await pool.end(); process.exit(brott > 0 ? 1 : 0); } if (process.env.NODE_ENV !== "test") { // Oförseglad drift ska synas VID START, inte upptäckas i en tvist. // Varje avslut varnar redan för sig, men en rad per avslut i en // loggström är brus; en rad vid start är ett beslut någon fattat // (TÜV-2 T-15). if (!FORSEGLING_NYCKEL) { logga("varning", "FORSEGLING_NYCKEL saknas", { konsekvens: "Kedjan skrivs men inga avslut förseglas. Verifieringen svarar 'saknas' för varje ärende som stängs i det här läget.", }); } skapaServer().listen(PORT, () => { console.log(`plattform lyssnar på :${PORT}`); }); }