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:
@@ -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");
|
||||
|
||||
+19
-6
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
return authAnrop("/api/auth/logga-in", { epost, losenord });
|
||||
}
|
||||
|
||||
export function registreraPlattform(epost: string, losenord: string, namn: string): Promise<string> {
|
||||
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<Response> {
|
||||
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;
|
||||
}
|
||||
@@ -45,7 +45,50 @@ async function klient(): Promise<SupabaseClient | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
// Självhostat läge: samma konfliktfria flätning, men mot plattformstjänsten.
|
||||
async function synkroniseraMotPlattform(arende: Arende): Promise<SynkResultat> {
|
||||
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<SynkResultat> {
|
||||
const { plattformAktiv } = await import("./plattform");
|
||||
if (plattformAktiv()) return synkroniseraMotPlattform(arende);
|
||||
|
||||
const supabase = await klient();
|
||||
if (!supabase) return { status: "lokal" };
|
||||
try {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user