Files
alva/felsokning/services/plattform/server.mjs
T
Claude be10704349 Grinden talar organisationens språk — engelska som standard
Kvalitetsgrinden formulerade sina hinder på svenska, hårdkodat. I en
produkt där engelska är standardspråket betyder det att en spärr kan
neka ett avslut på ett språk teknikern inte läser. Det är en spärr utan
väg förbi: teknikern ser att systemet vägrar men inte varför, och den
enda utvägen blir att ringa någon. Det är då regeln slutar vara en regel
och blir ett hinder man lär sig kringgå.

Varje hinder bär nu både nyckel och färdig text. Nyckeln därför att en
klient som byter språk ska kunna rendera om utan att fråga servern;
texten därför att ett hinder som hamnar i en logg eller en PDF måste gå
att läsa utan katalogen. Hindrets id och nyckel är språkoberoende — det
är bara texten som byter.

Språket är organisationens, inte användarens. 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. Det
lagras i organisationens inställningar, och en felstavad landskod blir
engelska i stället för ett fel — språkinställningen är en etikett och
ska inte hindra någon från att spara sina objekttyper.

SPARRFRAGOR bär nu nycklar i stället för svensk text. Testet som jämför
serverns lista med klientens jämför nycklar, så listorna kan fortfarande
inte glida isär.

Två tester prövade svenska ord i grindens utdata och gick sönder, vilket
var rätt av dem. De prövar nu nyckeln — texten är översatt och byter
språk med organisationen, och att pröva svenska ord hade gjort dem till
tester av vilket språk som råkar vara standard. Ett nytt test prövar det
som faktiskt betyder något: samma id, samma nyckel, olika text.

Integrationstestet följer språket hela vägen — organisationens
inställning, genom servern, ut i det 409-svar som nekar avslutet: engelska
som standard, tyska efter byte, engelska igen vid okänd kod.

582 tester, 186 integrationskontroller mot riktig Postgres, genomgång och
portalspärr gröna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-06 16:36:53 +00:00

2626 lines
118 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// 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 {
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 ?? "";
// 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",
});
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.
*/
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.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 = process.env.PERSONNYCKEL_HUVUD ?? "";
if (!) return null;
const b = /^[0-9a-fA-F]{64}$/.test() ? Buffer.from(, "hex") : Buffer.from(, "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") {
return `{${Object.keys(värde)
.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, ...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;
if (rad.rows[0].kalibrerad_till) {
härledd.matdonKalibreradTill = new Date(rad.rows[0].kalibrerad_till).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 tidpunkt, id`,
[arendeId, synliga],
)
: await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by 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() } : {}),
};
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, now(), $3, $4)`,
[`kb-${nyKod()}`, arendeId, "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 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 poster = [];
for (const [i, h] of handelser.entries()) {
const avtryck = crypto
.createHash("sha256")
.update(`${protokollVag[1]}${kalla.trim()}${i}${JSON.stringify(h)}`)
.digest("hex")
.slice(0, 32);
const { post, fel } = tillPost({ id: `prot-${avtryck}`, handelse: h }, anspr);
if (fel) {
avvisade.push({ index: i, typ: h.typ, orsak: fel });
continue;
}
post.handelse = skyddaHändelse(post.handelse, nyckel.id, nyckel.nyckel);
poster.push(post);
}
const klientDb = await pool.connect();
let skrivna = 0;
let dubbletter = 0;
try {
await klientDb.query("begin");
for (const post of poster) {
const r = await klientDb.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (arende_id, id) do nothing`,
[post.id, protokollVag[1], post.tidpunkt, post.anvandare, post.handelse],
);
if (r.rowCount === 0) dubbletter += 1;
else skrivna += 1;
}
await klientDb.query("commit");
} catch (fel) {
await klientDb.query("rollback");
throw fel;
} finally {
klientDb.release();
}
// Delvis lyckad inläsning är inte 200. Mottagaren ska behöva
// hantera att något inte kom in.
return svara(res, avvisade.length > 0 ? 207 : 200, {
kalla,
skrivna,
dubbletter,
avvisade,
});
}
// ---- Radering (dataskyddsförordningen art. 17) ------------------
//
// Krypto-shredding: nyckeln förstörs, loggen står kvar. Vad som
// raderas är identifieringen — inte protokollet över vad som
// kontrollerades, av vem och när. Det är den enda konstruktion där
// bevisvärdet överlever en raderingsbegäran.
if (req.method === "POST" && vag === "/api/radering") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const { subjekt, bekraftelse } = await lasKropp(req);
if (typeof subjekt !== "string" || !subjekt.trim()) {
return svara(res, 400, { error: "Ange vilket fordon eller vilken kund som avses." });
}
// Raderingen går inte att ångra. En bekräftelse som upprepar
// subjektet gör det svårt att göra av misstag och lätt att göra
// med avsikt.
if (bekraftelse !== subjekt) {
return svara(res, 400, { error: "Bekräftelsen måste upprepa subjektet exakt. Raderingen går inte att ångra." });
}
// Begäran gäller normalt ett fordon. Alla dess ärenden hittas via
// det blindade indexet; ett enskilt ärende-id fungerar också.
const arenden = await pool.query(
`select id from felsokning_arenden
where organisation_id = $1 and (identifierare_index = $2 or id = $3)`,
[anspr.org, blindaIdentifierare(subjekt), subjekt],
);
const berorda = { rows: [{ antal: arenden.rowCount }] };
const bort = await pool.query(
`delete from personnycklar
where organisation_id = $1 and subjekt = any($2::text[]) returning id`,
[anspr.org, arenden.rows.map((r) => r.id)],
);
if (bort.rowCount === 0) {
return svara(res, 404, { error: "Inget skyddat underlag finns för det subjektet." });
}
// Raderingen loggas — men loggen får inte själv bära uppgiften.
await pool.query(
`insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
values ($1, $2, now(), $3, $4)`,
[anspr.org, innehallsHash(Buffer.from(subjekt)), anspr.namn, berorda.rows[0].antal],
);
logga("info", "radering verkställd", {
spårId: res.spår.spårId,
org: anspr.org,
antalArenden: berorda.rows[0].antal,
});
return svara(res, 200, { ok: true, arenden: berorda.rows[0].antal, maskeras_som: MASKERAT });
}
if (req.method === "GET" && vag === "/api/radering") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const rader = await pool.query(
`select subjekt_hash, begard, verkstalld, begard_av, antal_arenden
from raderingar where organisation_id = $1 order by verkstalld desc limit 200`,
[anspr.org],
);
return svara(res, 200, { raderingar: rader.rows });
}
// ---- Åtkomstlogg -----------------------------------------------
if (req.method === "GET" && vag === "/api/atkomstlogg") {
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." });
const rader = await pool.query(
`select l.tidpunkt, l.arende_id, l.vag, l.delningskod, a.namn as anvandare
from atkomstlogg l left join anvandare a on a.id = l.anvandare_id
where l.organisation_id = $1 order by l.tidpunkt desc limit 500`,
[anspr.org],
);
return svara(res, 200, { atkomster: rader.rows });
}
// ---- Mätdon -----------------------------------------------------
//
// Ett mätvärde rankas som E4 — hög evidens. Utan kalibrerat
// instrument är det inte lägre evidens utan ingen evidens alls
// (QUALITET M-1). Registret gör påståendet kontrollerbart.
if (vag === "/api/matdon") {
if (req.method === "GET") {
const rader = await pool.query(
`select id, beteckning, serienummer, kalibrerad_till, aktiv,
(kalibrerad_till is not null and kalibrerad_till >= current_date) as giltig
from matdon where organisation_id = $1 and aktiv order by beteckning`,
[anspr.org],
);
return svara(res, 200, { matdon: rader.rows });
}
if (req.method === "POST") {
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledare eller administratör." });
const { beteckning, serienummer, kalibrerad_till } = await lasKropp(req);
if (typeof beteckning !== "string" || !beteckning.trim() || typeof serienummer !== "string" || !serienummer.trim()) {
return svara(res, 400, { error: "Beteckning och serienummer krävs." });
}
if (kalibrerad_till && Number.isNaN(Date.parse(kalibrerad_till))) {
return svara(res, 400, { error: "Kalibreringsdatum är ogiltigt." });
}
const rad = await pool.query(
`insert into matdon (organisation_id, beteckning, serienummer, kalibrerad_till)
values ($1, $2, $3, $4)
on conflict (organisation_id, serienummer)
do update set beteckning = excluded.beteckning,
kalibrerad_till = excluded.kalibrerad_till,
aktiv = true
returning id`,
[anspr.org, beteckning.trim(), serienummer.trim(), kalibrerad_till || null],
);
return svara(res, 200, { id: rad.rows[0].id });
}
}
const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/);
if (req.method === "POST" && aterkalla) {
const rad = await pool.query(
`update delningar d set aterkallad = now()
from felsokning_arenden a
where d.kod = $1 and d.arende_id = a.id and a.organisation_id = $2 and d.aterkallad is null
returning d.kod`,
[aterkalla[1], anspr.org],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Delningen är inte tillgänglig." });
return svara(res, 200, { ok: true });
}
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
if (handelserVag) {
// Organisationsgränsen: ärendet måste tillhöra användarens org.
if (!(await arendeIOrg(handelserVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
if (req.method === "GET") {
const rader = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[handelserVag[1]],
);
const nycklar = await nycklarFor(anspr.org);
loggaAtkomst(req, res, { org: anspr.org, anvandare: anspr.sub, arende: handelserVag[1] });
return svara(res, 200, {
handelser: rader.rows.map((r) => ({ ...r, handelse: öppnaHändelse(r.handelse, nycklar) })),
});
}
if (req.method === "POST") {
const { handelser } = await lasKropp(req);
if (!Array.isArray(handelser) || handelser.length > 500) {
return svara(res, 400, { error: "Ogiltig händelselista." });
}
res.spann.spår.antalHandelser = handelser.length;
// Härkomst och form avgörs här, inte av anroparen. Se
// services/gemensam/handelser.mjs och QUALITET C-1/M-3.
// Identifierande fält krypteras med en nyckel per fordon.
// Radering sker sedan genom att nyckeln förstörs — loggen
// förblir intakt, identifieringen försvinner (QUALITET C-3).
const nyckel = await personnyckel(anspr.org, handelserVag[1]);
const attSkriva = [];
for (const post of handelser) {
const { post: giltig, fel } = tillPost(post, anspr);
if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` });
// Mätdonet slås upp i registret i stället för att tros på
// (TÜV T-2). Beteckning och kalibrering HÄRLEDS därifrån —
// annars var E4 ett påstående klienten gjorde om sig själv.
const uppslag = await mätdonsFakta(anspr.org, giltig.handelse);
if (uppslag.fel) return svara(res, 400, { error: uppslag.fel });
giltig.handelse = uppslag.handelse;
// Avtrycket tas av det klienten skickade, före serverns fält
// och före krypteringen — se kollisionsprövningen nedan.
giltig.klientdigest = innehallsHash(kanoniskt(post.handelse));
giltig.handelse = skyddaHändelse(giltig.handelse, nyckel.id, nyckel.nyckel);
attSkriva.push(giltig);
}
// Avslut passerar kvalitetsgrinden på servern. Grinden fanns
// tidigare bara i webbläsaren, vilket gjorde hela ECM till ett
// råd i stället för en spärr (QUALITET C-2).
if (attSkriva.some((p) => p.handelse.typ === "arende_avslutat")) {
const hinder = await grindHinder(pool, handelserVag[1], attSkriva, await orgSprak(pool, anspr.org));
if (hinder.length > 0) {
return svara(res, 409, {
error: "Ärendet kan inte avslutas — kvalitetsgrinden är inte passerad.",
hinder,
});
}
}
// Gallringsdatum sätts vid avslut utifrån ärendetypen.
// Retention är ett juridiskt ställningstagande, inte en teknisk
// detalj: ett garantiärende måste kunna visas upp under hela
// garantitiden, ett kontantärende inte (QUALITET C-3). Ett
// ärende utan datum gallras aldrig automatiskt — det försiktiga
// utfallet, eftersom för tidig gallring inte går att ångra.
const avslut = attSkriva.find((p) => p.handelse.typ === "arende_avslutat");
if (avslut) {
const typrad = await pool.query(
`select handelse->>'arendetyp' as typ from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = 'arendetyp_satt'
order by tidpunkt desc limit 1`,
[handelserVag[1]],
);
await pool.query(`update felsokning_arenden set gallras_efter = $2 where id = $1`, [
handelserVag[1],
gallringsdatum(typrad.rows[0]?.typ, avslut.tidpunkt),
]);
}
// Blindat index över fordonet, så en raderingsbegäran kan nå
// hela historiken utan att identifieraren lagras i klartext.
const objekt = attSkriva.find((p) => p.handelse.typ === "objekt_identifierat");
if (objekt) {
const ident = handelser.find((h) => h?.handelse?.typ === "objekt_identifierat")?.handelse?.objekt
?.identifierare;
if (ident) {
await pool.query(`update felsokning_arenden set identifierare_index = $2 where id = $1`, [
handelserVag[1],
blindaIdentifierare(ident),
]);
}
}
// ---- Kollisioner: tyst bara när de är ofarliga (TÜV T-1) ----
//
// Nyckeln är numera skopad till ärendet, så en annan organisation
// kan inte längre ockupera id. Kvar finns den ofarliga
// omsändningen — och den inte fullt så ofarliga: samma id, annat
// innehåll. Skillnaden avgörs på klientens avtryck, eftersom
// varken raden eller nyttolasten går att jämföra (serverns
// tidsstämpel skiljer sig, chiffertexten är ny varje gång).
//
// Hela satsen prövas före första skrivningen. En delvis skriven
// batch som sedan avvisas vore värre än båda utfallen.
const befintliga = await pool.query(
`select id, klientdigest from felsokning_handelser where arende_id = $1 and id = any($2)`,
[handelserVag[1], attSkriva.map((p) => p.id)],
);
const krockar = [];
for (const rad of befintliga.rows) {
res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
const ny = attSkriva.find((p) => p.id === rad.id);
// Saknat avtryck = raden skrevs före den här kolumnen fanns.
// Då kan innehållet inte bedömas, och tystnad är det försiktiga
// utfallet: append-only skyddar raden som redan står där.
if (rad.klientdigest && rad.klientdigest !== ny.klientdigest) {
krockar.push({ id: rad.id, typ: ny.handelse.typ });
}
}
if (krockar.length > 0) {
logga("varning", "händelse-id återanvänt med annat innehåll", {
arende: handelserVag[1],
organisation: anspr.org,
anvandare: anspr.sub,
antal: krockar.length,
});
return svara(res, 409, {
error:
"Ett eller flera händelse-id finns redan i ärendet med annat innehåll. " +
"Ingenting skrevs — loggen kan inte skrivas över.",
krockar,
});
}
for (const p of attSkriva) {
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
// Här är kollisionen redan bedömd ofarlig ovan.
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse, klientdigest)
values ($1, $2, $3, $4, $5, $6) on conflict (arende_id, id) do nothing`,
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse, p.klientdigest],
);
}
// Utgående integrationer underrättas efter att loggen skrivits,
// aldrig före: en mottagare ska aldrig kunna se en händelse som
// inte finns i loggen.
for (const p of attSkriva) {
if (p.handelse.typ === "arende_avslutat") {
leverera(anspr.org, "arende.avslutat", { arende: handelserVag[1] });
}
if (p.handelse.typ === "slutsats") {
leverera(anspr.org, "arende.slutsats", { arende: handelserVag[1] });
}
if (p.handelse.typ === "foto" || p.handelse.typ === "video") {
leverera(anspr.org, "media.tillagt", { arende: handelserVag[1], typ: p.handelse.typ });
}
}
return svara(res, 200, { ok: true });
}
}
return svara(res, 404, { error: "Okänd resurs." });
} catch (fel) {
// Spår-id:t i raden gör att hela kedjan går att hitta i Logs
// Insights utifrån larmet.
logga("fel", "förfrågan misslyckades", {
spårId: res.spår.spårId,
väg: vag,
metod: req.method,
orsak: fel?.message ?? String(fel),
});
return svara(res, 500, { error: "Förfrågan misslyckades." });
}
});
}
if (process.env.NODE_ENV !== "test") {
skapaServer().listen(PORT, () => {
console.log(`plattform lyssnar på :${PORT}`);
});
}