Files
alva/felsokning/services/gemensam/handelser.mjs
T
Claude e73decd3f5 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
2026-08-05 13:32:00 +00:00

128 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Händelsevalidering vid API-gränsen.
//
// Bakgrund: revisionen (docs/QUALITY-AUDIT.md, C-1 och M-3) fann att servern
// bara kontrollerade att `anvandare` var *en sträng* och `tidpunkt` *sanningsvärd*
// — inte att de var sanna — och att `handelse` togs emot som godtycklig JSON.
//
// Två skilda problem med samma botemedel:
//
// Härkomst Vem som utförde arbetet och när är det som gör loggen till en
// bevis­kedja. Sätts de av anroparen är loggen en signerad behållare
// för overifierade påståenden. Båda härleds nu ur den verifierade
// token respektive serverns klocka.
//
// Form Loggen är append-only. En felaktig post kan aldrig rättas, bara
// kommenteras. Därför måste formen kontrolleras *innan* skrivning —
// efteråt är det för sent för alltid.
//
// Klientens klocka kastas inte bort. Vid offline-arbete är den det enda som
// finns, så den bevaras som `registrerad_tidpunkt` bredvid serverns
// mottagningstid. Glappet blir synligt i stället för osynligt.
/** Fält som aldrig får komma från klienten. */
export const SERVERÄGDA_FÄLT = ["anvandare", "tidpunkt"];
// Varje händelsetyp med sina obligatoriska fält. Listan är avsiktligt
// fullständig: en typ som inte står här avvisas, eftersom en okänd typ i en
// append-only-logg är permanent skräp.
export const HÄNDELSESCHEMA = {
objekt_identifierat: { objekt: "objekt" },
arbetsorder_skannad: { falt: "lista" },
felbeskrivning: { text: "text" },
arendetyp_satt: { arendetyp: "text" },
fraga_besvarad: { stegId: "text", frageId: "text", fraga: "text", svar: "text" },
kontroll_utford: { stegId: "text", kontrollId: "text", text: "text" },
observation: { text: "text" },
matvarde: { beskrivning: "text", varde: "text" },
hypotes: { text: "text", niva: ["medel", "lag"] },
foto: { beskrivning: "text" },
video: { beskrivning: "text" },
matarstallning: { lage: ["ingaende", "utgaende"], varde: "text" },
historik_kontrollerad: { kontrollerad: "boolean" },
reproducering: { status: ["ja", "delvis", "nej"], beskrivning: "text" },
felorsak: { avvikelse: "text", orsaker: "lista", underlag: "lista", sakerhet: ["hog", "medel", "lag"] },
atgardsforslag: { text: "text" },
kundbeslut: { utfall: ["godkant", "avbojt", "delvis"], kanal: "text" },
atgard_utford: { text: "text" },
kvalitetskontroll: { utfall: ["borta", "kvarstar", "delvis", "ej_verifierat"], beskrivning: "text" },
kommentar: { text: "text" },
kategori_byte: { kategori: "text" },
inaktivitet_forklarad: { text: "text", minuter: "tal" },
overlamning: { fran: "text" },
ansvarig_satt: { ansvarig: "text" },
ai_svar: { rader: "lista" },
export_skapad: { format: "text", version: "tal" },
arende_avslutat: {},
};
// Hypotesen får aldrig anta hög tillförlitlighet — samma regel som
// typsystemet upprätthåller i klienten, upprepad här därför att servern inte
// kan lita på att klienten är vår.
const GILTIG = {
text: (v) => typeof v === "string" && v.length > 0 && v.length <= 20000,
tal: (v) => typeof v === "number" && Number.isFinite(v),
boolean: (v) => typeof v === "boolean",
lista: (v) => Array.isArray(v) && v.length <= 500,
objekt: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
};
function fältFel(typ, nyckel, regel, värde) {
if (Array.isArray(regel)) {
return regel.includes(värde) ? null : `${typ}.${nyckel} måste vara en av: ${regel.join(", ")}`;
}
return GILTIG[regel](värde) ? null : `${typ}.${nyckel} har fel form (väntade ${regel})`;
}
/**
* Validerar en händelses form. Returnerar null när den duger, annars en
* förklaring avsedd att läsas av en människa som felsöker en integration.
*/
export function granskaHändelse(handelse) {
if (handelse === null || typeof handelse !== "object" || Array.isArray(handelse)) {
return "handelse måste vara ett objekt";
}
const schema = HÄNDELSESCHEMA[handelse.typ];
if (!schema) {
return `okänd händelsetyp: ${JSON.stringify(handelse.typ)}`;
}
for (const [nyckel, regel] of Object.entries(schema)) {
const fel = fältFel(handelse.typ, nyckel, regel, handelse[nyckel]);
if (fel) return fel;
}
return null;
}
/**
* Bygger den post som faktiskt skrivs.
*
* Härkomsten kommer härifrån och ingen annanstans: `anvandare` ur den
* verifierade token, `tidpunkt` ur serverns klocka. Klientens tidsstämpel
* bevaras separat när den finns, eftersom den är det enda som finns vid
* offline-arbete — men den avgör aldrig när något anses ha skett.
*/
export function tillPost(post, anspr, nu = new Date()) {
const fel = granskaHändelse(post?.handelse);
if (fel) return { fel };
if (typeof post.id !== "string" || !/^[A-Za-z0-9_:.-]{1,128}$/.test(post.id)) {
return { fel: "id saknas eller har otillåtna tecken" };
}
const klient = typeof post.tidpunkt === "string" ? post.tidpunkt : null;
const klientTid = klient && !Number.isNaN(Date.parse(klient)) ? klient : null;
return {
post: {
id: post.id,
tidpunkt: nu.toISOString(),
anvandare: anspr.namn,
handelse: {
...post.handelse,
// Fälten nedan går inte att sätta utifrån; skrivs de över här är det
// just poängen.
anvandarId: anspr.sub,
...(klientTid && klientTid !== nu.toISOString() ? { registrerad_tidpunkt: klientTid } : {}),
},
},
};
}