Helt självhostat: plattformsbackend, Postgres i klustret, klientläge

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-03 01:41:58 +00:00
parent 03f1ef20f6
commit 7c24186cc5
20 changed files with 906 additions and 45 deletions
+89
View File
@@ -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 (
<Panel rubrik="Plattformskonto">
<div className="flex items-center justify-between gap-3">
<p className="text-lg">
Inloggad som <span className="font-extrabold">{namn}</span> synk och AI aktiva.
</p>
<button
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
onClick={() => {
loggaUtPlattform();
setNamn(null);
}}
>
Logga ut
</button>
</div>
</Panel>
);
}
return (
<Panel rubrik="Plattformskonto">
{lage === "stangd" ? (
<>
<p className="mb-2 text-zinc-300">
Logga in för synk mellan enheter, samarbete i ärenden och AI-orkestern. Utan inloggning
arbetar appen i lokalt läge.
</p>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
Skapa konto
</StorKnapp>
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
</div>
</>
) : (
<form
onSubmit={async (e) => {
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" && <TextFalt label="Namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />}
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
Avbryt
</StorKnapp>
<StorKnapp type="submit" disabled={!epost.trim() || losenord.length < 8 || (lage === "registrera" && !nyttNamn.trim())}>
{lage === "loggaIn" ? "Logga in" : "Skapa konto"}
</StorKnapp>
</div>
</form>
)}
</Panel>
);
}
// 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() {
<p className="text-center text-lg text-zinc-400">Inga ärenden i det här filtret.</p>
)}
{plattformAktiv() && <PlattformInloggning />}
<Panel rubrik="AI-orkestern">
<p className="text-zinc-300">
Ingår i tjänsten och drivs av plattformen flera Claude-modeller med olika roller: handledning i
+15 -5
View File
@@ -15,11 +15,21 @@ interface DelatSvar {
}
async function hamtaDelat(kod: string): Promise<Arende | undefined> {
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,