e73decd3f5
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
128 lines
5.4 KiB
JavaScript
128 lines
5.4 KiB
JavaScript
// 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
|
||
// beviskedja. 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 } : {}),
|
||
},
|
||
},
|
||
};
|
||
}
|