6665004ec7
KEDJESVEPET. Verifieringen fanns men anropades bara vid tvist: ett brott kunde stå oupptäckt i åratal och upptäckas i sämsta tänkbara ögonblick. Nu går driften igenom varje kedja och varje försegling i varje organisation — POST /api/kedjesvep för arbetsledaren, `node server.mjs --kedjesvep` för nattlig cron utan server, med felkod vid brott så att larmet är gratis. Endpoint och svep delar EN implementation (kedjestatus): två implementationer av "är kedjan hel?" kommer att svara olika den dag det betyder något — det var T-13/T-14:s rot, tillämpad i förebyggande syfte. SVEPETS FÖRSTA FYND VAR PÅ RIKTIGT. Första körningen mot integrationsmiljön rapporterade TRE brott där testerna saboterat två. Det tredje var en bugg: leverantörshändelser bär `enhet: undefined`, och den kanoniska formen serialiserade nyckeln som null medan databasens rundresa släpper den. Skriv-digest och läs-digest skilde sig — varje leverantörshändelse bröt sin egen kedjelänk. Klienthändelser gick fria av en slump: JSON.parse kan inte producera undefined. Fixat vid roten: kanonisk form är nu exakt den form som överlever rundresan, inte en nästan likadan. Mekanismen fungerade precis som avsett — dag ett, mot sina egna upphovsmän. TRANSPORTEN. Säkerhetshuvuden på varje API-svar: nosniff, no-store (ett API vars svar är personuppgifter får inte bli en cacheträff), frame-ancestors 'none', no-referrer, HSTS. För stor kropp svarar 413 i stället för ett intetsägande 500. Kroppstak 4 MiB och batchtak 500 verifierade som redan på plats — bokfört, inte antaget. En skriptläxa på vägen: cron-svepets felkod är själva poängen, men under `set -e` dödade den testskriptet vid tilldelningen — felkoden måste fångas i samma andetag. Utgåva 3.4. API-specen dokumenterar svepet. TÜV-2-rapporten har bilaga A med samma-dags-härdningen, inklusive svepets eget fynd. 768 enhetstester, 224 integrationskontroller mot riktig Postgres, återställningsprov, genomgång 4/4, portalspärr, typkontroll och lint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2942 lines
132 KiB
JavaScript
2942 lines
132 KiB
JavaScript
// 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.<iv>.<tagg>.<chiffertext>, 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]} |