Revisionen: C-1, C-2, M-2, M-3, m-1 och m-2 åtgärdade

De fyra fynd som underminerade bevisvärdet sitter i samma kodväg och
åtgärdas därför tillsammans.

C-1 · Härkomsten sätts av servern. anvandare kommer ur den verifierade
token och tidpunkt ur serverns klocka. Klientens tidsstämpel kastas inte
— vid offline-arbete är den det enda som finns — utan bevaras som
registrerad_tidpunkt bredvid mottagningstiden, så glappet blir synligt i
stället för osynligt.

C-2 · Kvalitetsgrinden flyttad till services/gemensam/grind.mjs och
utvärderas nu på servern vid arende_avslutat. Ett avslut som inte
passerar får 409 med de faktiska hindren. Metodikdatan flyttades till
services/gemensam/metodiker.mjs och klientens metodiker.ts är en typad
återexport, så det finns fortfarande exakt en sanning.

M-2 · Högvoltsspärren är ett hinder. Ett nekande svar på behörighets-
eller spänningsfrihetsfrågan stoppar metodiken i stället för att räknas
som besvarad, och spärrar avslutet på servern. Ett obesvarat
säkerhetskrav spärrar också — tystnad är inte ett ja. Reglerna ligger i
data så nästa farliga metodik bara behöver en rad.

M-3 · Händelser valideras mot schema före skrivning. Loggen är
append-only, så en felaktig post kan aldrig rättas — kontrollen måste
ske innan, efteråt är det för sent för alltid. Okänd typ avvisas.

m-1 · Delningskoden använder förkastningsurval i stället för modulo.
m-2 · Id-kollisioner räknas i stället för att passera tyst.

21 nya tester låser varje regel. Ett skräddarsytt test kräver dessutom
att varje typ i händelseschemat är klassificerad i delningslistan — en
ny typ kan alltså varken läcka eller tappas bort.

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-05 13:32:00 +00:00
parent cdb3320c4a
commit e73decd3f5
11 changed files with 1563 additions and 889 deletions
+1
View File
@@ -0,0 +1 @@
../gemensam/grind.mjs
+1
View File
@@ -0,0 +1 @@
../gemensam/handelser.mjs
+1
View File
@@ -0,0 +1 @@
../gemensam/metodiker.mjs
+70 -7
View File
@@ -27,6 +27,9 @@ 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";
// API-first: OpenAPI-specen är en versionerad artefakt och serveras live.
const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8");
@@ -296,10 +299,46 @@ async function lasBinart(req, tak) {
return Buffer.concat(bitar);
}
function nyKod() {
// 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 { randomBytes } = crypto;
return Array.from(randomBytes(16), (b) => tecken[b % 36]).join("");
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("");
}
/**
* 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) {
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;
try {
regelpaket = JSON.parse(ECM_REGLER);
} catch {
// Ett trasigt regelpaket får aldrig tolkas som "inga extra krav".
return [{ id: "regelpaket", rubrik: "Regelpaketet gick inte att läsa — avslut spärrat." }];
}
return [...grinda(handelser, metodik), ...grindaArendetyp(handelser, regelpaket)];
}
function kravAuth(req, hemlighet) {
@@ -1172,17 +1211,41 @@ export function skapaServer() {
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.
const attSkriva = [];
for (const post of handelser) {
if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) {
return svara(res, 400, { error: "Ogiltig händelse." });
const { post: giltig, fel } = tillPost(post, anspr);
if (fel) return svara(res, 400, { error: `Ogiltig händelse: ${fel}` });
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);
if (hinder.length > 0) {
return svara(res, 409, {
error: "Ärendet kan inte avslutas — kvalitetsgrinden är inte passerad.",
hinder,
});
}
}
for (const p of attSkriva) {
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
await pool.query(
// 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`,
[post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse],
[p.id, handelserVag[1], p.tidpunkt, p.anvandare, p.handelse],
);
if (skrivet.rowCount === 0) res.spann.spår.kollisioner = (res.spann.spår.kollisioner ?? 0) + 1;
}
return svara(res, 200, { ok: true });
}