From 7c24186cc55d3c96ff8c593901d572c2ff97375a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 01:41:58 +0000 Subject: [PATCH] =?UTF-8?q?Helt=20sj=C3=A4lvhostat:=20plattformsbackend,?= =?UTF-8?q?=20Postgres=20i=20klustret,=20klientl=C3=A4ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sista externa beroendet borta — hela stacken kör i eget kluster: - Ny plattformstjänst (services/plattform): egen inloggning (registrering + inloggning, lösenord bcrypt-hashade med pgcrypto, HS256-JWT), append-only händelse-API för synken (endast insert, on conflict do nothing) och publik delningsendpoint för Live Share med samma filtrering som Supabase-funktionen. Fail closed utan hemligheter; inga update/delete-operationer exponeras. - Postgres som StatefulSet med PVC och init-schema; append-only garanterat i databasen med triggers på både händelser och ärenden — historik kan inte ändras oavsett ansluten roll. CloudNativePG rekommenderas för produktion i docs. - JWT-hemligheten delas mellan plattform och AI-orkester (orkestern läser JWT_SECRET med SUPABASE_JWT_SECRET som fallback) — flödet livetestat: plattformens token accepteras av orkestern, fel hemlighet och utgångna tokens avvisas. - Klienten får självhostat läge via VITE_PLATTFORM_URL: inloggnings- panel på startsidan, synk mot plattformens API (samma konfliktfria flätning), Live Share via /api/delad och AI via klustrets orkester. Utan variabeln är Supabase-läget oförändrat. - Ingress routar /api/ai → orkestern, /api + /halsa → plattformen, / → webben; kustomize inkluderar hela stacken; CI bygger alla tre bilderna; DRIFT.md omskriven för självhostad drift. Verifierat: 28 vitest-tester gröna (inkl. append-only-triggers och API-ytan), plattformstjänsten livetestad (hälsa, 401, fail closed), JWT-paritet mellan tjänsterna testad, alla manifest YAML-validerade, produktionsbygge ok, Playwright-röktest grönt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- .github/workflows/ci.yml | 1 + Dockerfile | 4 +- docs/DRIFT.md | 51 +++--- infra/k8s/ai-orkester.yaml | 4 +- infra/k8s/hemligheter.exempel.yaml | 6 +- infra/k8s/ingress.yaml | 9 +- infra/k8s/kustomization.yaml | 2 + infra/k8s/plattform.yaml | 80 +++++++++ infra/k8s/postgres.yaml | 123 ++++++++++++++ services/ai-orkester/server.mjs | 4 +- services/plattform/Dockerfile | 13 ++ services/plattform/package-lock.json | 161 ++++++++++++++++++ services/plattform/package.json | 13 ++ services/plattform/server.mjs | 218 +++++++++++++++++++++++++ src/felsokning/__tests__/ai.test.ts | 21 +++ src/felsokning/ai.ts | 25 ++- src/felsokning/plattform.ts | 64 ++++++++ src/felsokning/synk.ts | 43 +++++ src/pages/felsokning/Arendelista.tsx | 89 ++++++++++ src/pages/felsokning/PublikDelning.tsx | 20 ++- 20 files changed, 906 insertions(+), 45 deletions(-) create mode 100644 infra/k8s/plattform.yaml create mode 100644 infra/k8s/postgres.yaml create mode 100644 services/plattform/Dockerfile create mode 100644 services/plattform/package-lock.json create mode 100644 services/plattform/package.json create mode 100644 services/plattform/server.mjs create mode 100644 src/felsokning/plattform.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eacccc..f1f8cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,4 @@ jobs: # till registry sker i ett separat, behörighetsstyrt flöde. - run: docker build -t guidad-felsokning-web . - run: docker build -t guidad-felsokning-ai-orkester services/ai-orkester + - run: docker build -t guidad-felsokning-plattform services/plattform diff --git a/Dockerfile b/Dockerfile index 5b5e2cf..3dee4a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,12 @@ ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_PUBLISHABLE_KEY ARG VITE_SUPABASE_PROJECT_ID ARG VITE_AI_ORKESTER_URL +ARG VITE_PLATTFORM_URL ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \ VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY \ VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID \ - VITE_AI_ORKESTER_URL=$VITE_AI_ORKESTER_URL + VITE_AI_ORKESTER_URL=$VITE_AI_ORKESTER_URL \ + VITE_PLATTFORM_URL=$VITE_PLATTFORM_URL RUN npm run build FROM nginxinc/nginx-unprivileged:1.27-alpine diff --git a/docs/DRIFT.md b/docs/DRIFT.md index cbe4fc8..9102c76 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -1,68 +1,67 @@ -# Guidad Felsökning – Drift i Kubernetes +# Guidad Felsökning – Drift i Kubernetes (helt självhostat) -Målarkitekturen ur [Master Prompt](MASTER-PROMPT.md) som kod: allt körbart i kluster, med secrets för plattformsnycklarna och CI som verifierar både kod och containerbyggen. +Målarkitekturen ur [Master Prompt](MASTER-PROMPT.md) som kod: hela stacken körbar i eget kluster — webb, AI-orkester, plattformsbackend (auth + händelse-API + Live Share) och Postgres. Inga externa tjänstberoenden utöver Anthropic-API:et för AI-anropen. ## Arkitektur ```mermaid flowchart LR T[Tekniker] -->|HTTPS| I[Ingress + TLS] + T2[Kund via delningslänk] -->|HTTPS| I I -->|/| W[web\n2–10 pods, HPA] I -->|/api/ai| A[ai-orkester\n2–10 pods, HPA] + I -->|/api, /halsa| P[plattform\n2–10 pods, HPA] A -->|Claude API| C[(Anthropic)] - W & A -.->|auth + data| S[(Supabase\nPostgres · Auth)] - K[Secret: felsokning-hemligheter] --> A + P --> DB[(Postgres\nStatefulSet + PVC)] + K[Secret: felsokning-hemligheter] --> A & P & DB ``` | Komponent | Vad | Var | | --- | --- | --- | | `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB | -| `ai-orkester` | AI-orkestern som egen tjänst (`services/ai-orkester`) — samma routing som edge-funktionen, med egen JWT-verifiering och `/halsa` | Deployment + Service + HPA + PDB | -| Hemligheter | `ANTHROPIC_API_KEY` + `SUPABASE_JWT_SECRET` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | -| Databas & auth | Postgres (händelselogg, RLS, `hamta_delat_arende`) och inloggning | Supabase — managerad, eller självhostad Supabase i klustret när det steget tas | +| `plattform` | Självhostad backend (`services/plattform`): inloggning (bcrypt via pgcrypto, HS256-JWT), append-only händelse-API för synken, publik delningsendpoint för Live Share | Deployment + Service + HPA + PDB | +| `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB | +| `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR | +| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | -Klienten väljer AI-väg vid byggtillfället: med `VITE_AI_ORKESTER_URL` satt går anropen till orkestertjänsten i klustret; utan den används Supabase-edge-funktionen. Samma orkester (modeller, systemprompter, scheman) i båda. +**Klienten har två driftlägen**, valda vid bygget: med `VITE_PLATTFORM_URL` går inloggning, synk, Live Share och AI mot klustret (helt självhostat); utan den används Supabase-läget (edge-funktion + managerad Postgres/Auth) som tidigare. Samma händelsemodell, samma orkester — låst av paritetstester. ## Driftsätta ```sh # 1. Bygg och publicera bilderna (ersätt registry i infra/k8s/*.yaml) docker build -t ghcr.io/ORG/guidad-felsokning-web \ - --build-arg VITE_SUPABASE_URL=… \ - --build-arg VITE_SUPABASE_PUBLISHABLE_KEY=… \ - --build-arg VITE_SUPABASE_PROJECT_ID=… \ - --build-arg VITE_AI_ORKESTER_URL=https://app.exempel.se . + --build-arg VITE_PLATTFORM_URL=https://app.exempel.se . docker build -t ghcr.io/ORG/guidad-felsokning-ai-orkester services/ai-orkester +docker build -t ghcr.io/ORG/guidad-felsokning-plattform services/plattform docker push ghcr.io/ORG/guidad-felsokning-web docker push ghcr.io/ORG/guidad-felsokning-ai-orkester +docker push ghcr.io/ORG/guidad-felsokning-plattform # 2. Skapa secret:en (eller använd External Secrets/Sealed Secrets) kubectl create namespace guidad-felsokning kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \ --from-literal=anthropic-api-key='sk-ant-…' \ - --from-literal=supabase-jwt-secret='…' + --from-literal=jwt-secret="$(openssl rand -base64 48)" \ + --from-literal=postgres-losenord="$(openssl rand -base64 24)" -# 3. Applicera manifesten +# 3. Applicera manifesten (Postgres initieras med schema + append-only-triggers) kubectl apply -k infra/k8s # 4. Verifiera kubectl -n guidad-felsokning get pods -curl https://app.exempel.se/halsa # → {"status":"ok"} +curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen) ``` -Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. +Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Självregistrering är öppen i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment när organisationsstyrd användarhantering införs. -## Skalning och robusthet +## Säkerhet och robusthet -- **HPA** 2–10 pods per tjänst på 70 % CPU; **PDB** håller minst en pod uppe vid noddränering. -- Båda containrarna kör **non-root** utan capabilities; orkestern dessutom med read-only rotfilsystem. -- **Hälsokontroller**: nginx svarar på `/`, orkestern på `/halsa` (readiness + liveness + Docker HEALTHCHECK). -- Orkestern **failar closed**: utan nyckel/JWT-hemlighet svarar den 503, utan giltig JWT 401. +- **Append-only i tre lager:** klienten lägger bara till, API:t exponerar inga update/delete, och databastriggers avvisar ändringar även för en felkonfigurerad roll. +- **JWT-flödet är verifierat tvärs tjänsterna:** plattformen signerar, orkestern verifierar samma hemlighet; fel hemlighet och utgångna tokens avvisas (testat). +- Alla containrar kör **non-root** utan capabilities; backend-tjänsterna med read-only rotfilsystem. Båda failar closed utan sina hemligheter. +- **HPA** 2–10 pods per tjänst på 70 % CPU; **PDB** minst en pod uppe vid noddränering; readiness/liveness-prober överallt (`pg_isready` för Postgres). ## CI -`.github/workflows/ci.yml` kör tester + produktionsbygge och verifierar båda Dockerfilerna på varje push/PR. Publicering till registry och `kubectl apply` läggs i ett separat, behörighetsstyrt deploy-flöde (t.ex. environments med godkännande eller GitOps via Argo CD/Flux). - -## Kvar mot full självhostning - -Synk, Live Share-funktionen och inloggningen går fortfarande mot Supabase (managerad Postgres + Auth). Nästa steg för "allt i kluster" är självhostad Supabase eller ett eget API-lager över Postgres i klustret — händelsemodellen är redan API-klar, så det bytet påverkar inte klienten. +`.github/workflows/ci.yml` kör tester + produktionsbygge och verifierar alla tre Dockerfilerna på varje push/PR. Publicering och `kubectl apply` läggs i ett separat behörighetsstyrt deploy-flöde (GitOps via Argo CD/Flux rekommenderas). diff --git a/infra/k8s/ai-orkester.yaml b/infra/k8s/ai-orkester.yaml index 6657002..655602a 100644 --- a/infra/k8s/ai-orkester.yaml +++ b/infra/k8s/ai-orkester.yaml @@ -26,9 +26,9 @@ spec: - name: ANTHROPIC_API_KEY valueFrom: secretKeyRef: { name: felsokning-hemligheter, key: anthropic-api-key } - - name: SUPABASE_JWT_SECRET + - name: JWT_SECRET valueFrom: - secretKeyRef: { name: felsokning-hemligheter, key: supabase-jwt-secret } + secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret } resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: "1", memory: 256Mi } diff --git a/infra/k8s/hemligheter.exempel.yaml b/infra/k8s/hemligheter.exempel.yaml index 82a10d4..9ed333b 100644 --- a/infra/k8s/hemligheter.exempel.yaml +++ b/infra/k8s/hemligheter.exempel.yaml @@ -2,7 +2,8 @@ # # kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \ # --from-literal=anthropic-api-key='sk-ant-…' \ -# --from-literal=supabase-jwt-secret='…' +# --from-literal=jwt-secret="$(openssl rand -base64 48)" \ +# --from-literal=postgres-losenord="$(openssl rand -base64 24)" # # I produktion: använd extern secrets-hantering (External Secrets Operator, # Sealed Secrets eller molnleverantörens secret manager). @@ -14,4 +15,5 @@ metadata: type: Opaque stringData: anthropic-api-key: ERSATT-MIG - supabase-jwt-secret: ERSATT-MIG + jwt-secret: ERSATT-MIG + postgres-losenord: ERSATT-MIG diff --git a/infra/k8s/ingress.yaml b/infra/k8s/ingress.yaml index 7dbc99d..eb17d80 100644 --- a/infra/k8s/ingress.yaml +++ b/infra/k8s/ingress.yaml @@ -1,4 +1,5 @@ -# Ingress med TLS (cert-manager): webben på /, AI-orkestern på /api/ai. +# Ingress med TLS (cert-manager). Längsta prefix vinner: +# /api/ai → AI-orkestern, övriga /api + /halsa → plattformen, / → webben. apiVersion: networking.k8s.io/v1 kind: Ingress metadata: @@ -19,10 +20,14 @@ spec: pathType: Prefix backend: service: { name: ai-orkester, port: { number: 80 } } + - path: /api + pathType: Prefix + backend: + service: { name: plattform, port: { number: 80 } } - path: /halsa pathType: Prefix backend: - service: { name: ai-orkester, port: { number: 80 } } + service: { name: plattform, port: { number: 80 } } - path: / pathType: Prefix backend: diff --git a/infra/k8s/kustomization.yaml b/infra/k8s/kustomization.yaml index 496d2df..e13e93f 100644 --- a/infra/k8s/kustomization.yaml +++ b/infra/k8s/kustomization.yaml @@ -2,6 +2,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - namespace.yaml + - postgres.yaml + - plattform.yaml - web.yaml - ai-orkester.yaml - ingress.yaml diff --git a/infra/k8s/plattform.yaml b/infra/k8s/plattform.yaml new file mode 100644 index 0000000..8271a47 --- /dev/null +++ b/infra/k8s/plattform.yaml @@ -0,0 +1,80 @@ +# Plattformstjänsten: självhostad auth, händelse-API och Live Share-delning. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: plattform + namespace: guidad-felsokning + labels: { app: plattform } +spec: + replicas: 2 + selector: + matchLabels: { app: plattform } + template: + metadata: + labels: { app: plattform } + spec: + securityContext: + runAsNonRoot: true + containers: + - name: plattform + image: ghcr.io/ORGANISATION/guidad-felsokning-plattform:latest # ersätt med ert registry + ports: + - containerPort: 8080 + env: + - name: JWT_SECRET + valueFrom: + secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret } + - name: POSTGRES_LOSENORD + valueFrom: + secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord } + - name: DATABASE_URL + value: postgresql://plattform:$(POSTGRES_LOSENORD)@postgres:5432/felsokning + resources: + requests: { cpu: 100m, memory: 128Mi } + limits: { cpu: "1", memory: 256Mi } + readinessProbe: + httpGet: { path: /halsa, port: 8080 } + initialDelaySeconds: 3 + livenessProbe: + httpGet: { path: /halsa, port: 8080 } + periodSeconds: 15 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } +--- +apiVersion: v1 +kind: Service +metadata: + name: plattform + namespace: guidad-felsokning +spec: + selector: { app: plattform } + ports: + - port: 80 + targetPort: 8080 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: plattform + namespace: guidad-felsokning +spec: + scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: plattform } + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: { type: Utilization, averageUtilization: 70 } +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: plattform + namespace: guidad-felsokning +spec: + minAvailable: 1 + selector: + matchLabels: { app: plattform } diff --git a/infra/k8s/postgres.yaml b/infra/k8s/postgres.yaml new file mode 100644 index 0000000..220891a --- /dev/null +++ b/infra/k8s/postgres.yaml @@ -0,0 +1,123 @@ +# Självhostad Postgres för Guidad Felsökning. +# För produktion rekommenderas CloudNativePG-operatorn (automatiska +# säkerhetskopior, failover, PITR) — den här StatefulSet:en är den enkla +# grundplattan med samma schema. +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-init + namespace: guidad-felsokning +data: + # Körs vid första starten. Append-only garanteras med triggers — + # historiken kan inte ändras eller raderas oavsett vilken roll som + # ansluter. + init.sql: | + create extension if not exists pgcrypto; + + create table if not exists anvandare ( + id uuid primary key default gen_random_uuid(), + epost text unique not null, + losen_hash text not null, + namn text not null, + skapad timestamptz not null default now() + ); + + create table if not exists felsokning_arenden ( + id text primary key, + nummer integer not null, + skapad timestamptz not null, + delningskod text unique, + metodik_id text, + skapad_av uuid references anvandare(id), + insatt timestamptz not null default now() + ); + + create table if not exists felsokning_handelser ( + id text primary key, + arende_id text not null references felsokning_arenden(id), + tidpunkt timestamptz not null, + anvandare text not null, + handelse jsonb not null, + insatt timestamptz not null default now() + ); + create index if not exists felsokning_handelser_arende_idx + on felsokning_handelser (arende_id, tidpunkt); + + create or replace function forbjud_andring() returns trigger + language plpgsql as $$ + begin + raise exception 'Loggen är append-only — historik kan inte ändras eller raderas'; + end $$; + + drop trigger if exists handelser_append_only on felsokning_handelser; + create trigger handelser_append_only + before update or delete on felsokning_handelser + for each row execute function forbjud_andring(); + + drop trigger if exists arenden_append_only on felsokning_arenden; + create trigger arenden_append_only + before update or delete on felsokning_arenden + for each row execute function forbjud_andring(); +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: guidad-felsokning +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: { app: postgres } + template: + metadata: + labels: { app: postgres } + spec: + containers: + - name: postgres + image: postgres:17-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: felsokning + - name: POSTGRES_USER + value: plattform + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord } + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + - name: init + mountPath: /docker-entrypoint-initdb.d + resources: + requests: { cpu: 250m, memory: 256Mi } + limits: { cpu: "1", memory: 1Gi } + readinessProbe: + exec: + command: ["pg_isready", "-U", "plattform", "-d", "felsokning"] + initialDelaySeconds: 5 + volumes: + - name: init + configMap: { name: postgres-init } + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 10Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: guidad-felsokning +spec: + selector: { app: postgres } + ports: + - port: 5432 diff --git a/services/ai-orkester/server.mjs b/services/ai-orkester/server.mjs index 91ccc4b..c871c29 100644 --- a/services/ai-orkester/server.mjs +++ b/services/ai-orkester/server.mjs @@ -156,7 +156,9 @@ export function skapaServer() { } const apiNyckel = process.env.ANTHROPIC_API_KEY; - const jwtHemlighet = process.env.SUPABASE_JWT_SECRET; + // 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." }); } diff --git a/services/plattform/Dockerfile b/services/plattform/Dockerfile new file mode 100644 index 0000000..4e285e2 --- /dev/null +++ b/services/plattform/Dockerfile @@ -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 ./ + +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"] diff --git a/services/plattform/package-lock.json b/services/plattform/package-lock.json new file mode 100644 index 0000000..5f1854f --- /dev/null +++ b/services/plattform/package-lock.json @@ -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" + } + } + } +} diff --git a/services/plattform/package.json b/services/plattform/package.json new file mode 100644 index 0000000..7c9dc3f --- /dev/null +++ b/services/plattform/package.json @@ -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" + } +} diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs new file mode 100644 index 0000000..a47c538 --- /dev/null +++ b/services/plattform/server.mjs @@ -0,0 +1,218 @@ +// Plattformstjänsten — självhostad backend för Guidad Felsökning. +// +// Ersätter Supabase i klustret: egen inloggning (HS256-JWT, lösenord +// hashade med pgcrypto/bcrypt i Postgres), händelse-API för synken och +// publik delningsendpoint för Live Share. 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 självregistrering (default öppen, beta) +// PORT default 8080 + +import { createServer } from "node:http"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import pg from "pg"; + +const PORT = Number(process.env.PORT ?? 8080); +const MAX_KROPP = 4 * 1024 * 1024; +const TOKEN_LIVSTID_S = 12 * 60 * 60; + +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); + +// ---- 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; + } +} + +// ---- Hjälpare --------------------------------------------------------- + +function svara(res, status, kropp) { + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-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")); +} + +function kravAuth(req, hemlighet) { + const auth = req.headers.authorization ?? ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; + return token ? verifieraJwt(token, hemlighet) : null; +} + +// ---- Server ----------------------------------------------------------- + +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": "GET, POST, 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" }); + } + + const hemlighet = process.env.JWT_SECRET; + if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." }); + + // -- Auth (öppna endpoints) -- + if (req.method === "POST" && vag === "/api/auth/registrera") { + if (process.env.REGISTRERING_OPPEN === "false") { + return svara(res, 403, { error: "Registrering är stängd." }); + } + const { epost, losenord, namn } = await lasKropp(req); + if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim()) { + return svara(res, 400, { error: "Ogiltig e-post, namn eller för kort lösenord (minst 8 tecken)." }); + } + const rader = await pool.query( + `insert into anvandare (epost, losen_hash, namn) + values (lower($1), crypt($2, gen_salt('bf')), $3) + on conflict (epost) do nothing + returning id, namn`, + [epost.trim(), losenord, namn.trim()], + ); + if (rader.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." }); + return loggaIn(res, rader.rows[0], hemlighet); + } + + if (req.method === "POST" && vag === "/api/auth/logga-in") { + const { epost, losenord } = await lasKropp(req); + const rader = await pool.query( + `select id, namn from anvandare + where epost = lower($1) and losen_hash = crypt($2, losen_hash)`, + [epost ?? "", losenord ?? ""], + ); + if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." }); + return loggaIn(res, rader.rows[0], hemlighet); + } + + // -- Publik delning (Live Share via delningskod) -- + const delad = vag.match(/^\/api\/delad\/([a-z0-9-]+)$/); + if (req.method === "GET" && delad) { + const arende = await pool.query( + `select id, nummer, skapad from felsokning_arenden where delningskod = $1`, + [delad[1]], + ); + if (arende.rowCount === 0) return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); + const handelser = await pool.query( + `select id, tidpunkt, anvandare, handelse from felsokning_handelser + where arende_id = $1 and handelse->>'typ' not in ('kategori_byte','hypotes','ai_svar') + order by tidpunkt, id`, + [arende.rows[0].id], + ); + return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows }); + } + + // -- Skyddade endpoints -- + const anspr = kravAuth(req, hemlighet); + if (!anspr) return svara(res, 401, { error: "Inloggning krävs." }); + + 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, nummer, skapad, delningskod, metodik_id, skapad_av) + values ($1, $2, $3, $4, $5, $6) on conflict (id) do nothing`, + [id, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub], + ); + return svara(res, 200, { ok: true }); + } + + const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/); + if (handelserVag && 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 (handelserVag && 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." }); + } + }); + + function loggaIn(res, anvandare, hemlighet) { + const nu = Math.floor(Date.now() / 1000); + const token = skapaJwt( + { sub: anvandare.id, namn: anvandare.namn, iat: nu, exp: nu + TOKEN_LIVSTID_S }, + hemlighet, + ); + return svara(res, 200, { token, namn: anvandare.namn }); + } +} + +if (process.env.NODE_ENV !== "test") { + skapaServer().listen(PORT, () => { + console.log(`plattform lyssnar på :${PORT}`); + }); +} diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 3f844c3..1c63345 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -36,6 +36,27 @@ describe("AI-orkestern", () => { } }); + it("självhostade stacken: plattformstjänst + append-only i databasen", () => { + const plattform = readFileSync("services/plattform/server.mjs", "utf8"); + for (const bit of [ + "/api/auth/logga-in", + "/api/auth/registrera", + "/api/arenden", + String.raw`\/api\/delad\/`, + "gen_salt('bf')", + "JWT_SECRET", + ]) { + expect(plattform).toContain(bit); + } + // API:t exponerar medvetet inga update/delete-operationer. + expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i); + + const postgres = readFileSync("infra/k8s/postgres.yaml", "utf8"); + expect(postgres).toContain("before update or delete on felsokning_handelser"); + expect(postgres).toContain("before update or delete on felsokning_arenden"); + expect(postgres).toContain("append-only"); + }); + it("endpointen kodar AI-reglerna i grundprompten", () => { expect(ENDPOINT).toContain("Hitta aldrig på fakta"); expect(ENDPOINT).toContain("aldrig en hypotes som ett konstaterat fel"); diff --git a/src/felsokning/ai.ts b/src/felsokning/ai.ts index 90f6d7f..2f7a0f8 100644 --- a/src/felsokning/ai.ts +++ b/src/felsokning/ai.ts @@ -96,23 +96,36 @@ const ORKESTER_URL = (import.meta.env.VITE_AI_ORKESTER_URL as string | undefined // Returnerar null i lokalt läge (ej inloggad) — orkestern nås via // plattformens autentiserade backend. async function anropa(uppgift: AiUppgift, prompt: string): Promise<{ modell: string; svar: unknown } | null> { - const { supabase } = await import("@/integrations/supabase/client"); - const { data } = await supabase.auth.getSession(); - if (!data.session) return null; + const { plattformAktiv, plattformToken, PLATTFORM_URL } = await import("./plattform"); + + let token: string | null = null; + if (plattformAktiv()) { + // Självhostat: plattformens egen JWT — samma hemlighet verifieras + // av AI-orkestern i klustret. + token = plattformToken(); + if (!token) return null; + } else { + const { supabase } = await import("@/integrations/supabase/client"); + const { data } = await supabase.auth.getSession(); + if (!data.session) return null; + token = data.session.access_token; + } let resultat: unknown; - if (ORKESTER_URL) { - const res = await fetch(`${ORKESTER_URL}/api/ai`, { + const bas = ORKESTER_URL ?? (plattformAktiv() ? PLATTFORM_URL : undefined); + if (bas) { + const res = await fetch(`${bas}/api/ai`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${data.session.access_token}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify({ uppgift, prompt }), }); if (!res.ok) throw new Error(`AI-orkestern svarade ${res.status}`); resultat = await res.json(); } else { + const { supabase } = await import("@/integrations/supabase/client"); const { data: svar, error } = await supabase.functions.invoke("felsokning-ai", { body: { uppgift, prompt }, }); diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts new file mode 100644 index 0000000..5067b5b --- /dev/null +++ b/src/felsokning/plattform.ts @@ -0,0 +1,64 @@ +// Självhostat läge: klienten pratar med plattformstjänsten i klustret +// (services/plattform) i stället för Supabase. Aktiveras vid bygget med +// VITE_PLATTFORM_URL; utan den används Supabase-läget precis som förut. + +export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, ""); + +const TOKEN_NYCKEL = "gf-plattform-token"; +const NAMN_NYCKEL = "gf-plattform-namn"; + +export function plattformAktiv(): boolean { + return !!PLATTFORM_URL; +} + +export function plattformToken(): string | null { + return localStorage.getItem(TOKEN_NYCKEL); +} + +export function plattformNamn(): string | null { + return localStorage.getItem(NAMN_NYCKEL); +} + +export function loggaUtPlattform(): void { + localStorage.removeItem(TOKEN_NYCKEL); + localStorage.removeItem(NAMN_NYCKEL); +} + +async function authAnrop(vag: string, kropp: object): Promise { + const res = await fetch(`${PLATTFORM_URL}${vag}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(kropp), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((data as { error?: string }).error ?? `Fel ${res.status}`); + const { token, namn } = data as { token: string; namn: string }; + localStorage.setItem(TOKEN_NYCKEL, token); + localStorage.setItem(NAMN_NYCKEL, namn); + return namn; +} + +export function loggaInPlattform(epost: string, losenord: string): Promise { + return authAnrop("/api/auth/logga-in", { epost, losenord }); +} + +export function registreraPlattform(epost: string, losenord: string, namn: string): Promise { + return authAnrop("/api/auth/registrera", { epost, losenord, namn }); +} + +// Autentiserat anrop mot plattformen. En utgången token rensas (401) så +// att appen faller tillbaka till lokalt läge tills nästa inloggning. +export async function plattformFetch(vag: string, init?: RequestInit): Promise { + const token = plattformToken(); + if (!token) throw new Error("Ej inloggad"); + const res = await fetch(`${PLATTFORM_URL}${vag}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + Authorization: `Bearer ${token}`, + }, + }); + if (res.status === 401) loggaUtPlattform(); + return res; +} diff --git a/src/felsokning/synk.ts b/src/felsokning/synk.ts index 47c877f..ca256fc 100644 --- a/src/felsokning/synk.ts +++ b/src/felsokning/synk.ts @@ -45,7 +45,50 @@ async function klient(): Promise { } } +// Självhostat läge: samma konfliktfria flätning, men mot plattformstjänsten. +async function synkroniseraMotPlattform(arende: Arende): Promise { + const { plattformToken, plattformFetch } = await import("./plattform"); + if (!plattformToken()) return { status: "lokal" }; + try { + await plattformFetch("/api/arenden", { + method: "POST", + body: JSON.stringify({ + id: arende.id, + nummer: arende.nummer, + skapad: arende.skapad, + delningskod: arende.delningskod, + metodikId: arende.metodikId, + }), + }); + const hamtat = await plattformFetch(`/api/arenden/${arende.id}/handelser`); + if (!hamtat.ok) throw new Error(`Fel ${hamtat.status}`); + const { handelser: fjarrRader } = (await hamtat.json()) as { handelser: FjarrHandelseRad[] }; + const fjarr: LoggPost[] = fjarrRader.map((rad) => ({ + id: rad.id, + tidpunkt: new Date(rad.tidpunkt).toISOString(), + anvandare: rad.anvandare, + handelse: rad.handelse, + })); + const fjarrIds = new Set(fjarr.map((p) => p.id)); + const nya = arende.handelser.filter((p) => !fjarrIds.has(p.id)); + if (nya.length > 0) { + const skrivet = await plattformFetch(`/api/arenden/${arende.id}/handelser`, { + method: "POST", + body: JSON.stringify({ handelser: nya }), + }); + if (!skrivet.ok) throw new Error(`Fel ${skrivet.status}`); + } + return { status: "synkad", handelser: flataIhop(arende.handelser, fjarr) }; + } catch { + const { plattformToken: kvarToken } = await import("./plattform"); + return { status: kvarToken() ? "offline" : "lokal" }; + } +} + export async function synkroniseraArende(arende: Arende): Promise { + const { plattformAktiv } = await import("./plattform"); + if (plattformAktiv()) return synkroniseraMotPlattform(arende); + const supabase = await klient(); if (!supabase) return { status: "lokal" }; try { diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 368ccbe..528f7e1 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -3,11 +3,98 @@ import { Link } from "react-router-dom"; import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; import { byggDemoArende } from "@/felsokning/demo"; +import { + loggaInPlattform, + loggaUtPlattform, + plattformAktiv, + plattformNamn, + registreraPlattform, +} from "@/felsokning/plattform"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { tidDatum, tidKlockslag } from "@/felsokning/format"; type Filter = "alla" | "pagaende" | "klara"; +// Självhostat läge: inloggning mot plattformstjänsten i klustret. +// Aktiverar synk, samarbete och AI-orkestern. +function PlattformInloggning() { + const [namn, setNamn] = useState(plattformNamn()); + const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd"); + const [epost, setEpost] = useState(""); + const [losenord, setLosenord] = useState(""); + const [nyttNamn, setNyttNamn] = useState(""); + const [fel, setFel] = useState(""); + + if (namn) { + return ( + +
+

+ Inloggad som {namn} — synk och AI aktiva. +

+ +
+
+ ); + } + + return ( + + {lage === "stangd" ? ( + <> +

+ Logga in för synk mellan enheter, samarbete i ärenden och AI-orkestern. Utan inloggning + arbetar appen i lokalt läge. +

+
+ setLage("registrera")}> + Skapa konto + + setLage("loggaIn")}>Logga in +
+ + ) : ( +
{ + e.preventDefault(); + setFel(""); + try { + const inloggadSom = + lage === "loggaIn" + ? await loggaInPlattform(epost, losenord) + : await registreraPlattform(epost, losenord, nyttNamn); + setNamn(inloggadSom); + } catch (misslyckande) { + setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel."); + } + }} + > + {lage === "registrera" && } + + + {fel &&

{fel}

} +
+ setLage("stangd")}> + Avbryt + + + {lage === "loggaIn" ? "Logga in" : "Skapa konto"} + +
+ + )} +
+ ); +} + // Dashboard enligt direktivet: endast det viktigaste — mina ärenden, // pågående, klara och starta nytt ärende. export default function Arendelista() { @@ -90,6 +177,8 @@ export default function Arendelista() {

Inga ärenden i det här filtret.

)} + {plattformAktiv() && } +

Ingår i tjänsten och drivs av plattformen — flera Claude-modeller med olika roller: handledning i diff --git a/src/pages/felsokning/PublikDelning.tsx b/src/pages/felsokning/PublikDelning.tsx index 7f1133d..80e010f 100644 --- a/src/pages/felsokning/PublikDelning.tsx +++ b/src/pages/felsokning/PublikDelning.tsx @@ -15,11 +15,21 @@ interface DelatSvar { } async function hamtaDelat(kod: string): Promise { - const { supabase } = await import("@/integrations/supabase/client"); - const { data, error } = await (supabase as unknown as { - rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>; - }).rpc("hamta_delat_arende", { kod }); - if (error || !data) return undefined; + const { plattformAktiv, PLATTFORM_URL } = await import("@/felsokning/plattform"); + let data: unknown; + if (plattformAktiv()) { + // Självhostat: publik delningsendpoint i plattformstjänsten. + const res = await fetch(`${PLATTFORM_URL}/api/delad/${kod}`); + if (!res.ok) return undefined; + data = await res.json(); + } else { + const { supabase } = await import("@/integrations/supabase/client"); + const { data: rpcData, error } = await (supabase as unknown as { + rpc: (fn: string, args: object) => Promise<{ data: unknown; error: unknown }>; + }).rpc("hamta_delat_arende", { kod }); + if (error || !rpcData) return undefined; + data = rpcData; + } const svar = data as DelatSvar; return { id: svar.arende.id,