// ALVA-SPEC-020 · Integration interface. // // Verkstaden har redan system: ett DMS, ett diagnosinstrument som // producerar protokoll, ett videooffertsystem. ALVA ska koppla in sig i // dem — inte ersätta dem, och inte kräva att de anpassar sig. // // ---- Vad jag INTE bygger --------------------------------------------- // // Färdiga klienter mot namngivna leverantörer. Jag känner inte Beonodes // eller ServiceCams faktiska API:er, och en uppfunnen endpoint som ser // färdig ut är sämre än en tom: den ser ut att fungera tills någon // försöker, och då är felet dyrare att hitta. // // I stället: ett stabilt gränssnitt med PROFILER. En profil beskriver // vad en kategori av system förväntar sig — fältnamn, format, riktning — // och valideras mot leverantören innan den märks som `validated`. Tills // dess står den som `draft`, och det syns i gränssnittet. // // Det är samma princip som Knowledge Sources: leverantören är data. // // ---- Riktningar -------------------------------------------------------- // // IN Diagnosprotokoll, arbetsorder, fordonsdata. Blir händelser i // loggen med källa och tidsstämpel bevarade. // UT Ärendet, rapporten, slutsatsen, mediet. Levereras som webhook // eller hämtas via API. // // Riktningen är inte symmetrisk. Inkommande data blir evidens och måste // därför bära sin härkomst hela vägen; utgående data är en projektion // och kan formas fritt. /** Kategorier av system. Namnen är generiska med avsikt. */ export const KATEGORIER = { diagnosprotokoll: { namn: "Diagnostic protocol", riktning: "in", beskrivning: "Fault codes, live data and readouts from a diagnostic instrument.", // Vad ALVA gör av det: varje avläsning blir evidens, inte en bilaga. blir: ["matvarde", "observation", "foto"], }, dms: { namn: "Dealer management system", riktning: "bada", beskrivning: "Work orders in, completed cases and time records out.", blir: ["arbetsorder_skannad", "objekt_identifierat"], }, videooffert: { namn: "Video quotation", riktning: "bada", beskrivning: "Video evidence in, the case record and closing statement out.", blir: ["video", "foto"], }, fordonsdata: { namn: "Vehicle data", riktning: "in", beskrivning: "Identification, equipment level, service campaigns.", blir: ["objekt_identifierat"], }, garanti: { namn: "Warranty administration", riktning: "ut", beskrivning: "Claim substantiation: evidence chain, closing statement, traceability package.", blir: [], }, forsakring: { namn: "Insurance assessment", riktning: "ut", beskrivning: "Assessor-facing record: what was checked, what was ruled out, and why.", blir: [], }, skadekalkyl: { namn: "Damage calculation", riktning: "bada", beskrivning: "Damage estimation systems used between workshops and insurers — CABAS is the Nordic standard. " + "In: damage assessment and vehicle identification. Out: the diagnostic record substantiating that " + "the damage was investigated rather than assumed.", blir: ["objekt_identifierat", "observation", "foto"], // Det ALVA tillför en skadekalkyl är inte fler poster utan // beviskedjan bakom dem: vad som kontrollerades, vad som uteslöts // och varför. Det är den enda del av kalkylen som i dag inte går // att granska i efterhand. tillfor: ["slutsats", "felorsak", "reproducering"], }, }; /** * Profilens mognad. Samma resonemang som konnektorernas livscykel: * "det finns kod" är inte samma sak som "det fungerar mot leverantören". */ export const MOGNAD = ["draft", "tested", "validated"]; /** * Utgående händelser. En integration prenumererar på det den behöver — * ett videooffertsystem bryr sig inte om varje mätvärde. */ export const UTGAENDE = { "arende.skapat": "A case was opened.", "arende.fas": "The case entered a new ALVA phase.", "arende.slutsats": "A closing statement was recorded.", "arende.avslutat": "The case was closed and passed the quality gate.", "media.tillagt": "Photo or video evidence was added.", "atgardsforslag.lamnat": "A repair proposal was issued to the customer.", "kundbeslut.registrerat": "The customer's decision was recorded.", }; /** * Signerar en utgående leverans. * * HMAC över tidsstämpel och kropp, i det format nästan alla * webhook-mottagare redan förstår. Tidsstämpeln ingår i signaturen så * att en avlyssnad leverans inte går att spela upp igen. */ export function signeraLeverans(kropp, hemlighet, tidsstampel, hmac) { const underlag = `${tidsstampel}.${kropp}`; return `t=${tidsstampel},v1=${hmac("sha256", hemlighet).update(underlag).digest("hex")}`; } /** * Verifierar en inkommande leverans från oss. * * Exponerad så att mottagaren kan använda exakt samma kod som avsändaren * — de flesta integrationsfel uppstår i glappet mellan två * implementationer av samma signatur. */ export function verifieraLeverans(kropp, huvud, hemlighet, hmac, timingSafeEqual, nu = Date.now()) { const delar = Object.fromEntries( String(huvud ?? "") .split(",") .map((d) => d.split("=")), ); if (!delar.t || !delar.v1) return { ok: false, orsak: "Malformed signature header." }; // Fem minuter. Nog för klockglapp, kort nog att en fångad leverans // inte går att spela upp i morgon. if (Math.abs(nu - Number(delar.t) * 1000) > 300_000) return { ok: false, orsak: "Timestamp outside tolerance." }; const väntad = hmac("sha256", hemlighet).update(`${delar.t}.${kropp}`).digest("hex"); const a = Buffer.from(delar.v1); const b = Buffer.from(väntad); if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, orsak: "Signature mismatch." }; return { ok: true }; } /** * Normaliserar ett inkommande diagnosprotokoll till händelser. * * Profilen beskriver var värdena ligger i leverantörens format; den här * funktionen gör dem till evidens. Härkomsten bevaras i varje händelse — * ett värde som kommit utifrån får aldrig se ut som något teknikern * själv mätt. */ export function protokollTillHandelser(protokoll, profil, kalla) { const ut = []; // Vägen kommer från leverantörsprofilen, alltså utifrån. Uppslaget // begränsas till egna, uppräkningsbara egenskaper: annars når en // profil med `__proto__` eller `constructor.prototype` fram till // prototypkedjan. Läsningen är visserligen ofarlig i sig, men en // extern indata ska inte ha den friheten (QUALITY-AUDIT-2 · m-8). const plocka = (objekt, vag) => String(vag ?? "") .split(".") .reduce((o, n) => (o !== null && typeof o === "object" && Object.hasOwn(o, n) ? o[n] : undefined), objekt); for (const kod of plocka(protokoll, profil?.felkoder?.vag) ?? []) { ut.push({ typ: "observation", text: `${plocka(kod, profil.felkoder.kod) ?? "?"} — ${plocka(kod, profil.felkoder.text) ?? "utan beskrivning"}`, kalla, }); } for (const värde of plocka(protokoll, profil?.matvarden?.vag) ?? []) { ut.push({ typ: "matvarde", beskrivning: String(plocka(värde, profil.matvarden.beskrivning) ?? "Avläst värde"), varde: String(plocka(värde, profil.matvarden.varde) ?? ""), enhet: plocka(värde, profil.matvarden.enhet) ?? undefined, // Instrumentets identitet följer med när leverantören lämnar den. // Utan den nedgraderas värdet till E1 — ett avläst tal utan känt // instrument är inte en spårbar mätning (QUALITY-AUDIT M-1). matdonId: plocka(värde, profil.matvarden.instrumentId) ?? undefined, matdonBeteckning: plocka(värde, profil.matvarden.instrument) ?? undefined, kalla, }); } return ut.filter((h) => h.typ !== "matvarde" || h.varde !== ""); }