Merge main: Guidad Felsökning flyttar ur roten till felsokning/

Main är sedan den här grenen skapades en helt annan produkt — Semantika,
en mobilapp med egen CDK-infrastruktur. Den äger nu repots rot: en
npm-workspaces-monorepo med apps/mobile, services/api och infra.

För att båda ska rymmas i samma repo flyttar Guidad Felsökning in i en
egen katalog i stället för att göra anspråk på roten:

  felsokning/app        webbklienten (Vite, egen package.json och
                        eslint-/vitest-konfiguration)
  felsokning/services   plattformstjänsten och AI-orkestern
  felsokning/infra      Terraform och databasschemat
  felsokning/docs       vision, moduler, drift
  felsokning/supabase   edge-funktion och migrationer

Merge:n hade tagit bort 128 filer som Guidad Felsökning bygger på —
värdapplikationens komponenter, Supabase-klienten, tillgångar — eftersom
main raderat dem och den här grenen inte råkat ändra just dem. De är
återställda på sin nya plats. Utan dem gick varken bygget eller
testerna: ai.ts och synk.ts importerar Supabase-klienten.

Semantikas rotfiler är orörda: package.json, eslint.config.js och
.github/workflows/ är deras. Guidad Felsökning har egna motsvarigheter i
sin katalog.

CI flyttar samtidigt från GitHub Actions till .gitea/workflows — samma
syntax, egna runners. .github/workflows/ tillhör Semantika härefter.

Verifierat på den nya platsen: 96 vitest-tester, typkontroll, eslint på
både klient och tjänster, bygge, och integrationstest mot riktig Postgres.

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-04 12:22:49 +00:00
298 changed files with 23491 additions and 6140 deletions
@@ -0,0 +1,13 @@
# AI-orkestern — plattformens AI-endpoint som pod.
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
COPY server.mjs ./
ENV NODE_ENV=production PORT=8080
USER node
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/halsa || exit 1
CMD ["node", "server.mjs"]
+86
View File
@@ -0,0 +1,86 @@
{
"name": "ai-orkester",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-orkester",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
}
}
}
@@ -0,0 +1,13 @@
{
"name": "ai-orkester",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0"
}
}
+341
View File
@@ -0,0 +1,341 @@
// AI-orkestern som fristående tjänst — plattformens K8s-native AI-endpoint.
//
// Samma orkester som edge-funktionen (supabase/functions/felsokning-ai),
// men körbar som pod i Kubernetes: Claude API-nyckeln och Supabase
// JWT-hemligheten kommer från secrets, hälsokontroll på /halsa, och
// tjänsten verifierar användarens JWT själv (HS256) eftersom den inte
// står bakom Supabase-gatewayen.
//
// Miljövariabler:
// ANTHROPIC_API_KEY Claude-nyckel (plattformshemlighet, krävs)
// SUPABASE_JWT_SECRET JWT-hemlighet för verifiering (krävs — fail closed)
// PORT default 8080
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
const PORT = Number(process.env.PORT ?? 8080);
const MAX_PROMPT_LANGD = 40000;
// Rymmer en nedskalad arbetsorderbild som data-URL.
const MAX_KROPP = 4 * 1024 * 1024;
const MAX_BILD_LANGD = 3_000_000;
const GRUND_REGLER = `Du arbetar i Guidad Felsökning, en professionell diagnostikplattform för tekniker. Du är inte en AI-mekaniker: du ersätter aldrig teknikerns kompetens eller tillverkarens dokumentation.
Absoluta regler:
- Hitta aldrig på fakta. Låtsas aldrig veta. Gissa aldrig.
- Presentera aldrig en hypotes som ett konstaterat fel.
- Skilj strikt mellan raderna du returnerar:
- "verifierat": endast det som är belagt av mätvärden eller dokumenterade kontroller i underlaget.
- "observation": det som konstaterats utan slutsats.
- "hypotes": möjlig felorsak som KRÄVER verifiering — formulera alltid vad som skulle verifiera den.
- "rekommendation": nästa verifierbara kontroll eller mätning.
- Om underlaget är otillräckligt: säg det uttryckligen i en observation och rekommendera vad som behöver dokumenteras.
- Skriv aldrig "OK", "kontrollerad", "inga fel" eller "åtgärdad" om evidens saknas i underlaget — skriv i stället "Evidens saknas" och begär rätt underlag: foto för det synliga, video med ljud för det som låter, video för det som rör sig, mätvärde för det som mäts, foto av skärmen när ett instrument eller en diagnosdator visar informationen.
- Skriv aldrig "felet konstaterat" om symptomet varken reproducerats eller verifierats med dokumentation — skriv då "Kundens beskrivning kunde inte reproduceras under de förhållanden som rådde vid undersökningen."
- Svara på svenska, konsekvent, kortfattat och metodiskt. "nastaSteg" är EN konkret, verifierbar åtgärd.`;
const SVARS_SCHEMA = {
type: "object",
properties: {
rader: {
type: "array",
items: {
type: "object",
properties: {
typ: { type: "string", enum: ["observation", "verifierat", "hypotes", "rekommendation"] },
text: { type: "string" },
},
required: ["typ", "text"],
additionalProperties: false,
},
},
nastaSteg: { type: "string" },
},
required: ["rader", "nastaSteg"],
additionalProperties: false,
};
const METODIK_SCHEMA = {
type: "object",
properties: {
metodikId: { type: "string", enum: ["vibration", "elsystem", "generisk"] },
},
required: ["metodikId"],
additionalProperties: false,
};
// Fält som kan tolkas ur en skannad arbetsorder (samma katalog som
// edge-funktionen och klienten).
const ARBETSORDER_FALT_ID = [
"kund_namn", "kund_foretag", "kund_telefon", "kund_epost",
"fordon_regnr", "fordon_vin", "fordon_marke", "fordon_modell",
"fordon_motor", "fordon_arsmodell", "fordon_matarstallning",
"fordon_motorkod", "fordon_vaxellada",
"ao_nummer", "ao_claim", "ao_skadenummer", "ao_referens", "ao_serviceradgivare", "ao_bokningsdatum",
"felbeskrivning",
];
const ARBETSORDER_SCHEMA = {
type: "object",
properties: {
falt: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", enum: ARBETSORDER_FALT_ID },
varde: { type: "string" },
konfidens: { type: "number", minimum: 0, maximum: 1 },
omrade: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
bredd: { type: "number" },
hojd: { type: "number" },
},
required: ["x", "y", "bredd", "hojd"],
additionalProperties: false,
},
},
required: ["id", "varde", "konfidens"],
additionalProperties: false,
},
},
},
required: ["falt"],
additionalProperties: false,
};
const DOKUMENT_REGLER = `Du tolkar ett foto av en arbetsorder från en fordonsverkstad (OCR + layoutförståelse). Layouter varierar mellan verkstäder — identifiera fälten oavsett var de står, i tabeller såväl som fritext.
Regler:
- Returnera ENDAST fält som faktiskt går att läsa i dokumentet. Hitta aldrig på värden.
- "konfidens" är din läs-säkerhet 01: 1.0 endast vid helt entydig läsning; sänk vid oskarp text, handstil eller tvetydig layout.
- "omrade" anger ungefär var värdet står i bilden, normaliserat 01 (x, y = övre vänstra hörnet).
- Normalisera: registreringsnummer i versaler utan mellanslag, VIN med 17 tecken, datum som ÅÅÅÅ-MM-DD, mätarställning med enhet.
- "felbeskrivning" är kundens beskrivna problem/arbetsbegäran om en sådan finns i dokumentet.`;
// Visual-first: instrument och diagnosskärmar fotograferas i stället för
// att integreras — kameran är det universella gränssnittet.
const INSTRUMENT_SCHEMA = {
type: "object",
properties: {
instrumenttyp: { type: "string" },
varden: {
type: "array",
items: {
type: "object",
properties: {
beskrivning: { type: "string" },
varde: { type: "string" },
enhet: { type: "string" },
konfidens: { type: "number", minimum: 0, maximum: 1 },
},
required: ["beskrivning", "varde", "konfidens"],
additionalProperties: false,
},
},
},
required: ["instrumenttyp", "varden"],
additionalProperties: false,
};
const INSTRUMENT_REGLER = `Du läser av ett foto av ett instrument eller en skärm i en fordonsverkstad: multimeter, diagnosdator, oscilloskop, batteritestare, mätarkluster, manometer, utskrift eller etikett.
Regler:
- Identifiera instrumenttypen och extrahera ENDAST värden som faktiskt går att läsa i bilden. Hitta aldrig på värden.
- Varje värde får en beskrivning (t.ex. "Batterispänning", "Felkod P0301 misständning cylinder 1"), värdet som text och enhet när den syns (V, A, Ω, bar, °C, rpm …).
- Felkoder returneras som egna värden med kod och klartext om skärmen visar den.
- "konfidens" är din läs-säkerhet 01; sänk vid oskarp bild, reflexer eller delvis skymda siffror.`;
// Orkestern: en modell per uppgiftstyp (samma routing som edge-funktionen).
const ORKESTER = {
handledning: {
modell: "claude-sonnet-5",
effort: "medium",
maxTokens: 1024,
system: `${GRUND_REGLER}\n\nUppgift: Du är teknikerns digitala felsökningshandledare. Teknikern har just dokumenterat något nytt — svara på det i ljuset av ärendebriefen. En kontroll i taget, inte långa utläggningar: max fyra rader plus nästa steg.`,
schema: SVARS_SCHEMA,
},
granskning: {
modell: "claude-opus-5",
effort: "high",
maxTokens: 2048,
system: `${GRUND_REGLER}\n\nUppgift: Granska HELA underlaget i ärendet. Leta specifikt efter (1) motsägelser mellan observationer eller mätvärden, (2) luckor — kontroller som borde vara gjorda givet symptombilden men saknas, (3) förhastade slutsatser som saknar stöd. Rapportera motsägelser och luckor som "observation", möjliga felorsaker som underlaget antyder som "hypotes", och det viktigaste att åtgärda som "rekommendation". Max sex rader.`,
schema: SVARS_SCHEMA,
},
sammanfattning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 1024,
system: `${GRUND_REGLER}\n\nUppgift: En tekniker lämnar över ärendet. Komplettera överlämningen med det som inte syns i checklistorna: risker och osäkerheter i underlaget, klassificerade som rader. "nastaSteg" är det första nästa tekniker bör göra. Max fyra rader.`,
schema: SVARS_SCHEMA,
},
metodikval: {
modell: "claude-haiku-4-5",
maxTokens: 256,
system: `Du klassificerar en felbeskrivning från en verkstad till EN felsökningsmetodik:
- "vibration": vibrationer, skakningar eller obalans under körning.
- "elsystem": elektriska fel — reläer, säkringar, spänning, batteri, belysning, givare, strömförsörjning.
- "generisk": allt annat, eller när det är oklart.
Gissa inte: välj "generisk" om beskrivningen inte tydligt hör till en specifik metodik.`,
schema: METODIK_SCHEMA,
},
// Ärendestart: teknikern fotograferar arbetsordern; vi läser dokumentet
// och returnerar strukturerade fält med konfidens per värde.
// Visual-first: foto av instrument/diagnosskärm → strukturerade värden.
instrumentavlasning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 1024,
system: INSTRUMENT_REGLER,
schema: INSTRUMENT_SCHEMA,
bild: true,
},
dokumenttolkning: {
modell: "claude-sonnet-5",
effort: "low",
maxTokens: 2048,
system: DOKUMENT_REGLER,
schema: ARBETSORDER_SCHEMA,
bild: true,
},
};
// Minimal HS256-JWT-verifiering (Supabase legacy JWT secret) utan beroenden.
export function verifieraJwt(token, hemlighet) {
const delar = token.split(".");
if (delar.length !== 3) return null;
const [huvud, kropp, signatur] = delar;
const forvantad = createHmac("sha256", hemlighet)
.update(`${huvud}.${kropp}`)
.digest("base64url");
const a = Buffer.from(signatur);
const b = Buffer.from(forvantad);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
const anspServer = JSON.parse(Buffer.from(kropp, "base64url").toString("utf8"));
if (typeof anspServer.exp === "number" && anspServer.exp * 1000 < Date.now()) return null;
return anspServer;
} catch {
return null;
}
}
function svara(res, status, kropp) {
const data = JSON.stringify(kropp);
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(data);
}
async function lasKropp(req) {
const bitar = [];
let storlek = 0;
for await (const bit of req) {
storlek += bit.length;
if (storlek > MAX_KROPP) throw new Error("för stor kropp");
bitar.push(bit);
}
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
}
export function skapaServer() {
return createServer(async (req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
});
return res.end();
}
if (req.method === "GET" && req.url === "/halsa") {
return svara(res, 200, { status: "ok" });
}
if (req.method !== "POST" || req.url !== "/api/ai") {
return svara(res, 404, { error: "Okänd resurs." });
}
const apiNyckel = process.env.ANTHROPIC_API_KEY;
// JWT_SECRET i självhostat läge (delas med plattformstjänsten);
// SUPABASE_JWT_SECRET när auth ligger hos Supabase.
const jwtHemlighet = process.env.JWT_SECRET ?? process.env.SUPABASE_JWT_SECRET;
if (!apiNyckel || !jwtHemlighet) {
return svara(res, 503, { error: "AI-tjänsten är inte konfigurerad." });
}
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token || !verifieraJwt(token, jwtHemlighet)) {
return svara(res, 401, { error: "Inloggning krävs." });
}
let uppgift, prompt, bild;
try {
({ uppgift, prompt, bild } = await lasKropp(req));
} catch {
return svara(res, 400, { error: "Ogiltig förfrågan." });
}
const konfig = ORKESTER[uppgift];
if (!konfig) return svara(res, 400, { error: "Okänd uppgift." });
if (typeof prompt !== "string" || prompt.trim().length === 0) {
return svara(res, 400, { error: "prompt saknas." });
}
if (prompt.length > MAX_PROMPT_LANGD) {
return svara(res, 400, { error: "prompt är för lång." });
}
// Bilduppgifter: data-URL → image-block före textprompten.
let innehall = prompt;
if (konfig.bild) {
const matchning =
typeof bild === "string" && bild.length <= MAX_BILD_LANGD
? bild.match(/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/)
: null;
if (!matchning) return svara(res, 400, { error: "bild saknas eller har fel format." });
innehall = [
{ type: "image", source: { type: "base64", media_type: matchning[1], data: matchning[2] } },
{ type: "text", text: prompt },
];
}
try {
const klient = new Anthropic({ apiKey: apiNyckel });
const svar = await klient.beta.messages.create({
model: konfig.modell,
max_tokens: konfig.maxTokens,
output_config: {
...(konfig.effort ? { effort: konfig.effort } : {}),
format: { type: "json_schema", schema: konfig.schema },
},
betas: ["server-side-fallback-2026-07-01"],
fallbacks: "default",
system: [{ type: "text", text: konfig.system, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: innehall }],
});
if (svar.stop_reason === "refusal") {
return svara(res, 502, { error: "AI-tjänsten avböjde förfrågan." });
}
const textBlock = svar.content.find((block) => block.type === "text");
if (!textBlock) return svara(res, 502, { error: "AI-svaret saknade innehåll." });
return svara(res, 200, { modell: konfig.modell, svar: JSON.parse(textBlock.text) });
} catch (fel) {
console.error("ai-orkester:", fel);
return svara(res, 500, { error: "AI-anropet misslyckades." });
}
});
}
if (process.env.NODE_ENV !== "test") {
skapaServer().listen(PORT, () => {
console.log(`ai-orkester lyssnar på :${PORT}`);
});
}
+17
View File
@@ -0,0 +1,17 @@
// Tjänsterna är rena Node-moduler — inga React-regler, men Node-globaler
// måste vara kända (console, process, Buffer, fetch).
import js from "@eslint/js";
import globals from "globals";
export default [
{ ignores: ["**/node_modules/**"] },
{
files: ["**/*.mjs"],
...js.configs.recommended,
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
globals: { ...globals.node },
},
},
];
+13
View File
@@ -0,0 +1,13 @@
# Plattformstjänsten — självhostad backend (auth, händelse-API, delning).
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
COPY server.mjs bilagor.mjs openapi.yaml ecm-regler.json integrationer.json ./
ENV NODE_ENV=production PORT=8080
USER node
EXPOSE 8080
HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/halsa || exit 1
CMD ["node", "server.mjs"]
+220
View File
@@ -0,0 +1,220 @@
// Bilagor — foton, videoklipp och instrumentbilder.
//
// Tidigare låg de som data-URL:er inne i händelserna. Det gjorde
// händelseloggen tung på ett sätt som drabbade allt som läser den:
// synken drog med hela bildmassan var femtonde sekund, kundvyn likaså,
// och en säkerhetskopia av loggen var i praktiken en kopia av alla foton.
//
// Nu ligger innehållet utanför händelsen och loggen bär en referens med
// SHA-256 av innehållet. Det är inte en försvagning av bevisvärdet utan
// en förstärkning: hashen står i den append-only-skyddade loggen, så en
// bild som bytts ut går att upptäcka. Tidigare låg bilden i loggen och
// måste helt enkelt tros på.
//
// Innehållsadressering ger dedup på köpet — samma foto som dokumenteras
// två gånger lagras en gång.
//
// Två lager, valda med BILAGE_LAGE:
// databas bytea i en egen tabell (standard, fungerar överallt)
// s3 S3-kompatibel objektlagring — loggen och bilderna växer
// då oberoende av varandra
import { createHash, createHmac } from "node:crypto";
export function innehallsHash(buffert) {
return createHash("sha256").update(buffert).digest("hex");
}
// Vad som får laddas upp. Listan är avsiktligt kort: det här är
// dokumentation av ett fordon, inte en filserver.
export const TILLATNA_MEDIATYPER = [
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/webm",
"video/quicktime",
];
export function mediatypGiltig(typ) {
return TILLATNA_MEDIATYPER.includes((typ ?? "").split(";")[0].trim().toLowerCase());
}
// ---- AWS Signature Version 4 -------------------------------------------
//
// Egen implementation i stället för molnleverantörens SDK: tjänsten
// behöver två operationer (PUT och GET av ett objekt) och SDK:t hade
// dragit in tiotals megabyte beroenden i en bild som annars bara har
// pg-drivrutinen. Signeringen korsverifieras mot botocore i testerna.
const hmac = (nyckel, data) => createHmac("sha256", nyckel).update(data, "utf8").digest();
export function signeringsnyckel(hemlighet, datum, region, tjanst) {
const d = hmac(`AWS4${hemlighet}`, datum);
const r = hmac(d, region);
const t = hmac(r, tjanst);
return hmac(t, "aws4_request");
}
// Sökvägen kanoniseras INTE här, och det är ett medvetet val.
//
// S3 följer andra URL-kodningsregler än övriga AWS-tjänster, och en
// felgissad regel ger signaturer som ser rimliga ut men avvisas. I
// stället begränsas det som kan hamna i en nyckel (se namnGiltigt) till
// tecken som aldrig behöver kodas: hink och prefix är [a-z0-9.-/] och
// resten av nyckeln är hexadecimal ur innehållshashen. Då finns ingen
// kodningsfråga att gissa fel på.
export const NAMN_MONSTER = /^[a-z0-9][a-z0-9./-]*$/;
export function namnGiltigt(namn) {
return typeof namn === "string" && namn.length <= 128 && NAMN_MONSTER.test(namn) && !namn.includes("..");
}
/**
* Bygger Authorization-huvudet för en S3-förfrågan.
*
* @param metod HTTP-metod, t.ex. "PUT"
* @param url fullständig URL till objektet
* @param nyttolast kroppen (Buffer) — tom Buffer för GET
* @param uppgifter { nyckelId, hemlighet, region }
* @param tidsstampel ISO-basic, t.ex. "20260803T120000Z"
*/
export function signera(metod, url, nyttolast, uppgifter, tidsstampel) {
// host, inte hostname: vid en icke-standardport måste porten med i
// huvudet, annars avvisar självhostad S3 (MinIO, Ceph) signaturen.
const { host, pathname, search } = new URL(url);
const datum = tidsstampel.slice(0, 8);
const nyttolastHash = innehallsHash(nyttolast);
const huvuden = {
host,
"x-amz-content-sha256": nyttolastHash,
"x-amz-date": tidsstampel,
};
const signerade = Object.keys(huvuden).sort();
const kanoniskaHuvuden = signerade.map((n) => `${n}:${huvuden[n].trim()}\n`).join("");
const signeradeNamn = signerade.join(";");
// Frågesträngen ska vara sorterad och kodad. Vi använder inga
// parametrar i dag, men kanoniseringen måste ändå vara rätt.
const parametrar = [...new URLSearchParams(search).entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([n, v]) => `${encodeURIComponent(n)}=${encodeURIComponent(v)}`)
.join("&");
const kanoniskForfragan = [
metod,
pathname,
parametrar,
kanoniskaHuvuden,
signeradeNamn,
nyttolastHash,
].join("\n");
const omfang = `${datum}/${uppgifter.region}/s3/aws4_request`;
const attSignera = [
"AWS4-HMAC-SHA256",
tidsstampel,
omfang,
innehallsHash(Buffer.from(kanoniskForfragan, "utf8")),
].join("\n");
const signatur = createHmac("sha256", signeringsnyckel(uppgifter.hemlighet, datum, uppgifter.region, "s3"))
.update(attSignera, "utf8")
.digest("hex");
return {
...huvuden,
Authorization:
`AWS4-HMAC-SHA256 Credential=${uppgifter.nyckelId}/${omfang}, ` +
`SignedHeaders=${signeradeNamn}, Signature=${signatur}`,
};
}
// ---- Lager --------------------------------------------------------------
// Innehållsadresserat: nyckeln ÄR hashen. Två identiska filer blir en.
function objektnyckel(prefix, hash) {
const rent = prefix.replace(/^\/+|\/+$/g, "");
return `${rent ? `${rent}/` : ""}${hash.slice(0, 2)}/${hash}`;
}
export function skapaS3Lager(konfig, hamtare = fetch, nu = () => new Date()) {
const prefix = (konfig.prefix ?? "").replace(/^\/+|\/+$/g, "");
if (!namnGiltigt(konfig.hink) || (prefix !== "" && !namnGiltigt(prefix))) {
throw new Error(
"S3_HINK och S3_PREFIX får bara innehålla a-z, 0-9, punkt, bindestreck och snedstreck.",
);
}
const bas = konfig.endpoint.replace(/\/$/, "");
const url = (hash) => `${bas}/${konfig.hink}/${objektnyckel(prefix, hash)}`;
const stampel = () => nu().toISOString().replace(/[-:]|\.\d{3}/g, "");
return {
namn: "s3",
async spara(hash, data) {
const mal = url(hash);
const svar = await hamtare(mal, {
method: "PUT",
headers: signera("PUT", mal, data, konfig, stampel()),
body: data,
});
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid skrivning.`);
},
async hamta(hash) {
const mal = url(hash);
const svar = await hamtare(mal, {
method: "GET",
headers: signera("GET", mal, Buffer.alloc(0), konfig, stampel()),
});
if (svar.status === 404) return null;
if (!svar.ok) throw new Error(`Objektlagringen svarade ${svar.status} vid läsning.`);
return Buffer.from(await svar.arrayBuffer());
},
};
}
export function skapaDatabasLager(pool) {
return {
namn: "databas",
async spara(hash, data) {
// Innehållsadresserat: finns hashen redan är filen redan sparad.
await pool.query(
`insert into bilage_innehall (hash, data) values ($1, $2) on conflict (hash) do nothing`,
[hash, data],
);
},
async hamta(hash) {
const rad = await pool.query(`select data from bilage_innehall where hash = $1`, [hash]);
return rad.rowCount === 0 ? null : rad.rows[0].data;
},
};
}
// Väljer lager ur miljön. Saknas något som s3-läget kräver failar vi
// hellre vid start än vid första uppladdningen.
export function valjLager(env, pool, hamtare = fetch) {
if ((env.BILAGE_LAGE ?? "databas") !== "s3") return skapaDatabasLager(pool);
const saknas = ["S3_ENDPOINT", "S3_HINK", "S3_REGION", "S3_NYCKEL_ID", "S3_NYCKEL"].filter((n) => !env[n]);
if (saknas.length > 0) {
throw new Error(`BILAGE_LAGE=s3 kräver ${saknas.join(", ")}.`);
}
return skapaS3Lager(
{
endpoint: env.S3_ENDPOINT,
hink: env.S3_HINK,
region: env.S3_REGION,
prefix: env.S3_PREFIX ?? "bilagor",
nyckelId: env.S3_NYCKEL_ID,
hemlighet: env.S3_NYCKEL,
},
hamtare,
);
}
@@ -0,0 +1,59 @@
{
"version": "2.0",
"beskrivning": "ECM Knowledge Library — serverdistribuerat regelpaket. Uppdateras utan appändring: redigera filen (eller montera en ConfigMap över den i klustret) och starta om tjänsten. Klienten hämtar paketet vid inloggning och faller tillbaka till sitt inbyggda standardpaket offline.",
"arendetypRegler": {
"Garanti": [
{ "id": "garanti_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Garantiärenden kräver dokumenterad mätarställning." },
{ "id": "garanti_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Garantiärenden kräver kontrollerad servicehistorik." },
{ "id": "garanti_claim", "rubrik": "Claim-/garantinummer registrerat", "krav": "claim", "detaljVidBrist": "Ange claim-/garantinummer (läses ur arbetsordern)." }
],
"Goodwill": [
{ "id": "goodwill_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Goodwillärenden kräver dokumenterad mätarställning." },
{ "id": "goodwill_historik", "rubrik": "Servicehistorik kontrollerad", "krav": "historik", "detaljVidBrist": "Goodwillärenden kräver kontrollerad servicehistorik." }
],
"Försäkring": [
{ "id": "forsakring_skadenummer", "rubrik": "Skadenummer registrerat", "krav": "skadenummer", "detaljVidBrist": "Försäkringsärenden kräver skadenummer (läses ur arbetsordern)." },
{ "id": "forsakring_bildbevis", "rubrik": "Bildbevis finns", "krav": "foto", "detaljVidBrist": "Försäkringsärenden kräver bilddokumentation." }
],
"Reklamation": [
{ "id": "reklamation_historik", "rubrik": "Historik och tidigare försök kontrollerade", "krav": "historik", "detaljVidBrist": "Reklamationer kräver kontrollerad historik (tidigare reparationer/försök)." }
],
"Begagnatgaranti": [
{ "id": "begagnat_miltal", "rubrik": "Miltal dokumenterat", "krav": "miltal", "detaljVidBrist": "Begagnatgaranti kräver dokumenterad mätarställning." }
]
},
"undantagsorsaker": [
"Komponenten är oåtkomlig",
"Fordonet kan inte lyftas säkert",
"Kunden avböjde demontering",
"Dålig sikt/åtkomst",
"Utrustning saknas"
],
"orsakskategorier": [
"Normalt slitage",
"Ålder",
"Körsträcka",
"Materialutmattning",
"Tillverkningsfel",
"Bristande underhåll",
"Felaktig tidigare reparation",
"Yttre påverkan",
"Korrosion",
"Överhettning",
"Förorening",
"Felaktig användning",
"Modifiering",
"Olycka eller skada",
"Okänd orsak"
],
"underlagskallor": [
"Foto",
"Video",
"Mätresultat",
"Diagnosutläsning",
"Tidigare historik",
"Servicehistorik",
"Teknisk dokumentation",
"Direkt observation"
]
}
@@ -0,0 +1,96 @@
{
"version": "1.0",
"beskrivning": "Register över märkesspecifika kopplingar. Leverantörer är data, inte kod: nya märken/dataleverantörer läggs till här (eller via ConfigMap-mount, INTEGRATIONER_FIL) utan att applikationen byggs om. Fält märkta hemlig=true maskeras alltid i API-svar och lagras krypterat.",
"leverantorer": [
{
"id": "generisk_vin",
"namn": "Generisk VIN-tjänst",
"beskrivning": "Valfri dataleverantör med VIN-uppslag över HTTP. Ange bas-URL och nyckel enligt leverantörens dokumentation.",
"falt": [
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
],
"uppslag": {
"urlFalt": "bas_url",
"auth": "bearer",
"authFalt": "api_nyckel",
"svarsfalt": {
"marke": "make",
"modell": "model",
"arsmodell": "year",
"motor": "engine",
"vaxellada": "transmission"
}
}
},
{
"id": "vag_erwin",
"namn": "Volkswagen Group erWin",
"beskrivning": "OEM-portal för VW, Audi, Škoda och SEAT. Kräver eget abonnemang hos tillverkaren.",
"falt": [
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
{ "nyckel": "anvandarnamn", "etikett": "Användarnamn", "hemlig": false },
{ "nyckel": "losenord", "etikett": "Lösenord", "hemlig": true }
],
"uppslag": {
"urlFalt": "bas_url",
"auth": "basic",
"authFalt": "anvandarnamn",
"authFalt2": "losenord",
"svarsfalt": {
"marke": "brand",
"modell": "model",
"arsmodell": "modelYear",
"motor": "engineCode",
"vaxellada": "gearbox"
}
}
},
{
"id": "volvo_vida",
"namn": "Volvo VIDA",
"beskrivning": "Volvos verkstadsportal. Kräver eget abonnemang och verkstads-ID.",
"falt": [
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
{ "nyckel": "verkstadsid", "etikett": "Verkstads-ID", "hemlig": false },
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
],
"uppslag": {
"urlFalt": "bas_url",
"auth": "header",
"authHeader": "X-Api-Key",
"authFalt": "api_nyckel",
"svarsfalt": {
"marke": "make",
"modell": "model",
"arsmodell": "year",
"motor": "engine",
"vaxellada": "transmission"
}
}
},
{
"id": "fordonsregister",
"namn": "Fordonsregisteruppslag (regnr → fordon)",
"beskrivning": "Nationell fordonsdata via vald leverantör. Uppslag sker på registreringsnummer i stället för VIN.",
"nyckeltyp": "regnr",
"falt": [
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {regnr} som platshållare)", "hemlig": false },
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
],
"uppslag": {
"urlFalt": "bas_url",
"auth": "query",
"authParam": "key",
"authFalt": "api_nyckel",
"svarsfalt": {
"marke": "make",
"modell": "model",
"arsmodell": "year",
"vin": "vin",
"motor": "engine"
}
}
}
]
}
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env bash
# Integrationstest för plattformstjänsten mot en riktig Postgres.
# Kör hela flödet: registrering (org + admin), synk av händelser,
# idempotens, append-only-triggern, organisationsisolering, publik
# delning med filtrering samt rollstyrd användarhantering.
#
# Krav: postgres (initdb/pg_ctl), node, curl. Kör: bash integrationstest.sh
set -euo pipefail
cd "$(dirname "$0")"
PGPORT=5499
APPPORT=8399
BAS="http://127.0.0.1:$APPPORT"
DATADIR=$(mktemp -d)
PGBIN=$(ls -d /usr/lib/postgresql/*/bin | head -1)
stada() {
kill "$SERVER_PID" 2>/dev/null || true
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' stop -m immediate" >/dev/null 2>&1 || true
rm -rf "$DATADIR"
}
trap stada EXIT
# ---- Postgres upp ----
chown postgres "$DATADIR"
su postgres -c "$PGBIN/initdb -D '$DATADIR' -A trust" >/dev/null
su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\""
su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'"
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/postgres-init.sql
# ---- Tjänsten upp ----
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs &
SERVER_PID=$!
sleep 1
falt() { node -pe "JSON.parse(require('fs').readFileSync(0,'utf8'))$1"; }
kontroll() { # kontroll <namn> <faktiskt> <forvantat>
if [ "$2" = "$3" ]; then echo "$1"; else echo "$1: fick '$2', väntade '$3'"; exit 1; fi
}
# 1. Registrera organisation A (admin)
SVAR=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
-d '{"epost":"anna@a.se","losenord":"hemligt123","namn":"Anna","organisation":"Verkstad A"}')
TOKEN_A=$(echo "$SVAR" | falt .token); kontroll "registrering ger admin" "$(echo "$SVAR" | falt .roll)" "admin"
kontroll "organisationsnamn" "$(echo "$SVAR" | falt .organisation)" "Verkstad A"
# 2. Skapa ärende + händelser (inkl. internt kategoribyte)
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-test1","nummer":1,"skapad":"2026-08-03T08:00:00Z","delningskod":"delkod123"}' >/dev/null
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[
{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"Startar inte"}},
{"id":"h2","tidpunkt":"2026-08-03T08:02:00Z","anvandare":"Anna","handelse":{"typ":"kategori_byte","kategori":"provkorning"}}
]}' >/dev/null
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length)
kontroll "händelser sparade" "$ANTAL" "2"
# 3. Idempotens: samma push igen ändrar ingenting
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"ÄNDRAD"}}]}' >/dev/null
TEXT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt '.handelser[0].handelse.text')
kontroll "händelser skrivs aldrig över" "$TEXT" "Startar inte"
# 4. Append-only-triggern stoppar direkta ändringar i databasen
if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update felsokning_handelser set anvandare='hackad' where id='h1'" 2>/dev/null; then
echo "✗ append-only-trigger saknas"; exit 1
else
echo "✓ databastriggern avvisar update"
fi
# 5. Organisationsisolering: org B ser inte org A:s ärenden
TOKEN_B=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \
-d '{"epost":"bo@b.se","losenord":"hemligt123","namn":"Bo","organisation":"Verkstad B"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B nekas org A:s händelser" "$KOD" "404"
ANTAL_B=$(curl -s "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)
kontroll "org B:s ärendelista är tom" "$ANTAL_B" "0"
# 6. Publik delning: utan inloggning, interna poster filtrerade
DELAT=$(curl -s "$BAS/api/delad/delkod123")
kontroll "delning nås utan inloggning" "$(echo "$DELAT" | falt .handelser.length)" "1"
kontroll "kategoribyte filtreras ur delning" "$(echo "$DELAT" | falt '.handelser[0].handelse.typ')" "felbeskrivning"
# 7. Rollstyrning: admin skapar tekniker; tekniker får inte hantera användare
curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"epost":"johan@a.se","losenord":"hemligt123","namn":"Johan","roll":"tekniker"}' >/dev/null
TOKEN_J=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"johan@a.se","losenord":"hemligt123"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_J" \
-H 'Content-Type: application/json' -d '{"epost":"x@a.se","losenord":"hemligt123","namn":"X","roll":"admin"}')
kontroll "tekniker nekas användarhantering" "$KOD" "403"
ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" | falt .anvandare.length)
kontroll "org A har två användare" "$ANTAL_ANV" "2"
# 8. Tekniker i org A når ärendet (delad arbetsyta inom organisationen)
ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_J" | falt .handelser.length)
kontroll "tekniker i samma org når ärendet" "$ANTAL" "2"
# 9. Organisationsöversikten: tekniker nekas; arbetsledare får status ur loggen
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_J")
kontroll "tekniker nekas översikten" "$KOD" "403"
curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"epost":"lisa@a.se","losenord":"hemligt123","namn":"Lisa","roll":"arbetsledare"}' >/dev/null
TOKEN_L=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"lisa@a.se","losenord":"hemligt123"}' | falt .token)
OVERSIKT=$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L")
kontroll "arbetsledaren ser organisationens ärenden" "$(echo "$OVERSIKT" | falt .arenden.length)" "1"
kontroll "översikten härleder felbeskrivning" "$(echo "$OVERSIKT" | falt '.arenden[0].felbeskrivning')" "Startar inte"
kontroll "översikten härleder status" "$(echo "$OVERSIKT" | falt '.arenden[0].avslutat')" "false"
kontroll "översikten räknar händelser" "$(echo "$OVERSIKT" | falt '.arenden[0].antal_handelser')" "2"
# 9b. Omfördelning: arbetsledaren listar användare och sätter ny ansvarig
ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" | falt .anvandare.length)
kontroll "arbetsledaren kan lista användare" "$ANTAL_ANV" "3"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_L" \
-H 'Content-Type: application/json' -d '{"epost":"y@a.se","losenord":"hemligt123","namn":"Y","roll":"tekniker"}')
kontroll "arbetsledaren nekas skapa användare" "$KOD" "403"
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_L" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"h-omf","tidpunkt":"2026-08-03T08:05:00Z","anvandare":"Lisa","handelse":{"typ":"ansvarig_satt","ansvarig":"Johan"}}]}' >/dev/null
kontroll "översikten visar ny ansvarig" "$(curl -s "$BAS/api/oversikt" -H "Authorization: Bearer $TOKEN_L" | falt '.arenden[0].ansvarig')" "Johan"
# 9c. Organisationsinställningar: alla läser, bara admin ändrar
INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J")
kontroll "inloggad läser organisationen" "$(echo "$INST" | falt .namn)" "Verkstad A"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \
-H "Authorization: Bearer $TOKEN_J" -H 'Content-Type: application/json' \
-d '{"objekttyper":["Fordon"],"identifieringsmetoder":["VIN"]}')
kontroll "tekniker nekas ändra inställningar" "$KOD" "403"
curl -s -X POST "$BAS/api/organisation/installningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"objekttyper":["Fordon","Hydraulik"],"identifieringsmetoder":["VIN","Manuell inmatning"]}' >/dev/null
INST=$(curl -s "$BAS/api/organisation" -H "Authorization: Bearer $TOKEN_J")
kontroll "inställningarna gäller hela organisationen" "$(echo "$INST" | falt '.installningar.objekttyper.join(",")')" "Fordon,Hydraulik"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/organisation/installningar" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"objekttyper":[],"identifieringsmetoder":["VIN"]}')
kontroll "tomma listor avvisas" "$KOD" "400"
# 10. Live Share-behörighetsnivåer: kund/partner/intern + återkallelse
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[
{"id":"h3","tidpunkt":"2026-08-03T08:03:00Z","anvandare":"Anna","handelse":{"typ":"hypotes","text":"Trasigt relä","niva":"lag"}},
{"id":"h4","tidpunkt":"2026-08-03T08:04:00Z","anvandare":"Anna","handelse":{"typ":"arbetsorder_skannad","falt":[{"id":"kund_namn","etikett":"Namn","varde":"Kalle Kund","konfidens":0.97}]}}
]}' >/dev/null
kontroll "kundkoden filtrerar hypoteser och arbetsorder" "$(curl -s "$BAS/api/delad/delkod123" | falt .handelser.length)" "1"
PARTNERKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod)
PARTNER=$(curl -s "$BAS/api/delad/$PARTNERKOD")
kontroll "partnernivån visar hypoteser men inte arbetsordern" "$(echo "$PARTNER" | falt .handelser.length)" "2"
kontroll "partnernivån döljer kategoribyten" "$(echo "$PARTNER" | falt '.handelser.some(h=>h.handelse.typ==="kategori_byte")')" "false"
kontroll "nivån följer med svaret" "$(echo "$PARTNER" | falt .niva)" "partner"
INTERNKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"intern"}' | falt .kod)
kontroll "internnivån visar allt" "$(curl -s "$BAS/api/delad/$INTERNKOD" | falt .handelser.length)" "5"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/delningar" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: application/json' -d '{"niva":"intern"}')
kontroll "org B kan inte skapa delning av org A:s ärende" "$KOD" "404"
curl -s -X POST "$BAS/api/delningar/$PARTNERKOD/aterkalla" -H "Authorization: Bearer $TOKEN_A" >/dev/null
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$PARTNERKOD")
kontroll "återkallad delning ger 404" "$KOD" "404"
# 10a. Fordonshistorik och felorsaksstatistik
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-test2","nummer":2,"skapad":"2026-08-03T09:00:00Z"}' >/dev/null
curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"id":"arende-test3","nummer":3,"skapad":"2026-08-03T10:00:00Z"}' >/dev/null
curl -s -X POST "$BAS/api/arenden/arende-test2/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"f1","tidpunkt":"2026-08-03T09:01:00Z","anvandare":"Anna","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"XYZ999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}}]}' >/dev/null
curl -s -X POST "$BAS/api/arenden/arende-test3/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[
{"id":"f2","tidpunkt":"2026-08-03T10:01:00Z","anvandare":"Johan","handelse":{"typ":"objekt_identifierat","objekt":{"typ":"Personbil","identifierare":"xyz999","identifieringsmetod":"Regnr","beskrivning":"VW Golf 2023"}}},
{"id":"f3","tidpunkt":"2026-08-03T10:02:00Z","anvandare":"Johan","handelse":{"typ":"felorsak","avvikelse":"Vattenpumpen läcker vid axeltätningen.","orsaker":["Normalt slitage","Ålder"],"underlag":["Foto"],"sakerhet":"hog","atgard":"Byt vattenpump."}}
]}' >/dev/null
HIST=$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_J")
kontroll "fordonshistoriken hittar båda ärendena (case-okänsligt)" "$(echo "$HIST" | falt .arenden.length)" "2"
kontroll "historiken bär felorsakerna" "$(echo "$HIST" | falt '.arenden.flatMap(a=>a.felorsaker).length')" "1"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B ser inte org A:s fordonshistorik" "$(curl -s "$BAS/api/fordon/XYZ999/historik" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length)" "0"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_J")
kontroll "tekniker nekas felorsaksstatistiken" "$KOD" "403"
STAT=$(curl -s "$BAS/api/statistik/felorsaker" -H "Authorization: Bearer $TOKEN_L")
kontroll "statistiken räknar orsakskategorier" "$(echo "$STAT" | falt .orsaker.length)" "2"
kontroll "statistiken är organisationsknuten data" "$(echo "$STAT" | falt '.orsaker[0].antal')" "1"
# 10b. ECM Knowledge Library: regelpaketet serveras till inloggade klienter
REGLER=$(curl -s "$BAS/api/ecm/regler" -H "Authorization: Bearer $TOKEN_J")
kontroll "regelpaketet serveras" "$(echo "$REGLER" | falt .version)" "2.0"
kontroll "regelpaketet innehåller garantiregler" "$(echo "$REGLER" | falt '.arendetypRegler.Garanti.length')" "3"
# 10c. Publikt kundgodkännande — den enda skrivande publika vägen
# Ärendets ursprungliga delningskod saknar registrerad nivå och får
# därför aldrig svara (bara riktiga kundlänkar duger).
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/delkod123/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant"}')
kontroll "legacy-delningskod kan inte svara" "$KOD" "404"
# Kundlänk utan åtgärdsförslag: inget att svara på
KUNDKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod)
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant"}')
kontroll "beslut utan åtgärdsförslag avvisas" "$KOD" "409"
# Lägg in ett åtgärdsförslag
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"handelser":[{"id":"h-forslag","tidpunkt":"2026-08-03T08:06:00Z","anvandare":"Anna","handelse":{"typ":"atgardsforslag","beskrivning":"Byt reläet i kupémodulen.","uppskattadKostnad":"1 450 kr"}}]}' >/dev/null
# Partnerlänk får INTE svara åt kunden
PKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"partner"}' | falt .kod)
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$PKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant"}')
kontroll "partnerlänk kan inte svara åt kunden" "$KOD" "404"
# Ogiltigt beslutsvärde avvisas
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"kanske"}')
kontroll "ogiltigt beslutsvärde avvisas" "$KOD" "400"
# Kunden godkänner via sin länk
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant","kommentar":"Kör på."}')
kontroll "kunden kan godkänna via sin länk" "$KOD" "200"
BESLUT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" \
| falt '.handelser.filter(h=>h.handelse.typ==="kundbeslut").map(h=>h.handelse.kanal+"/"+h.anvandare).join(",")')
kontroll "beskedet loggas med kanal och avsändare" "$BESLUT" "Delningslänk/Kund via delningslänk"
# Ett beslut per ärende — svaret kan inte ändras i efterhand
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"avbojt"}')
kontroll "beskedet kan inte ändras i efterhand" "$KOD" "409"
# Återkallad delning kan inte svara
curl -s -X POST "$BAS/api/delningar/$KUNDKOD/aterkalla" -H "Authorization: Bearer $TOKEN_A" >/dev/null
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$KUNDKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant"}')
kontroll "återkallad länk kan inte svara" "$KOD" "404"
# Takt-begränsning slår till efter upprepade försök
NYKOD=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod)
SISTA=""
for i in 1 2 3 4 5 6 7; do
SISTA=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/delad/$NYKOD/beslut" \
-H 'Content-Type: application/json' -d '{"beslut":"godkant"}')
done
kontroll "takt-begränsning stoppar upprepade försök" "$SISTA" "429"
# 10d. Märkesspecifika kopplingar: kundens egna credentials
# Registret är läsbart för alla inloggade (inställningssidan behöver
# veta vilka leverantörer som finns) men innehåller inga uppgifter.
LEV=$(curl -s "$BAS/api/integrationer/leverantorer" -H "Authorization: Bearer $TOKEN_J")
kontroll "leverantörsregistret är läsbart för tekniker" \
"$(echo "$LEV" | falt '.leverantorer.some(l=>l.id==="generisk_vin")')" "true"
kontroll "registret pekar ut hemliga fält" \
"$(echo "$LEV" | falt '.leverantorer.every(l=>l.falt.some(f=>f.hemlig===true))')" "true"
# Uppgifterna är administratörens ensak
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_J")
kontroll "tekniker ser inte kopplingarnas uppgifter" "$KOD" "403"
# Utan konfigurerad krypteringsnyckel sparas ingenting — fail closed
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}","api_nyckel":"k"}}')
kontroll "utan krypteringsnyckel sparas inga uppgifter" "$KOD" "503"
# Starta om tjänsten med krypteringsnyckel konfigurerad
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
node server.mjs &
SERVER_PID=$!
sleep 1
# Okänd leverantör och ofullständiga uppgifter avvisas
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"leverantor":"hittepa","uppgifter":{"a":"b"}}')
kontroll "okänd leverantör avvisas" "$KOD" "400"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}"}}')
kontroll "ofullständiga uppgifter avvisas" "$KOD" "400"
# Administratören sparar organisationens egna credentials
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' \
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"http://127.0.0.1:9/vin/{vin}","api_nyckel":"sk-verkstad-123456"}}')
kontroll "administratören kan spara credentials" "$KOD" "200"
# Hemligheten lämnar aldrig servern i klartext
INT=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A")
kontroll "krypteringen är konfigurerad" "$(echo "$INT" | falt .krypteringKonfigurerad)" "true"
kontroll "hemligt fält maskeras i svaret" "$(echo "$INT" | falt '.integrationer[0].uppgifter.api_nyckel')" "••••3456"
kontroll "öppet fält visas som det är" "$(echo "$INT" | falt '.integrationer[0].uppgifter.bas_url')" "http://127.0.0.1:9/vin/{vin}"
# … och ligger krypterad i databasen
RAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select uppgifter_krypt from integrationer where leverantor='generisk_vin'")
case "$RAD" in
*sk-verkstad-123456*) echo "✗ uppgifterna ligger i klartext i databasen"; exit 1 ;;
*) echo "✓ uppgifterna ligger krypterade i databasen" ;;
esac
# Uppslag: identifieraren valideras, okonfigurerad koppling ger 404
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"x"}')
kontroll "ogiltig identifierare avvisas" "$KOD" "400"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/volvo_vida/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
# Bas-URL:en pekar inåt (127.0.0.1) — uppslaget får inte bli en väg in i
# klustret. Anropet ska stoppas innan det görs och rapporteras ärligt.
SVAR=$(curl -s -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
kontroll "uppslag mot intern adress avvisas (502)" "$KOD" "502"
kontroll "felet säger varför" "$(echo "$SVAR" | falt '.error.includes("intern adress")')" "true"
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
| falt '.integrationer[0].senaste_status.slice(0,3)')
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
# Kopplingar är organisationsknutna
ANTAL_B=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_B" | falt .integrationer.length)
kontroll "org B ser inte org A:s kopplingar" "$ANTAL_B" "0"
# Borttagning
curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Bearer $TOKEN_A" >/dev/null
ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length)
kontroll "kopplingen kan tas bort" "$ANTAL" "0"
# 10e. Kontospärr, återkallelse och takt-begränsning på inloggning
# Skapa en tekniker att stänga av.
SVAR=$(curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123","namn":"Karin","roll":"tekniker"}')
KARIN_ID=$(echo "$SVAR" | falt .id)
kontroll "ny användare är aktiv" "$(echo "$SVAR" | falt .aktiv)" "true"
TOKEN_K=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
kontroll "teknikern kommer in" "$KOD" "200"
# Tekniker får inte stänga av någon
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_K")
kontroll "tekniker kan inte stänga av konton" "$KOD" "403"
# Administratören kan inte stänga av sig själv
ANNA_ID=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" \
| falt '.anvandare.find(a=>a.epost==="anna@a.se").id')
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$ANNA_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_A")
kontroll "admin kan inte stänga av sig själv" "$KOD" "400"
# Org B kan inte röra org A:s användare
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" \
-H "Authorization: Bearer $TOKEN_B")
kontroll "org B kan inte stänga av org A:s användare" "$KOD" "404"
# Avstängningen gäller OMEDELBART för redan utfärdad token
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/avaktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")
kontroll "utfärdad token slutar gälla direkt vid avstängning" "$KOD" "401"
# … och inloggning stängs
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}')
kontroll "avstängt konto kan inte logga in" "$KOD" "403"
# Öppnas kontot igen fungerar inloggning, men den gamla token är död för gott
curl -s -X POST "$BAS/api/anvandare/$KARIN_ID/aktivera" -H "Authorization: Bearer $TOKEN_A" >/dev/null
TOKEN_K2=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \
-d '{"epost":"karin@a.se","losenord":"hemligt123"}' | falt .token)
kontroll "återöppnat konto kan logga in" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "200"
kontroll "den återkallade token förblir ogiltig" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K")" "401"
# Logga ut på alla enheter återkallar den egna sessionen
curl -s -X POST "$BAS/api/auth/logga-ut-alla" -H "Authorization: Bearer $TOKEN_K2" >/dev/null
kontroll "logga-ut-alla dödar den egna token" "$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_K2")" "401"
# Takt-begränsning: efter tio misslyckade försök spärras kontot en stund
SISTA=""
for i in $(seq 1 11); do
SISTA=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"fel-losenord"}')
done
kontroll "upprepade misslyckade inloggningar spärras" "$SISTA" "429"
# Spärren gäller kontot även med RÄTT lösenord — annars vore den meningslös
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"karin@a.se","losenord":"hemligt123"}')
kontroll "spärren gäller även rätt lösenord" "$KOD" "429"
# Ett annat konto påverkas inte av spärren på det första
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/auth/logga-in" \
-H 'Content-Type: application/json' -d '{"epost":"anna@a.se","losenord":"hemligt123"}')
kontroll "andra konton påverkas inte" "$KOD" "200"
# 10f. Bilagor: innehållet ligger utanför händelsen, hashen i loggen
PNG=$(mktemp); printf '\x89PNG\r\n\x1a\nTESTBILD-1' > "$PNG"
SVAR=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG")
BIL_ID=$(echo "$SVAR" | falt .id)
BIL_HASH=$(echo "$SVAR" | falt .hash)
kontroll "bilagan får en hash" "${#BIL_HASH}" "64"
kontroll "hashen är innehållets" "$BIL_HASH" "$(sha256sum "$PNG" | cut -d' ' -f1)"
# Samma innehåll igen: ny referens, men bara ett lagrat innehåll
SVAR2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG")
kontroll "identiskt innehåll ger samma hash" "$(echo "$SVAR2" | falt .hash)" "$BIL_HASH"
ANTAL_INNEHALL=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-tAc "select count(*) from bilage_innehall where hash='$BIL_HASH'")
kontroll "innehållsadresserat — lagras en gång" "$ANTAL_INNEHALL" "1"
# Hämtning ger tillbaka exakt samma bytes
curl -s "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A" -o /tmp/hamtad.png
kontroll "hämtat innehåll är identiskt" "$(sha256sum /tmp/hamtad.png | cut -d' ' -f1)" "$BIL_HASH"
# Fel mediatyp och tom kropp avvisas
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/pdf' --data-binary "@$PNG")
kontroll "endast bilder och video tas emot" "$KOD" "415"
# Organisationsgränsen gäller bilagor
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_B")
kontroll "org B kommer inte åt org A:s bilaga" "$KOD" "404"
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/bilagor" \
-H "Authorization: Bearer $TOKEN_B" -H 'Content-Type: image/png' --data-binary "@$PNG")
kontroll "org B kan inte ladda upp till org A:s ärende" "$KOD" "404"
# Manipulerat innehåll upptäcks: hashen i loggen är facit
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update bilage_innehall set data = decode('4d414e4950554c45524154', 'hex') where hash='$BIL_HASH'"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/bilagor/$BIL_ID" -H "Authorization: Bearer $TOKEN_A")
kontroll "utbytt innehåll upptäcks och lämnas inte ut" "$KOD" "409"
PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "update bilage_innehall set data = pg_read_binary_file('$PNG') where hash='$BIL_HASH'" 2>/dev/null \
|| PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
-qc "delete from bilage_innehall where hash='$BIL_HASH'"
# Delningsfiltret gäller även bilagor: en bilaga som hör till en intern
# händelsetyp får inte hämtas via kundlänken.
BIL2=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
BIL3=$(curl -s -X POST "$BAS/api/arenden/arende-test1/bilagor" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: image/png' --data-binary "@$PNG" | falt .id)
curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d "{\"handelser\":[
{\"id\":\"h-foto\",\"tidpunkt\":\"2026-08-03T08:07:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"foto\",\"beskrivning\":\"Hjul\",\"bilagaId\":\"$BIL2\"}},
{\"id\":\"h-ao\",\"tidpunkt\":\"2026-08-03T08:08:00Z\",\"anvandare\":\"Anna\",\"handelse\":{\"typ\":\"arbetsorder_skannad\",\"falt\":[],\"bilagaId\":\"$BIL3\"}}
]}" >/dev/null
NYKUND=$(curl -s -X POST "$BAS/api/arenden/arende-test1/delningar" -H "Authorization: Bearer $TOKEN_A" \
-H 'Content-Type: application/json' -d '{"niva":"kund"}' | falt .kod)
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL2")
kontroll "kunden når bilagan till ett foto" "$KOD" "200"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL3")
kontroll "kunden når INTE arbetsorderbilden" "$KOD" "404"
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/delad/$NYKUND/bilagor/$BIL_ID")
kontroll "bilaga utan händelse lämnas inte ut publikt" "$KOD" "404"
rm -f "$PNG" /tmp/hamtad.png
# 11. API-first: OpenAPI-specen serveras live, utan inloggning
SPEC=$(curl -s "$BAS/api/openapi.yaml")
case "$SPEC" in
"openapi: 3.0.3"*) echo "✓ OpenAPI-specen serveras på /api/openapi.yaml" ;;
*) echo "✗ OpenAPI-specen saknas"; exit 1 ;;
esac
echo "Integrationstest: allt grönt"
+1111
View File
@@ -0,0 +1,1111 @@
openapi: 3.0.3
info:
title: Guidad Felsökning plattforms-API
version: 1.0.0
description: |
API-first-ytan för Guidad Felsökning: autentisering, organisationens
användare, ärenden med append-only händelselogg, arbetsledaröversikt,
publik Live Share-delning samt AI-orkestern.
**Händelseloggen är append-only.** Det finns inga update- eller
delete-operationer — historik kan aldrig ändras eller raderas, vilket
även garanteras av databastriggers. En händelse med redan känt `id`
ignoreras tyst (idempotent synk).
**Multi-tenant.** All ärendedata är organisationsknuten. Ett ärende i
en annan organisation ger `404`, som om det inte fanns.
Specen serveras live av tjänsten på `GET /api/openapi.yaml`.
servers:
- url: https://app.exempel.se
description: Klustrets ingress (ersätt med er domän)
tags:
- name: Auth
- name: Användare
- name: Ärenden
- name: Översikt
- name: Delning
- name: AI
- name: Integrationer
- name: Drift
paths:
/halsa:
get:
tags: [Drift]
summary: Hälsokontroll
security: []
responses:
"200":
description: Tjänsten svarar.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: ok }
/api/openapi.yaml:
get:
tags: [Drift]
summary: Denna specifikation
security: []
responses:
"200":
description: OpenAPI-specen i YAML.
content:
application/yaml:
schema: { type: string }
/api/auth/registrera:
post:
tags: [Auth]
summary: Skapa organisation + systemadministratör
description: >
Registrerar en ny organisation (tenant) och dess första användare,
som blir systemadministratör. Kan stängas av driften
(REGISTRERING_OPPEN=false → 403).
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [epost, losenord, namn, organisation]
properties:
epost: { type: string, format: email }
losenord: { type: string, minLength: 8 }
namn: { type: string }
organisation: { type: string }
responses:
"200": { $ref: "#/components/responses/Inloggad" }
"400": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" }
/api/auth/logga-in:
post:
tags: [Auth]
summary: Logga in
description: >
Takt-begränsad i databasen, så spärren håller bakom flera
repliker: 10 misslyckade försök per konto och 30 per källadress
inom 15 minuter ger 429. Spärren gäller kontot även vid rätt
lösenord — annars vore den meningslös. Ett avstängt konto svarar
403.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [epost, losenord]
properties:
epost: { type: string, format: email }
losenord: { type: string }
responses:
"200": { $ref: "#/components/responses/Inloggad" }
"401": { $ref: "#/components/responses/Fel" }
"403":
description: Kontot är avstängt.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"429":
description: För många misslyckade försök.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/anvandare:
get:
tags: [Användare]
summary: Lista organisationens användare
description: Kräver rollen `admin` eller `arbetsledare`.
responses:
"200":
description: Användare i den egna organisationen.
content:
application/json:
schema:
type: object
properties:
anvandare:
type: array
items: { $ref: "#/components/schemas/Anvandare" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
post:
tags: [Användare]
summary: Skapa användare i den egna organisationen
description: Kräver rollen `admin`.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [epost, losenord, namn, roll]
properties:
epost: { type: string, format: email }
losenord: { type: string, minLength: 8 }
namn: { type: string }
roll: { $ref: "#/components/schemas/Roll" }
responses:
"200":
description: Den skapade användaren.
content:
application/json:
schema: { $ref: "#/components/schemas/Anvandare" }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" }
/api/ecm/regler:
get:
tags: [Drift]
summary: ECM Knowledge Library — aktuellt regelpaket
description: >
Serverdistribuerat, versionerat regelpaket (compliance-regler per
ärendetyp, orsakskategorier, evidenskällor, undantagsorsaker).
Uppdateras i driften utan appändring; klienten cachar och faller
tillbaka till sitt inbyggda standardpaket offline.
responses:
"200":
description: Regelpaketet.
content:
application/json:
schema:
type: object
properties:
version: { type: string }
arendetypRegler: { type: object, additionalProperties: true }
undantagsorsaker: { type: array, items: { type: string } }
orsakskategorier: { type: array, items: { type: string } }
underlagskallor: { type: array, items: { type: string } }
/api/fordon/{identifierare}/historik:
get:
tags: [Ärenden]
summary: Fordonshistorik — tidigare ärenden på samma objekt
description: >
Organisationens tidigare ärenden där objektet (regnr/VIN) matchar,
med dokumenterade felorsaker. Underlag för pre-diagnostikens
historiksteg och orsakskedjan.
parameters:
- name: identifierare
in: path
required: true
schema: { type: string }
responses:
"200":
description: Tidigare ärenden (senaste 20).
content:
application/json:
schema:
type: object
properties:
arenden:
type: array
items:
type: object
properties:
id: { type: string }
nummer: { type: integer }
skapad: { type: string, format: date-time }
avslutat: { type: boolean }
felbeskrivning: { type: string, nullable: true }
felorsaker: { type: array, items: { type: object, additionalProperties: true } }
"401": { $ref: "#/components/responses/Fel" }
/api/statistik/felorsaker:
get:
tags: [Översikt]
summary: Felorsaksstatistik per orsakskategori
description: >
Flottdata ur dokumenterade felorsaksanalyser i organisationen.
Kräver rollen `arbetsledare` eller `admin`.
responses:
"200":
description: Antal per orsakskategori, fallande.
content:
application/json:
schema:
type: object
properties:
orsaker:
type: array
items:
type: object
properties:
orsak: { type: string }
antal: { type: integer }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
/api/anvandare/{anvandarId}/avaktivera:
post:
tags: [Användare]
summary: Stäng av ett konto
description: >
Kräver rollen `admin` och att användaren tillhör samma
organisation. Avstängningen höjer kontots token-version, så
**pågående sessioner upphör omedelbart** — annars vore den
verkningslös tills utfärdade tokens gick ut. Ett konto kan inte
stänga av sig självt.
parameters:
- name: anvandarId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Kontot är avstängt.
content:
application/json:
schema:
type: object
properties:
id: { type: string, format: uuid }
namn: { type: string }
aktiv: { type: boolean }
"400":
description: Försök att stänga av sitt eget konto.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/anvandare/{anvandarId}/aktivera:
post:
tags: [Användare]
summary: Öppna ett avstängt konto
description: >
Kräver rollen `admin`. Kontot kan logga in igen, men tokens som
återkallades vid avstängningen förblir ogiltiga.
parameters:
- name: anvandarId
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200": { description: Kontot är öppnat. }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/auth/logga-ut-alla:
post:
tags: [Auth]
summary: Logga ut på alla enheter
description: >
Höjer den egna token-versionen, vilket gör samtliga utfärdade
tokens för kontot ogiltiga direkt — vägen ut när en enhet
tappats bort.
responses:
"200": { description: Alla sessioner är avslutade. }
"401": { $ref: "#/components/responses/Fel" }
/api/arenden/{arendeId}/bilagor:
post:
tags: [Ärenden]
summary: Ladda upp en bilaga
description: >
Foton, videoklipp och instrumentbilder. Kroppen är råa bytes och
`Content-Type` anger mediatypen — endast bild och video tas emot.
Servern beräknar innehållets SHA-256 och returnerar en referens
som ska läggas i händelsen; **hashen hamnar därmed i den
append-only-skyddade loggen**, så en utbytt bild går att upptäcka.
Innehållsadresserat: samma innehåll lagras en gång.
parameters:
- name: arendeId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
image/jpeg: { schema: { type: string, format: binary } }
image/png: { schema: { type: string, format: binary } }
image/webp: { schema: { type: string, format: binary } }
video/mp4: { schema: { type: string, format: binary } }
video/webm: { schema: { type: string, format: binary } }
responses:
"200":
description: Referensen att spara i händelsen.
content:
application/json:
schema:
type: object
properties:
id: { type: string }
hash: { type: string, description: "SHA-256 av innehållet, hex." }
mediatyp: { type: string }
storlek: { type: integer }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
"413":
description: Bilagan är större än 32 MB.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"415":
description: Endast bilder och videoklipp tas emot.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/bilagor/{bilagaId}:
get:
tags: [Ärenden]
summary: Hämta en bilaga
description: >
Organisationsknuten. Innehållet kontrolleras mot hashen innan det
lämnas ut — stämmer det inte svarar tjänsten 409 i stället för att
visa en bild som kan ha bytts ut.
parameters:
- name: bilagaId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Innehållet.
content:
application/octet-stream:
schema: { type: string, format: binary }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
"409":
description: Innehållet stämmer inte med hashen i loggen.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/delad/{delningskod}/bilagor/{bilagaId}:
get:
tags: [Delning]
summary: Hämta en bilaga via delningslänk
description: >
Samma filtrering som händelserna: bilagan lämnas bara ut om
händelsen den hör till är synlig på delningens nivå. En bild som
hör till en intern händelsetyp — t.ex. den skannade arbetsordern —
nås alltså aldrig via kundlänken.
security: []
parameters:
- name: delningskod
in: path
required: true
schema: { type: string }
- name: bilagaId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Innehållet.
content:
application/octet-stream:
schema: { type: string, format: binary }
"404": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" }
/api/integrationer/leverantorer:
get:
tags: [Integrationer]
summary: Registret över märkesspecifika kopplingar
description: >
Leverantörer är data, inte kod: registret läses ur
`integrationer.json` (eller filen i `INTEGRATIONER_FIL`) och kan
bytas via ConfigMap utan att applikationen byggs om. Innehåller
endast fältdefinitioner — aldrig någon organisations uppgifter.
responses:
"200":
description: Leverantörsdefinitioner.
content:
application/json:
schema:
type: object
properties:
version: { type: string }
leverantorer:
type: array
items:
type: object
properties:
id: { type: string }
namn: { type: string }
beskrivning: { type: string }
nyckeltyp:
type: string
enum: [vin, regnr]
description: Vad uppslaget sker på. Utelämnat betyder VIN.
falt:
type: array
items:
type: object
properties:
nyckel: { type: string }
etikett: { type: string }
hemlig:
type: boolean
description: >
Hemliga fält maskeras alltid i svar och
visas aldrig igen efter sparande.
"401": { $ref: "#/components/responses/Fel" }
/api/integrationer:
get:
tags: [Integrationer]
summary: Organisationens konfigurerade kopplingar
description: >
Kräver rollen `admin`. Uppgifterna lagras krypterade
(AES-256-GCM) och returneras alltid maskerade — hemliga värden
lämnar aldrig servern i klartext.
responses:
"200":
description: Konfigurerade kopplingar med maskerade uppgifter.
content:
application/json:
schema:
type: object
properties:
krypteringKonfigurerad:
type: boolean
description: Falskt om `INTEGRATION_NYCKEL` saknas — då kan inget sparas.
integrationer:
type: array
items:
type: object
properties:
leverantor: { type: string }
namn: { type: string }
aktiv: { type: boolean }
uppdaterad: { type: string, format: date-time }
senast_testad: { type: string, format: date-time, nullable: true }
senaste_status: { type: string, nullable: true }
uppgifter:
type: object
additionalProperties: { type: string }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
post:
tags: [Integrationer]
summary: Spara eller uppdatera en kopplings uppgifter
description: >
Kräver rollen `admin`. Endast leverantörens definierade fält
sparas och samtliga måste fyllas i. Uppgifterna krypteras innan
de skrivs. Ett sparande nollställer tidigare testresultat.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [leverantor, uppgifter]
properties:
leverantor: { type: string }
aktiv: { type: boolean, default: true }
uppgifter:
type: object
additionalProperties: { type: string }
responses:
"200": { description: Sparad. }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
"503":
description: Kryptering är inte konfigurerad (`INTEGRATION_NYCKEL` saknas).
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/integrationer/{leverantor}:
delete:
tags: [Integrationer]
summary: Ta bort en kopplings uppgifter
description: Kräver rollen `admin`.
parameters:
- name: leverantor
in: path
required: true
schema: { type: string }
responses:
"200": { description: Borttagen. }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
/api/integrationer/{leverantor}/uppslag:
post:
tags: [Integrationer]
summary: Slå upp ett fordon hos leverantören
description: >
Anropet görs alltid av servern — kundens leverantörsnycklar når
aldrig webbläsaren. Svaret mappas till våra fält enligt
registrets `svarsfalt`. Resultatet skrivs som kopplingens
senaste teststatus.
parameters:
- name: leverantor
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [identifierare]
properties:
identifierare:
type: string
description: VIN eller registreringsnummer beroende på leverantörens `nyckeltyp`.
responses:
"200":
description: Fordonsuppgifter från leverantören.
content:
application/json:
schema:
type: object
properties:
fordon:
type: object
additionalProperties: { type: string }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"404":
description: Kopplingen är inte konfigurerad för organisationen.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"502":
description: Leverantören svarade med fel eller inga kända fält.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
"503":
description: Kryptering är inte konfigurerad.
content:
application/json:
schema: { $ref: "#/components/schemas/Fel" }
/api/delad/{delningskod}/beslut:
post:
tags: [Delning]
summary: Kundens besked på ett åtgärdsförslag
description: >
Den enda skrivande publika vägen. Endast delningar på **kundnivå**
som inte återkallats får svara, det måste finnas ett
åtgärdsförslag, och **ett besked per ärende** — svaret kan inte
ändras i efterhand. Takt-begränsad per delningskod. Beskedet
loggas som `kundbeslut` med kanal `Delningslänk`.
security: []
parameters:
- name: delningskod
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [beslut]
properties:
beslut: { type: string, enum: [godkant, avbojt, delvis] }
kommentar: { type: string, maxLength: 500 }
responses:
"200":
description: Beskedet registrerat.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
"400": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
"409": { $ref: "#/components/responses/Fel" }
"429": { $ref: "#/components/responses/Fel" }
/api/organisation:
get:
tags: [Organisation]
summary: Hämta organisationens namn och inställningar
description: >
Inställningarna styr vad som visas när ett ärende startas
(objekttyper och identifieringsmetoder). Alla inloggade läser.
responses:
"200":
description: Organisationen.
content:
application/json:
schema:
type: object
properties:
namn: { type: string }
installningar: { $ref: "#/components/schemas/OrganisationsInstallningar" }
"401": { $ref: "#/components/responses/Fel" }
/api/organisation/installningar:
post:
tags: [Organisation]
summary: Uppdatera organisationens inställningar
description: Kräver rollen `admin`. Minst ett alternativ per lista.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/OrganisationsInstallningar" }
responses:
"200":
description: Sparat.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
/api/arenden:
get:
tags: [Ärenden]
summary: Lista organisationens ärenden
responses:
"200":
description: Ärenden (senaste 200).
content:
application/json:
schema:
type: object
properties:
arenden:
type: array
items: { $ref: "#/components/schemas/Arende" }
"401": { $ref: "#/components/responses/Fel" }
post:
tags: [Ärenden]
summary: Registrera ett ärende (idempotent)
description: >
Skapar ärendet i användarens organisation. Ett redan känt `id`
ignoreras tyst — ärenderaden ändras aldrig.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, nummer, skapad]
properties:
id: { type: string }
nummer: { type: integer }
skapad: { type: string, format: date-time }
delningskod: { type: string, nullable: true }
metodikId: { type: string, nullable: true }
responses:
"200":
description: Mottaget.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
/api/arenden/{arendeId}/handelser:
parameters:
- name: arendeId
in: path
required: true
schema: { type: string }
get:
tags: [Ärenden]
summary: Hämta ärendets händelselogg
responses:
"200":
description: Händelser i tidsordning.
content:
application/json:
schema:
type: object
properties:
handelser:
type: array
items: { $ref: "#/components/schemas/LoggPost" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
post:
tags: [Ärenden]
summary: Lägg till händelser (append-only, idempotent)
description: >
Lägger till händelser i loggen. Max 500 per anrop. En händelse med
redan känt `id` ignoreras tyst — befintliga händelser skrivs aldrig
över, och databastriggern stoppar alla ändringsförsök.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [handelser]
properties:
handelser:
type: array
maxItems: 500
items: { $ref: "#/components/schemas/LoggPost" }
responses:
"200":
description: Mottaget.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/oversikt:
get:
tags: [Översikt]
summary: Organisationsöversikt med status och statistikunderlag
description: >
Kräver rollen `arbetsledare` eller `admin`. Status, deltagande
tekniker och sammanfattning härleds ur händelseloggen.
responses:
"200":
description: Alla ärenden i organisationen med härledd status.
content:
application/json:
schema:
type: object
properties:
arenden:
type: array
items: { $ref: "#/components/schemas/OversiktsRad" }
"401": { $ref: "#/components/responses/Fel" }
"403": { $ref: "#/components/responses/Fel" }
/api/arenden/{arendeId}/delningar:
parameters:
- name: arendeId
in: path
required: true
schema: { type: string }
get:
tags: [Delning]
summary: Lista ärendets delningslänkar
responses:
"200":
description: Delningar (inklusive återkallade).
content:
application/json:
schema:
type: object
properties:
delningar:
type: array
items: { $ref: "#/components/schemas/Delning" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
post:
tags: [Delning]
summary: Skapa delningslänk med behörighetsnivå
description: >
Verkstaden kontrollerar alltid delningen: kund (det kunddelbara),
partner (även hypoteser, tydligt märkta ej verifierade) eller
intern (full insyn). Nivåfiltreringen sker på serversidan.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [niva]
properties:
niva: { $ref: "#/components/schemas/DelningsNiva" }
responses:
"200":
description: Den skapade länkens kod.
content:
application/json:
schema:
type: object
properties:
kod: { type: string }
niva: { $ref: "#/components/schemas/DelningsNiva" }
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/delningar/{kod}/aterkalla:
post:
tags: [Delning]
summary: Återkalla en delningslänk
description: Efter återkallelse ger länken 404. Kan inte ångras.
parameters:
- name: kod
in: path
required: true
schema: { type: string }
responses:
"200":
description: Återkallad.
content:
application/json:
schema:
type: object
properties:
ok: { type: boolean }
"401": { $ref: "#/components/responses/Fel" }
"404": { $ref: "#/components/responses/Fel" }
/api/delad/{delningskod}:
get:
tags: [Delning]
summary: Publik Live Share-vy via delningskod
description: >
Kräver ingen inloggning — delningskoden är nyckeln. Filtreringen
styrs av länkens behörighetsnivå: kund utesluter kategoribyten,
hypoteser och AI-dialog; partner utesluter kategoribyten och
AI-dialog; intern visar allt. Ärendets ursprungliga delningskod
fungerar bakåtkompatibelt som kundnivå.
security: []
parameters:
- name: delningskod
in: path
required: true
schema: { type: string, pattern: "^[A-Za-z0-9_-]+$" }
responses:
"200":
description: Ärendet med nivåfiltrerad händelselogg.
content:
application/json:
schema:
type: object
properties:
arende:
type: object
properties:
id: { type: string }
nummer: { type: integer }
skapad: { type: string, format: date-time }
handelser:
type: array
items: { $ref: "#/components/schemas/LoggPost" }
niva: { $ref: "#/components/schemas/DelningsNiva" }
"404": { $ref: "#/components/responses/Fel" }
/api/ai:
post:
tags: [AI]
summary: AI-orkestern (separat tjänst bakom samma ingress)
description: >
Routas av ingressen till ai-orkester-tjänsten. Servern äger
modellval, effort, systemprompt och svarsschema per uppgift:
handledning (Claude Sonnet 5), granskning (Claude Opus 5),
sammanfattning (Claude Sonnet 5), metodikval (Claude Haiku 4.5).
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [uppgift, prompt]
properties:
uppgift:
type: string
enum: [handledning, granskning, sammanfattning, metodikval]
prompt: { type: string, maxLength: 40000 }
responses:
"200":
description: Klassificerat AI-svar med modellen som svarade.
content:
application/json:
schema:
type: object
properties:
modell: { type: string, example: claude-sonnet-5 }
svar:
oneOf:
- $ref: "#/components/schemas/AiSvar"
- type: object
properties:
metodikId:
type: string
enum: [vibration, elsystem, generisk]
"400": { $ref: "#/components/responses/Fel" }
"401": { $ref: "#/components/responses/Fel" }
"502": { $ref: "#/components/responses/Fel" }
"503": { $ref: "#/components/responses/Fel" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
HS256-JWT från /api/auth/logga-in eller /api/auth/registrera.
Anspråk: sub (användar-id), namn, org (organisations-id),
roll, iat, exp (12 timmar).
responses:
Inloggad:
description: Inloggad — token + kontouppgifter.
content:
application/json:
schema:
type: object
properties:
token: { type: string }
namn: { type: string }
roll: { $ref: "#/components/schemas/Roll" }
organisation: { type: string }
Fel:
description: Felsvar.
content:
application/json:
schema:
type: object
properties:
error: { type: string }
schemas:
Fel:
type: object
properties:
error: { type: string }
Roll:
type: string
enum: [tekniker, arbetsledare, admin]
OrganisationsInstallningar:
type: object
required: [objekttyper, identifieringsmetoder]
properties:
objekttyper:
type: array
minItems: 1
items: { type: string }
identifieringsmetoder:
type: array
minItems: 1
items: { type: string }
DelningsNiva:
type: string
enum: [kund, partner, intern]
Delning:
type: object
properties:
kod: { type: string }
niva: { $ref: "#/components/schemas/DelningsNiva" }
skapad: { type: string, format: date-time }
aterkallad: { type: string, format: date-time, nullable: true }
Anvandare:
type: object
properties:
id: { type: string, format: uuid }
epost: { type: string, format: email }
namn: { type: string }
roll: { $ref: "#/components/schemas/Roll" }
Arende:
type: object
properties:
id: { type: string }
nummer: { type: integer }
skapad: { type: string, format: date-time }
delningskod: { type: string, nullable: true }
metodik_id: { type: string, nullable: true }
LoggPost:
type: object
required: [id, tidpunkt, anvandare, handelse]
properties:
id: { type: string }
tidpunkt: { type: string, format: date-time }
anvandare: { type: string }
handelse: { $ref: "#/components/schemas/Handelse" }
Handelse:
type: object
description: >
Diskriminerad på `typ`; fullständiga fältdefinitioner i klientens
domänmodell (src/felsokning/domain.ts).
required: [typ]
additionalProperties: true
properties:
typ:
type: string
enum:
- objekt_identifierat
- felbeskrivning
- fraga_besvarad
- kontroll_utford
- observation
- matvarde
- hypotes
- foto
- kommentar
- kategori_byte
- inaktivitet_forklarad
- overlamning
- ansvarig_satt
- arbetsorder_skannad
- arendetyp_satt
- historik_kontrollerad
- matarstallning
- video
- atgardsforslag
- kundbeslut
- atgard_utford
- kvalitetskontroll
- reproducering
- felorsak
- export_skapad
- ai_svar
- arende_avslutat
OversiktsRad:
type: object
properties:
id: { type: string }
nummer: { type: integer }
skapad: { type: string, format: date-time }
delningskod: { type: string, nullable: true }
metodik_id: { type: string, nullable: true }
antal_handelser: { type: integer }
forsta: { type: string, format: date-time, nullable: true }
senaste: { type: string, format: date-time, nullable: true }
avslutat: { type: boolean }
objekt: { type: string, nullable: true }
felbeskrivning: { type: string, nullable: true }
tekniker:
type: array
items: { type: string }
nullable: true
AiSvar:
type: object
required: [rader, nastaSteg]
properties:
rader:
type: array
items:
type: object
required: [typ, text]
properties:
typ:
type: string
enum: [observation, verifierat, hypotes, rekommendation]
text: { type: string }
nastaSteg: { type: string }
security:
- bearerAuth: []
+161
View File
@@ -0,0 +1,161 @@
{
"name": "plattform",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plattform",
"version": "1.0.0",
"dependencies": {
"pg": "^8.13.0"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
@@ -0,0 +1,13 @@
{
"name": "plattform",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"pg": "^8.13.0"
}
}
+1174
View File
@@ -0,0 +1,1174 @@
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
//
// Multi-tenant: varje organisation är en egen tenant. Registrering skapar
// en organisation med en systemadministratör; admin skapar övriga
// användare (tekniker/arbetsledare) i sin organisation. All ärendedata
// är organisationsknuten — API:t släpper aldrig data över gränsen.
//
// Händelseloggen är append-only även i databasen (triggers) — den här
// tjänsten exponerar medvetet inga update/delete-operationer.
//
// Miljövariabler:
// DATABASE_URL Postgres-anslutning (krävs)
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs)
// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta)
// INTEGRATION_NYCKEL 32 byte (hex/base64) — krypterar kundernas leverantörsuppgifter
// TILLATNA_URSPRUNG kommaseparerade ursprung för CORS (utelämnad = "*")
// TILLAT_INTERNA_UPPSLAG "true" tillåter leverantörsuppslag mot privata nät
// ECM_REGLER_FIL / INTEGRATIONER_FIL sökvägar till utbytbar konfiguration
// PORT default 8080
import { createServer } from "node:http";
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { lookup } from "node:dns/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import pg from "pg";
import { innehallsHash, mediatypGiltig, valjLager } from "./bilagor.mjs";
// API-first: OpenAPI-specen är en versionerad artefakt och serveras live.
const OPENAPI = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "openapi.yaml"), "utf8");
// ECM Knowledge Library: regelpaketet är serverägd konfiguration — inte
// 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 = readFileSync(
process.env.ECM_REGLER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "ecm-regler.json"),
"utf8",
);
// Register över märkesspecifika kopplingar — data, inte kod. Nya
// leverantörer läggs till i filen (eller via ConfigMap-mount) utan att
// applikationen byggs om.
const INTEGRATIONER = JSON.parse(
readFileSync(
process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"),
"utf8",
),
);
const PORT = Number(process.env.PORT ?? 8080);
const MAX_KROPP = 4 * 1024 * 1024;
// Bilagor får vara större än en händelse — ett videoklipp med ljud är
// evidens som inte går att skala ned hur långt som helst.
const MAX_BILAGA = 32 * 1024 * 1024;
const TOKEN_LIVSTID_S = 12 * 60 * 60;
const ROLLER = ["tekniker", "arbetsledare", "admin"];
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
// Var bilagornas innehåll hamnar. Felkonfigurerat s3-läge failar här,
// vid start, i stället för vid första uppladdningen.
const BILAGELAGER = valjLager(process.env, pool);
// Vilka ursprung som får anropa API:t från en webbläsare. I klusterdriften
// serveras klienten från samma domän som API:t, så listan kan hållas kort.
// TILLATNA_URSPRUNG="https://app.exempel.se,https://demo.exempel.se" —
// utelämnad betyder "*" (öppet), vilket bara hör hemma i utveckling.
const TILLATNA_URSPRUNG = (process.env.TILLATNA_URSPRUNG ?? "")
.split(",")
.map((u) => u.trim())
.filter(Boolean);
function ursprungFor(req) {
const ursprung = req.headers.origin;
if (TILLATNA_URSPRUNG.length === 0) return "*";
return ursprung && TILLATNA_URSPRUNG.includes(ursprung) ? ursprung : TILLATNA_URSPRUNG[0];
}
// Enkel takt-begränsning för den publika beslutsendpointen (per
// delningskod, i minnet). Räcker för en enda pod; bakom flera repliker
// kompletteras den av databasspärren "ett beslut per förslag".
const BESLUT_TAK = 5;
const BESLUT_FONSTER_MS = 60_000;
const beslutsForsok = new Map();
function forTataForsok(kod) {
const nu = Date.now();
const forsok = (beslutsForsok.get(kod) ?? []).filter((t) => nu - t < BESLUT_FONSTER_MS);
forsok.push(nu);
beslutsForsok.set(kod, forsok);
// Enkel städning så kartan inte växer obegränsat.
if (beslutsForsok.size > 5000) beslutsForsok.clear();
return forsok.length > BESLUT_TAK;
}
// ---- Takt-begränsning på inloggning -----------------------------------
//
// Den i minnet (beslutsvägen ovan) räcker för en pod. Inloggningen skalar
// till flera repliker och behöver därför en gemensam räknare — den ligger
// i databasen. Två spärrar: per konto (skyddar en enskild användare) och
// per källa (stoppar den som betar av många konton från samma håll).
const INLOGG_FONSTER = "15 minutes";
const INLOGG_TAK_KONTO = 10;
const INLOGG_TAK_KALLA = 30;
function kallaFor(req) {
// Bakom ingressen står klientens adress först i X-Forwarded-For.
const vidarebefordrad = req.headers["x-forwarded-for"];
const forsta = typeof vidarebefordrad === "string" ? vidarebefordrad.split(",")[0].trim() : "";
return (forsta || req.socket?.remoteAddress || "").slice(0, 64);
}
async function inloggningSparrad(epost, kalla) {
const rad = await pool.query(
`select
count(*) filter (where epost = $1) as konto,
count(*) filter (where kalla = $2 and $2 <> '') as kalla
from inloggningsforsok
where lyckades = false and tidpunkt > now() - interval '${INLOGG_FONSTER}'`,
[epost, kalla],
);
const { konto, kalla: franKalla } = rad.rows[0];
return Number(konto) >= INLOGG_TAK_KONTO || Number(franKalla) >= INLOGG_TAK_KALLA;
}
async function loggaForsok(epost, kalla, lyckades) {
await pool.query(
`insert into inloggningsforsok (epost, kalla, lyckades) values ($1, $2, $3)`,
[epost, kalla, lyckades],
);
// Städa bort det som inte längre kan påverka någon spärr. Billigt nog
// att göra i skrivvägen och slipper ett schemalagt jobb.
if (Math.random() < 0.02) {
await pool.query(`delete from inloggningsforsok where tidpunkt < now() - interval '1 day'`);
}
}
// ---- JWT (HS256, utan beroenden) --------------------------------------
const b64url = (data) => Buffer.from(data).toString("base64url");
export function skapaJwt(anspr, hemlighet) {
const huvud = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const kropp = b64url(JSON.stringify(anspr));
const signatur = createHmac("sha256", hemlighet).update(`${huvud}.${kropp}`).digest("base64url");
return `${huvud}.${kropp}.${signatur}`;
}
export function verifieraJwt(token, hemlighet) {
const delar = token.split(".");
if (delar.length !== 3) return null;
const forvantad = createHmac("sha256", hemlighet).update(`${delar[0]}.${delar[1]}`).digest("base64url");
const a = Buffer.from(delar[2]);
const b = Buffer.from(forvantad);
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;
return anspr;
} catch {
return null;
}
}
// ---- Integrationsuppgifter: kryptering i vila ------------------------
//
// Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM.
// Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och
// finns bara i driftens hemlighetshantering. Saknas nyckeln kan
// integrationer varken sparas eller användas — fail closed.
function integrationsNyckel() {
const ra = process.env.INTEGRATION_NYCKEL;
if (!ra) return null;
const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64");
return buf.length === 32 ? buf : null;
}
export function kryptera(klartext, nyckel) {
const iv = randomBytes(12);
const chiffer = createCipheriv("aes-256-gcm", nyckel, iv);
const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]);
return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`;
}
export function dekryptera(paket, nyckel) {
const [iv, tagg, data] = paket.split(".");
const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64"));
dechiffer.setAuthTag(Buffer.from(tagg, "base64"));
return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8");
}
// Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att
// ett värde finns och dess sista tecken.
export function maskera(varde) {
if (typeof varde !== "string" || varde.length === 0) return "";
if (varde.length <= 4) return "••••";
return `••••${varde.slice(-4)}`;
}
function leverantorsDef(id) {
return INTEGRATIONER.leverantorer.find((l) => l.id === id);
}
// ---- Delningsfilter: tillåtelselista, inte nekalista ------------------
//
// Vilka händelsetyper som får lämna verkstaden är en integritetsgräns.
// Med en nekalista blir varje NY händelsetyp automatiskt synlig för
// kunden tills någon kommer ihåg att neka den — fel håll att fela åt.
// Här listas i stället uttryckligen vad som får delas; allt annat är
// internt tills det aktivt släpps fram. Testet i delning.test.ts kräver
// att varje händelsetyp i domänmodellen är klassificerad.
export const DELBART_KUND = [
"objekt_identifierat",
"arendetyp_satt",
"felbeskrivning",
"fraga_besvarad",
"kontroll_utford",
"observation",
"matvarde",
"foto",
"video",
"kommentar",
"inaktivitet_forklarad",
"overlamning",
"historik_kontrollerad",
"matarstallning",
"reproducering",
"felorsak",
"atgardsforslag",
"kundbeslut",
"atgard_utford",
"kvalitetskontroll",
"export_skapad",
"arende_avslutat",
];
// Extern partner (försäkringsbolag, tillverkare) ser dessutom hypoteser
// — alltid märkta som ej verifierade.
export const DELBART_PARTNER = [...DELBART_KUND, "hypotes"];
// Aldrig utanför organisationen: arbetsledning, arbetsmaterial och
// underlag som kan läsas som konstateranden.
export const ENDAST_INTERNT = ["kategori_byte", "hypotes", "ai_svar", "ansvarig_satt", "arbetsorder_skannad"];
export function synligaTyper(niva) {
if (niva === "intern") return null; // full insyn — ingen filtrering
return niva === "partner" ? DELBART_PARTNER : DELBART_KUND;
}
// ---- Hjälpare ---------------------------------------------------------
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",
"Access-Control-Allow-Headers": "authorization, content-type",
});
res.end(JSON.stringify(kropp));
}
async function lasKropp(req) {
const bitar = [];
let storlek = 0;
for await (const bit of req) {
storlek += bit.length;
if (storlek > MAX_KROPP) throw new Error("för stor kropp");
bitar.push(bit);
}
return JSON.parse(Buffer.concat(bitar).toString("utf8"));
}
async function lasBinart(req, tak) {
const bitar = [];
let storlek = 0;
for await (const bit of req) {
storlek += bit.length;
if (storlek > tak) throw new Error("för stor kropp");
bitar.push(bit);
}
return Buffer.concat(bitar);
}
function nyKod() {
const tecken = "abcdefghijklmnopqrstuvwxyz0123456789";
const { randomBytes } = crypto;
return Array.from(randomBytes(16), (b) => tecken[b % 36]).join("");
}
function kravAuth(req, hemlighet) {
const auth = req.headers.authorization ?? "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
return token ? verifieraJwt(token, hemlighet) : null;
}
// En giltig signatur räcker inte. Kontot måste fortfarande vara aktivt,
// och token-versionen måste stämma med kontots — annars har den
// återkallats. Ett uppslag på primärnyckeln per anrop, vilket gör
// återkallelsen omedelbar i stället för att gälla vid nästa utgång.
async function kontoGiltigt(anspr) {
const rad = await pool.query(
`select aktiv, token_version from anvandare where id = $1 and organisation_id = $2`,
[anspr.sub, anspr.org],
);
if (rad.rowCount === 0) return false;
if (rad.rows[0].aktiv === false) return false;
return (anspr.tv ?? 0) === rad.rows[0].token_version;
}
// Verifierar att ärendet tillhör användarens organisation.
async function arendeIOrg(arendeId, organisationId) {
const rader = await pool.query(
`select 1 from felsokning_arenden where id = $1 and organisation_id = $2`,
[arendeId, organisationId],
);
return rader.rowCount > 0;
}
// Adresser som aldrig får nås utifrån ett kundkonfigurerat uppslag:
// loopback, privata nät, link-local (inkl. molnens metadatatjänst),
// CGNAT och IPv6-motsvarigheterna.
export function arPrivatAdress(adress) {
const v4 = adress.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = v4.slice(1).map(Number);
return (
a === 0 || a === 10 || a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
);
}
const v6 = adress.toLowerCase().replace(/^\[|\]$/g, "");
if (v6 === "::1" || v6 === "::") return true;
// Unika lokala adresser (fc00::/7), link-local (fe80::/10) och
// IPv4-mappade adresser som ::ffff:127.0.0.1.
if (/^f[cd]/.test(v6) || /^fe[89ab]/.test(v6)) return true;
const mappad = v6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
return mappad ? arPrivatAdress(mappad[1]) : false;
}
// Slår upp värdnamnet och avgör om något av svaren pekar inåt. Namn som
// resolvar till interna adresser fångas också — inte bara IP-literaler.
export async function pekarInat(url, slaUpp = lookup) {
let vard;
try {
vard = new URL(url).hostname;
} catch {
return "ogiltig URL";
}
const bar = vard.replace(/^\[|\]$/g, "");
if (/^[\d.]+$/.test(bar) || bar.includes(":")) {
return arPrivatAdress(bar) ? bar : null;
}
if (bar === "localhost" || bar.endsWith(".localhost") || bar.endsWith(".internal") || bar.endsWith(".local")) {
return bar;
}
try {
const traffar = await slaUpp(bar, { all: true });
const intern = traffar.find((t) => arPrivatAdress(t.address));
return intern ? intern.address : null;
} catch {
// Namnet går inte att slå upp — låt anropet självt misslyckas i
// stället för att påstå något om var det pekar.
return null;
}
}
// Generiskt uppslag mot en leverantör. All variation ligger i registret
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
// specifika kodgrenar.
export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) {
const u = def.uppslag ?? {};
const mall = uppgifter[u.urlFalt ?? "bas_url"];
if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) {
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
}
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
// Bas-URL:en sätts av kundens administratör men anropet görs av vår
// server. Utan spärr blir det en väg in i klustrets interna nät och
// molnets metadatatjänst (169.254.169.254) — tenantens administratör
// är inte infrastrukturens ägare. Interna mål tillåts bara när driften
// uttryckligen öppnat för det (verkstäder med OEM-server på egna nätet).
if (process.env.TILLAT_INTERNA_UPPSLAG !== "true") {
const internt = await pekarInat(url);
if (internt) return { ok: false, fel: `Bas-URL:en pekar på en intern adress (${internt}) och tillåts inte.` };
}
const headers = { Accept: "application/json" };
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt];
if (u.auth === "basic") {
const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`;
headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`;
}
if (u.auth === "query") {
url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`;
}
try {
const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) });
if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` };
const data = await svarFran.json();
const fordon = {};
for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) {
const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data);
if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim();
}
if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." };
return { ok: true, fordon };
} catch (fel) {
return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." };
}
}
// ---- Server -----------------------------------------------------------
// Lämnar ut innehållet — men bara efter att det kontrollerats mot
// hashen i loggen. Stämmer det inte säger vi det rakt ut i stället för
// att visa en bild som kan ha bytts ut.
async function skickaBilaga(res, rad) {
const data = await BILAGELAGER.hamta(rad.hash);
if (!data) return svara(res, 404, { error: "Innehållet saknas i lagringen." });
if (innehallsHash(data) !== rad.hash) {
console.error("bilaga: innehållet stämmer inte med hashen i loggen", rad.hash);
return svara(res, 409, { error: "Innehållet stämmer inte med det som dokumenterades." });
}
res.writeHead(200, {
"Content-Type": rad.mediatyp,
"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",
});
return res.end(data);
}
export function skapaServer() {
function loggaIn(res, rad, hemlighet) {
const nu = Math.floor(Date.now() / 1000);
const token = skapaJwt(
{
sub: rad.id,
namn: rad.namn,
org: rad.organisation_id,
roll: rad.roll,
// Bärs med så att en återkallelse gör token ogiltig direkt.
tv: rad.token_version ?? 0,
iat: nu,
exp: nu + TOKEN_LIVSTID_S,
},
hemlighet,
);
return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn });
}
return createServer(async (req, res) => {
res.ursprung = ursprungFor(req);
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": res.ursprung,
Vary: "Origin",
"Access-Control-Allow-Headers": "authorization, content-type",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
});
return res.end();
}
const url = new URL(req.url ?? "/", "http://intern");
const vag = url.pathname;
try {
if (req.method === "GET" && vag === "/halsa") {
return svara(res, 200, { status: "ok" });
}
if (req.method === "GET" && vag === "/api/openapi.yaml") {
res.writeHead(200, {
"Content-Type": "application/yaml; charset=utf-8",
"Access-Control-Allow-Origin": res.ursprung,
});
return res.end(OPENAPI);
}
const hemlighet = process.env.JWT_SECRET;
if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." });
// -- Registrering: skapar organisation + systemadministratör --
if (req.method === "POST" && vag === "/api/auth/registrera") {
if (process.env.REGISTRERING_OPPEN === "false") {
return svara(res, 403, { error: "Registrering är stängd — kontakta er administratör." });
}
const { epost, losenord, namn, organisation } = await lasKropp(req);
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) {
return svara(res, 400, {
error: "Ange organisation, namn, e-post och lösenord (minst 8 tecken).",
});
}
const klientDb = await pool.connect();
try {
await klientDb.query("begin");
const org = await klientDb.query(
`insert into organisationer (namn) values ($1) returning id, namn`,
[organisation.trim()],
);
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')
on conflict (epost) do nothing
returning id, namn, organisation_id, roll, token_version`,
[org.rows[0].id, epost.trim(), losenord, namn.trim()],
);
if (rad.rowCount === 0) {
await klientDb.query("rollback");
return svara(res, 409, { error: "E-postadressen är redan registrerad." });
}
await klientDb.query("commit");
return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet);
} catch (fel) {
await klientDb.query("rollback");
throw fel;
} finally {
klientDb.release();
}
}
if (req.method === "POST" && vag === "/api/auth/logga-in") {
const { epost, losenord } = await lasKropp(req);
const normaliserad = (epost ?? "").trim().toLowerCase();
const kalla = kallaFor(req);
if (await inloggningSparrad(normaliserad, kalla)) {
return svara(res, 429, { error: "För många misslyckade försök — vänta en stund och försök igen." });
}
const rader = await pool.query(
`select a.id, a.namn, a.organisation_id, a.roll, a.aktiv, a.token_version, o.namn as org_namn
from anvandare a join organisationer o on o.id = a.organisation_id
where a.epost = $1 and a.losen_hash = crypt($2, a.losen_hash)`,
[normaliserad, losenord ?? ""],
);
if (rader.rowCount === 0) {
await loggaForsok(normaliserad, kalla, false);
return svara(res, 401, { error: "Fel e-post eller lösenord." });
}
// Ett avaktiverat konto räknas som misslyckat försök: annars blir
// svarstiden ett sätt att lista ut vilka konton som finns.
if (rader.rows[0].aktiv === false) {
await loggaForsok(normaliserad, kalla, false);
return svara(res, 403, { error: "Kontot är avstängt — kontakta er administratör." });
}
await loggaForsok(normaliserad, kalla, true);
return loggaIn(res, rader.rows[0], hemlighet);
}
// -- Publik delning (Live Share via delningskod) --
// Behörighetsnivån styr filtreringen på serversidan:
// kund det kunddelbara (inga kategoribyten, hypoteser, AI-dialog)
// partner försäkringsbolag/tillverkare: även hypoteser (tydligt märkta)
// intern full insyn
const delad = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)$/);
if (req.method === "GET" && delad) {
let arendeId = null;
let niva = "kund";
const delning = await pool.query(
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
[delad[1]],
);
if (delning.rowCount > 0) {
arendeId = delning.rows[0].arende_id;
niva = delning.rows[0].niva;
} else {
// Bakåtkompatibelt: ärendets ursprungliga delningskod = kundnivå.
const viaArende = await pool.query(
`select id from felsokning_arenden where delningskod = $1`,
[delad[1]],
);
if (viaArende.rowCount > 0) arendeId = viaArende.rows[0].id;
}
if (!arendeId) return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
const arende = await pool.query(
`select id, nummer, skapad from felsokning_arenden where id = $1`,
[arendeId],
);
const synliga = synligaTyper(niva);
const handelser = synliga
? await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = any($2)
order by tidpunkt, id`,
[arendeId, synliga],
)
: await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[arendeId],
);
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows, niva });
}
// Bilaga via delningslänk. Bilden får bara hämtas om den hör till
// en händelse som nivån faktiskt får se — annars vore det en väg
// runt delningsfiltret.
const delatBilaga = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/bilagor\/([A-Za-z0-9_-]+)$/);
if (req.method === "GET" && delatBilaga) {
const delning = await pool.query(
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
[delatBilaga[1]],
);
if (delning.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
const synliga = synligaTyper(delning.rows[0].niva);
const rad = await pool.query(
`select b.hash, b.mediatyp from bilagor b
where b.id = $1 and b.arende_id = $2
and exists (
select 1 from felsokning_handelser h
where h.arende_id = b.arende_id
and h.handelse->>'bilagaId' = b.id
and ($3::text[] is null or h.handelse->>'typ' = any($3))
)`,
[delatBilaga[2], delning.rows[0].arende_id, synliga],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan är inte tillgänglig." });
return skickaBilaga(res, rad.rows[0]);
}
// -- Publikt kundgodkännande (den enda skrivande publika vägen) --
//
// Kunden svarar på ett åtgärdsförslag via sin delningslänk. Spärrar:
// 1. endast delningar på kundnivå (partner/intern får inte svara
// åt kunden), och aldrig återkallade
// 2. det måste finnas ett åtgärdsförslag att svara på
// 3. ett beslut per ärende — svaret kan inte ändras i efterhand
// 4. takt-begränsning per kod
// 5. beslutet får bara vara godkant/avbojt/delvis + kort kommentar;
// inget annat kan skrivas till loggen den här vägen
const beslutVag = vag.match(/^\/api\/delad\/([A-Za-z0-9_-]+)\/beslut$/);
if (req.method === "POST" && beslutVag) {
const kod = beslutVag[1];
if (forTataForsok(kod)) return svara(res, 429, { error: "För många försök — vänta en stund." });
const delning = await pool.query(
`select arende_id, niva from delningar where kod = $1 and aterkallad is null`,
[kod],
);
if (delning.rowCount === 0 || delning.rows[0].niva !== "kund") {
return svara(res, 404, { error: "Delningen är inte tillgänglig." });
}
const arendeId = delning.rows[0].arende_id;
const { beslut, kommentar } = await lasKropp(req);
if (!["godkant", "avbojt", "delvis"].includes(beslut)) {
return svara(res, 400, { error: "Ogiltigt beslut." });
}
if (kommentar !== undefined && (typeof kommentar !== "string" || kommentar.length > 500)) {
return svara(res, 400, { error: "Kommentaren är för lång." });
}
const forslag = await pool.query(
`select 1 from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = 'atgardsforslag' limit 1`,
[arendeId],
);
if (forslag.rowCount === 0) {
return svara(res, 409, { error: "Det finns inget åtgärdsförslag att svara på." });
}
const tidigare = await pool.query(
`select 1 from felsokning_handelser
where arende_id = $1 and handelse->>'typ' = 'kundbeslut' limit 1`,
[arendeId],
);
if (tidigare.rowCount > 0) {
return svara(res, 409, { error: "Ett besked är redan registrerat — kontakta verkstaden." });
}
const handelse = {
typ: "kundbeslut",
beslut,
kanal: "Delningslänk",
...(kommentar?.trim() ? { kommentar: kommentar.trim() } : {}),
};
await pool.query(
`insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse)
values ($1, $2, now(), $3, $4)`,
[`kb-${nyKod()}`, arendeId, "Kund via delningslänk", handelse],
);
return svara(res, 200, { ok: true });
}
// -- Skyddade endpoints (organisationsknutna) --
const anspr = kravAuth(req, hemlighet);
if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." });
if (!(await kontoGiltigt(anspr))) {
return svara(res, 401, { error: "Sessionen gäller inte längre — logga in på nytt." });
}
// Användarhantering: endast systemadministratör, endast egen org.
if (vag === "/api/anvandare") {
// Läsning: admin + arbetsledare (behövs för omfördelning).
// Skapande: endast admin.
if (anspr.roll === "tekniker") return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
if (req.method === "GET") {
const rader = await pool.query(
`select id, epost, namn, roll, aktiv from anvandare where organisation_id = $1 order by namn`,
[anspr.org],
);
return svara(res, 200, { anvandare: rader.rows });
}
if (req.method === "POST") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const { epost, losenord, namn, roll } = await lasKropp(req);
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) {
return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." });
}
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)
on conflict (epost) do nothing
returning id, epost, namn, roll, aktiv`,
[anspr.org, epost.trim(), losenord, namn.trim(), roll],
);
if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." });
return svara(res, 200, rad.rows[0]);
}
}
// Stäng av eller öppna ett konto. Att stänga av höjer också
// token-versionen, så pågående sessioner upphör direkt — annars
// vore avstängningen verkningslös i upp till tolv timmar.
const kontoVag = vag.match(/^\/api\/anvandare\/([0-9a-fA-F-]{36})\/(avaktivera|aktivera)$/);
if (req.method === "POST" && kontoVag) {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const [, id, atgard] = kontoVag;
if (id === anspr.sub) {
return svara(res, 400, { error: "Du kan inte stänga av ditt eget konto." });
}
const aktivera = atgard === "aktivera";
const rad = await pool.query(
`update anvandare
set aktiv = $3,
token_version = token_version + case when $3 then 0 else 1 end
where id = $1 and organisation_id = $2
returning id, namn, aktiv`,
[id, anspr.org, aktivera],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Användaren finns inte." });
return svara(res, 200, rad.rows[0]);
}
// Logga ut på alla enheter — den egna vägen ut när en telefon
// tappats bort. Höjer den egna token-versionen.
if (req.method === "POST" && vag === "/api/auth/logga-ut-alla") {
await pool.query(
`update anvandare set token_version = token_version + 1 where id = $1 and organisation_id = $2`,
[anspr.sub, anspr.org],
);
return svara(res, 200, { ok: true });
}
// -- Bilagor --
// Innehållet ligger utanför händelsen; loggen bär referensen och
// innehållets hash. Uppladdningen sker före händelsen skrivs, så
// en händelse aldrig pekar på något som inte finns.
const laddaUppVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/bilagor$/);
if (req.method === "POST" && laddaUppVag) {
if (!(await arendeIOrg(laddaUppVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
const mediatyp = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (!mediatypGiltig(mediatyp)) {
return svara(res, 415, { error: "Endast bilder och videoklipp kan laddas upp." });
}
let data;
try {
data = await lasBinart(req, MAX_BILAGA);
} catch {
return svara(res, 413, { error: `Bilagan är för stor (max ${MAX_BILAGA / 1024 / 1024} MB).` });
}
if (data.length === 0) return svara(res, 400, { error: "Bilagan är tom." });
const hash = innehallsHash(data);
await BILAGELAGER.spara(hash, data);
const id = `bil-${nyKod()}`;
await pool.query(
`insert into bilagor (id, organisation_id, arende_id, hash, mediatyp, storlek, laddad_av)
values ($1, $2, $3, $4, $5, $6, $7)`,
[id, anspr.org, laddaUppVag[1], hash, mediatyp, data.length, anspr.sub],
);
// Hashen går tillbaka till klienten och hamnar i händelsen —
// därmed står den i den append-only-skyddade loggen.
return svara(res, 200, { id, hash, mediatyp, storlek: data.length });
}
const bilagaVag = vag.match(/^\/api\/bilagor\/([A-Za-z0-9_-]+)$/);
if (req.method === "GET" && bilagaVag) {
const rad = await pool.query(
`select hash, mediatyp from bilagor where id = $1 and organisation_id = $2`,
[bilagaVag[1], anspr.org],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Bilagan finns inte." });
return skickaBilaga(res, rad.rows[0]);
}
// ECM Knowledge Library: aktuellt regelpaket för inloggade klienter.
if (req.method === "GET" && vag === "/api/ecm/regler") {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": res.ursprung,
});
return res.end(ECM_REGLER);
}
// -- Märkesspecifika kopplingar (integrationer) --
// Registret läses av alla inloggade (så inställningssidan kan visa
// vilka leverantörer som finns); uppgifterna hanteras endast av
// systemadministratören och returneras alltid maskerade.
if (req.method === "GET" && vag === "/api/integrationer/leverantorer") {
return svara(res, 200, INTEGRATIONER);
}
if (vag === "/api/integrationer") {
if (req.method === "GET") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const nyckel = integrationsNyckel();
const rader = await pool.query(
`select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status
from integrationer where organisation_id = $1 order by leverantor`,
[anspr.org],
);
const integrationer = rader.rows.map((rad) => {
const def = leverantorsDef(rad.leverantor);
let uppgifter = {};
try {
if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel));
} catch {
// Fel nyckel eller manipulerad rad — visa inga värden.
}
const maskerade = {};
for (const falt of def?.falt ?? []) {
const varde = uppgifter[falt.nyckel];
maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? "");
}
return {
leverantor: rad.leverantor,
namn: def?.namn ?? rad.leverantor,
aktiv: rad.aktiv,
uppdaterad: rad.uppdaterad,
senast_testad: rad.senast_testad,
senaste_status: rad.senaste_status,
uppgifter: maskerade,
};
});
return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel });
}
if (req.method === "POST") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const nyckel = integrationsNyckel();
if (!nyckel) {
return svara(res, 503, {
error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.",
});
}
const { leverantor, uppgifter, aktiv } = await lasKropp(req);
const def = leverantorsDef(leverantor);
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
if (!uppgifter || typeof uppgifter !== "object") {
return svara(res, 400, { error: "Uppgifter saknas." });
}
// Endast leverantörens definierade fält sparas, och varje fält
// måste ha ett värde — inga tomma nycklar i vila.
const rena = {};
for (const falt of def.falt) {
const varde = uppgifter[falt.nyckel];
if (typeof varde !== "string" || !varde.trim()) {
return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` });
}
if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." });
rena[falt.nyckel] = varde.trim();
}
await pool.query(
`insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv)
values ($1, $2, $3, $4)
on conflict (organisation_id, leverantor)
do update set uppgifter_krypt = excluded.uppgifter_krypt,
aktiv = excluded.aktiv,
uppdaterad = now(),
senast_testad = null,
senaste_status = null`,
[anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false],
);
return svara(res, 200, { ok: true });
}
}
const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/);
if (req.method === "DELETE" && integrationVag) {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [
anspr.org,
integrationVag[1],
]);
return svara(res, 200, { ok: true });
}
// Uppslag mot märkesspecifik koppling. Anropet görs alltid av
// servern — kundens leverantörsnycklar når aldrig webbläsaren.
const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/);
if (req.method === "POST" && uppslagVag) {
const nyckel = integrationsNyckel();
const def = leverantorsDef(uppslagVag[1]);
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." });
const { identifierare } = await lasKropp(req);
if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) {
return svara(res, 400, { error: "Ogiltig identifierare." });
}
const rad = await pool.query(
`select uppgifter_krypt from integrationer
where organisation_id = $1 and leverantor = $2 and aktiv = true`,
[anspr.org, uppslagVag[1]],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." });
let uppgifter;
try {
uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel));
} catch {
return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." });
}
const resultat = await gorUppslag(def, uppgifter, identifierare.trim().toUpperCase());
await pool.query(
`update integrationer set senast_testad = now(), senaste_status = $3
where organisation_id = $1 and leverantor = $2`,
[anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)],
);
if (!resultat.ok) return svara(res, 502, { error: resultat.fel });
return svara(res, 200, { fordon: resultat.fordon });
}
// Organisationens inställningar: vad som visas när ett ärende
// startas (objekttyper, identifieringsmetoder). Alla inloggade
// läser; endast systemadministratören ändrar.
if (req.method === "GET" && vag === "/api/organisation") {
const rader = await pool.query(
`select namn, installningar from organisationer where id = $1`,
[anspr.org],
);
if (rader.rowCount === 0) return svara(res, 404, { error: "Organisationen finns inte." });
return svara(res, 200, rader.rows[0]);
}
if (req.method === "POST" && vag === "/api/organisation/installningar") {
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
const { objekttyper, identifieringsmetoder } = await lasKropp(req);
const giltigLista = (lista) =>
Array.isArray(lista) && lista.length > 0 && lista.length <= 50 &&
lista.every((v) => typeof v === "string" && v.length <= 100);
if (!giltigLista(objekttyper) || !giltigLista(identifieringsmetoder)) {
return svara(res, 400, { error: "Ange minst en objekttyp och en identifieringsmetod." });
}
await pool.query(
`update organisationer set installningar = $2 where id = $1`,
[anspr.org, JSON.stringify({ objekttyper, identifieringsmetoder })],
);
return svara(res, 200, { ok: true });
}
// Fordonshistorik: organisationens tidigare ärenden på samma objekt
// (regnr/VIN), med dokumenterade felorsaker — pre-diagnostikens
// historiksteg och orsakskedjan. Organisationsgränsen gäller alltid.
const fordonVag = vag.match(/^\/api\/fordon\/([^/]+)\/historik$/);
if (req.method === "GET" && fordonVag) {
const ident = decodeURIComponent(fordonVag[1]).trim().toUpperCase();
if (!ident) return svara(res, 400, { error: "Identifierare saknas." });
const rader = await pool.query(
`select a.id, a.nummer, a.skapad,
coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat,
(array_agg(h.handelse->>'text')
filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning,
coalesce(json_agg(json_build_object(
'avvikelse', h.handelse->>'avvikelse',
'orsaker', h.handelse->'orsaker',
'atgard', h.handelse->>'atgard'))
filter (where h.handelse->>'typ' = 'felorsak'), '[]') as felorsaker
from felsokning_arenden a
join felsokning_handelser h on h.arende_id = a.id
where a.organisation_id = $1
and a.id in (
select arende_id from felsokning_handelser
where handelse->>'typ' = 'objekt_identifierat'
and upper(handelse->'objekt'->>'identifierare') = $2
)
group by a.id
order by a.skapad desc
limit 20`,
[anspr.org, ident],
);
return svara(res, 200, { arenden: rader.rows });
}
// Flottdata: felorsaksstatistik per orsakskategori över hela
// organisationen (arbetsledare/admin) — återkommande fel blir
// synliga när orsakerna är strukturerad data.
if (req.method === "GET" && vag === "/api/statistik/felorsaker") {
if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") {
return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
}
const rader = await pool.query(
`select orsak, count(*)::int as antal
from felsokning_arenden a
join felsokning_handelser h on h.arende_id = a.id,
jsonb_array_elements_text(h.handelse->'orsaker') as orsak
where a.organisation_id = $1 and h.handelse->>'typ' = 'felorsak'
group by orsak
order by antal desc`,
[anspr.org],
);
return svara(res, 200, { orsaker: rader.rows });
}
// Organisationsöversikt för arbetsledare/admin: alla ärenden med
// status, deltagande tekniker och sammanfattning — härlett ur
// händelseloggen, aldrig lagrat separat.
if (req.method === "GET" && vag === "/api/oversikt") {
if (anspr.roll !== "arbetsledare" && anspr.roll !== "admin") {
return svara(res, 403, { error: "Kräver arbetsledar- eller administratörsbehörighet." });
}
const rader = await pool.query(
`select a.id, a.nummer, a.skapad, a.delningskod, a.metodik_id,
count(h.id)::int as antal_handelser,
min(h.tidpunkt) as forsta,
max(h.tidpunkt) as senaste,
coalesce(bool_or(h.handelse->>'typ' = 'arende_avslutat'), false) as avslutat,
(array_agg(h.handelse->'objekt'->>'beskrivning')
filter (where h.handelse->>'typ' = 'objekt_identifierat'))[1] as objekt,
(array_agg(h.handelse->>'text')
filter (where h.handelse->>'typ' = 'felbeskrivning'))[1] as felbeskrivning,
(array_agg(coalesce(h.handelse->>'ansvarig', h.handelse->>'till') order by h.tidpunkt desc)
filter (where h.handelse->>'typ' in ('ansvarig_satt', 'overlamning')
and coalesce(h.handelse->>'ansvarig', h.handelse->>'till') is not null))[1] as ansvarig,
(array_agg(h.anvandare order by h.tidpunkt asc))[1] as skapare,
array_agg(distinct h.anvandare) filter (where h.anvandare is not null) as tekniker
from felsokning_arenden a
left join felsokning_handelser h on h.arende_id = a.id
where a.organisation_id = $1
group by a.id
order by max(h.tidpunkt) desc nulls last
limit 200`,
[anspr.org],
);
return svara(res, 200, { arenden: rader.rows });
}
if (req.method === "GET" && vag === "/api/arenden") {
const rader = await pool.query(
`select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden
where organisation_id = $1 order by skapad desc limit 200`,
[anspr.org],
);
return svara(res, 200, { arenden: rader.rows });
}
if (req.method === "POST" && vag === "/api/arenden") {
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
if (typeof id !== "string" || typeof nummer !== "number" || !skapad) {
return svara(res, 400, { error: "Ogiltigt ärende." });
}
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`,
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
);
return svara(res, 200, { ok: true });
}
// Delningslänkar: skapa/lista per ärende, återkalla per kod.
// Verkstaden kontrollerar alltid delningen (organisationskravet).
const delningarVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/delningar$/);
if (delningarVag) {
if (!(await arendeIOrg(delningarVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
if (req.method === "GET") {
const rader = await pool.query(
`select kod, niva, skapad, aterkallad from delningar where arende_id = $1 order by skapad desc`,
[delningarVag[1]],
);
return svara(res, 200, { delningar: rader.rows });
}
if (req.method === "POST") {
const { niva } = await lasKropp(req);
if (!["kund", "partner", "intern"].includes(niva)) {
return svara(res, 400, { error: "Ogiltig nivå." });
}
const kod = nyKod();
await pool.query(
`insert into delningar (kod, arende_id, niva, skapad_av) values ($1, $2, $3, $4)`,
[kod, delningarVag[1], niva, anspr.sub],
);
return svara(res, 200, { kod, niva });
}
}
const aterkalla = vag.match(/^\/api\/delningar\/([A-Za-z0-9_-]+)\/aterkalla$/);
if (req.method === "POST" && aterkalla) {
const rad = await pool.query(
`update delningar d set aterkallad = now()
from felsokning_arenden a
where d.kod = $1 and d.arende_id = a.id and a.organisation_id = $2 and d.aterkallad is null
returning d.kod`,
[aterkalla[1], anspr.org],
);
if (rad.rowCount === 0) return svara(res, 404, { error: "Delningen är inte tillgänglig." });
return svara(res, 200, { ok: true });
}
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
if (handelserVag) {
// Organisationsgränsen: ärendet måste tillhöra användarens org.
if (!(await arendeIOrg(handelserVag[1], anspr.org))) {
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
}
if (req.method === "GET") {
const rader = await pool.query(
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
where arende_id = $1 order by tidpunkt, id`,
[handelserVag[1]],
);
return svara(res, 200, { handelser: rader.rows });
}
if (req.method === "POST") {
const { handelser } = await lasKropp(req);
if (!Array.isArray(handelser) || handelser.length > 500) {
return svara(res, 400, { error: "Ogiltig händelselista." });
}
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." });
}
// Append-only: on conflict do nothing — en befintlig händelse
// skrivs aldrig över, och databastriggern stoppar allt annat.
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],
);
}
return svara(res, 200, { ok: true });
}
}
return svara(res, 404, { error: "Okänd resurs." });
} catch (fel) {
console.error("plattform:", fel);
return svara(res, 500, { error: "Förfrågan misslyckades." });
}
});
}
if (process.env.NODE_ENV !== "test") {
skapaServer().listen(PORT, () => {
console.log(`plattform lyssnar på :${PORT}`);
});
}