Härdning av hela TÜV-revisionen

Tio av tolv fynd stängda, två reducerade med skälen utskrivna. Varje
stängning prövas av integrationssviten mot riktig Postgres — 144
kontroller, upp från 100.

T-1  Händelsenyckeln är nu (arende_id, id). Det finns inget delat
     namnrum kvar att ockupera, så attacken saknar yta i stället för att
     vara mildrad. Inom ett ärende bedöms en kollision på klientens
     avtryck — en hash av det kanoniserade innehåll klienten skickade,
     taget före serverns egna fält och före krypteringen, eftersom
     varken raden eller nyttolasten går att jämföra. Identiskt innehåll
     är fortfarande idempotent; samma id med annat innehåll ger 409 och
     skriver ingenting alls, inte heller resten av satsen. Samma attack
     en nivå upp — ärende-id är lika förutsägbart — stängs separat: ett
     id som ägs av en annan organisation ger 409 i stället för att tyst
     låta bli, vilket tidigare lämnade offrets ärende oskapat och varje
     senare synk svarande 404.

T-2  Mätdonet slås upp i registret. Beteckning och kalibrering HÄRLEDS
     därifrån och skriver över det klienten skickade. Okänt mätdon ger
     400. Utgånget avvisas inte — mätningen gjordes — men registrets
     datum följer med, så graderingen faller på registrets uppgift. Ett
     mätvärde utan mätdon får sina påstådda uppgifter borttagna.

T-3  Reducerad, inte stängd. Personnycklarna kuverteras under en
     huvudnyckel utanför databasen. En återställd dump ger nycklar som
     inte öppnas — verifierat genom att starta om utan huvudnyckeln. Vad
     som återstår står utskrivet: en backup tagen FÖRE en radering, plus
     huvudnyckeln, återställer fortfarande uppgifterna.

Därtill: gallringen verkställs nu av ett eget jobb och grupperar på det
blindade fordonsindexet så att en delad nyckel inte gallras för tidigt
(T-4) · AI-avlästa mätvärden bär härkomst (T-5) · bcrypt kostnad 12
(T-6) · åtkomstloggen och raderingsregistret är append-only, med en
smalare regel för personnycklarna som måste kunna förstöras (T-7) ·
åtkomstloggen dokumenterad (T-8) · ett externt regelpaket utan signatur
spärrar avslut (T-9) · CORS faller inte längre öppet (T-10) · exp krävs
i token (T-11) · react-router 7 (T-12, med den kvarvarande avvikelsen
motiverad).

Den motspelande hyresgästen som revisionen efterlyste finns nu som
testform och körs i CI.

---- Vad härdningen själv avslöjade -----------------------------------

Två av rättelserna var kortvarigt fel på samma sätt som fynden, och
bägge fångades bara av att jag försökte bevisa dem:

  T-7-testet var grönt mot en TOM tabell. En radnivåtrigger har inga
  rader att fyra på, så delete lyckades och kontrollen mätte ingenting.

  T-6-testet påstod anropet, inte kostnaden. toContain("gen_salt('bf')")
  hade accepterat kostnad 6 för evigt — och gjorde det, så länge det
  fanns.

Bägge är mönstret revisionen namngav: en kontroll som är riktig i sina
egna termer och oprövad vid sin gräns. Det gäller tester lika mycket som
kod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 13:05:16 +00:00
parent 0833a573e0
commit b147694e31
15 changed files with 970 additions and 63 deletions
+251 -27
View File
@@ -17,6 +17,8 @@
// 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
// 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";
@@ -49,6 +51,7 @@ const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "open
// 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",
@@ -68,6 +71,13 @@ 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);
@@ -84,7 +94,9 @@ if (REGELPAKET_STATUS !== "signerat") {
konsekvens:
REGELPAKET_STATUS === "ogiltig signatur"
? "Paketet används INTE — inbyggt standardpaket gäller."
: "Paketet används men märks som osignerat i spårbarheten.",
: 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.",
});
}
@@ -155,7 +167,12 @@ const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
function ursprungFor(req) {
const ursprung = req.headers.origin;
if (TILLATNA_URSPRUNG.length === 0) return "*";
// 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];
}
@@ -239,7 +256,12 @@ export function verifieraJwt(token, hemlighet) {
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const anspr = JSON.parse(Buffer.from(delar[1], "base64url").toString("utf8"));
if (typeof anspr.exp === "number" && anspr.exp * 1000 < Date.now()) return null;
// 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;
@@ -345,12 +367,24 @@ export function synligaTyper(niva) {
// ---- 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.
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
...korsHuvuden(res),
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(JSON.stringify(kropp));
@@ -414,6 +448,15 @@ async function grindHinder(pool, arendeId, nya) {
if (REGELPAKET_STATUS === "ogiltig signatur") {
return [{ id: "regelpaket", rubrik: "Regelpaketets signatur stämmer inte — avslut spärrat." }];
}
if (REGELPAKET_STATUS === "osignerat externt") {
return [
{
id: "regelpaket",
rubrik: "Ett externt regelpaket används utan signatur — avslut spärrat.",
detalj: "Sätt ECM_REGLER_NYCKEL och ECM_REGLER_SIGNATUR, eller ta bort ECM_REGLER_FIL.",
},
];
}
try {
regelpaket = JSON.parse(ECM_REGLER);
} catch {
@@ -445,15 +488,69 @@ function blindaIdentifierare(ident) {
.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, randomBytes(32)],
[orgId, subjekt, kuvertera(randomBytes(32))],
);
return rad.rows[0];
return { ...rad.rows[0], nyckel: oppnaKuvert(rad.rows[0].nyckel) };
}
/** Nycklar som fortfarande finns, för att öppna en logg vid läsning. */
@@ -462,7 +559,7 @@ async function nycklarFor(orgId) {
`select id, nyckel from personnycklar where organisation_id = $1`,
[orgId],
);
return new Map(rader.rows.map((r) => [r.id, r.nyckel]));
return new Map(rader.rows.map((r) => [r.id, oppnaKuvert(r.nyckel)]));
}
/**
@@ -588,6 +685,66 @@ async function fakturamedStatus(id) {
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(
@@ -714,8 +871,7 @@ async function skickaBilaga(res, rad) {
"Content-Length": data.length,
// Innehållsadresserat — samma id ger alltid samma bytes.
"Cache-Control": "private, max-age=31536000, immutable",
"Access-Control-Allow-Origin": res.ursprung ?? "*",
Vary: "Origin",
...korsHuvuden(res),
});
return res.end(data);
}
@@ -763,8 +919,7 @@ export function skapaServer() {
});
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": res.ursprung,
Vary: "Origin",
...korsHuvuden(res),
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
});
@@ -780,7 +935,7 @@ export function skapaServer() {
if (req.method === "GET" && vag === "/api/openapi.yaml") {
res.writeHead(200, {
"Content-Type": "application/yaml; charset=utf-8",
"Access-Control-Allow-Origin": res.ursprung,
...korsHuvuden(res),
});
return res.end(OPENAPI);
}
@@ -808,7 +963,7 @@ export function skapaServer() {
);
const rad = await klientDb.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin')
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()],
@@ -1199,7 +1354,7 @@ export function skapaServer() {
}
const rad = await pool.query(
`insert into anvandare (organisation_id, epost, losen_hash, namn, roll)
values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5)
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],
@@ -1291,7 +1446,7 @@ export function skapaServer() {
res.setHeader("X-Regelpaket-Status", REGELPAKET_STATUS);
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": res.ursprung,
...korsHuvuden(res),
});
return res.end(ECM_REGLER);
}
@@ -1567,11 +1722,31 @@ export function skapaServer() {
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
return svara(res, 400, { error: "Ogiltigt ärende." });
}
await pool.query(
// Ä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`,
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 });
}
@@ -1779,7 +1954,7 @@ export function skapaServer() {
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 (id) do nothing`,
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;
@@ -1960,6 +2135,17 @@ export function skapaServer() {
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);
}
@@ -2011,18 +2197,56 @@ export function skapaServer() {
}
}
// ---- 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.
// Kollisionen räknas: ett id som redan finns är antingen en
// ofarlig omsändning eller ett försök att blockera en framtida
// post, och skillnaden syns bara om den mäts (QUALITET m-2).
const skrivet = await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, $3, $4, $5) on conflict (id) do nothing`,
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse],
// 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],
);
if (skrivet.rowCount === 0) res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
}
// Utgående integrationer underrättas efter att loggen skrivits,
// aldrig före: en mottagare ska aldrig kunna se en händelse som