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:
@@ -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." });
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
Generated
+161
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user