From f518eb7a1394ee29b825e551e6557dd71cd95a28 Mon Sep 17 00:00:00 2001 From: Karim Gabriele Varano Date: Tue, 30 Jun 2026 01:24:44 +0200 Subject: [PATCH] Squashed 'cms/core/' changes from f709b5d..ac7538f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ac7538f chore: gitignore admin/dist build output 86f9f57 core: stage 7 — auth provider (config.auth: supabase | local), DB-less core git-subtree-dir: cms/core git-subtree-split: ac7538fa0c2c883e29fe67c8d8c15c5f40fa2230 --- .gitignore | 3 + admin/src/supabase.js | 38 ++++++++++++- api/package-lock.json | 11 +++- api/package.json | 1 + api/src/auth-local.js | 109 ++++++++++++++++++++++++++++++++++++ api/src/auth.js | 3 + api/src/config.js | 1 + api/src/index.js | 14 ++++- api/src/routes/auth.js | 20 +++++++ api/src/routes/stats.js | 20 +++++-- api/src/routes/users.js | 30 ++++++++-- api/src/supabase.js | 34 ++++++----- api/test/auth-local.test.js | 67 ++++++++++++++++++++++ examples/kgva.config.js | 1 + 14 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 api/src/auth-local.js create mode 100644 api/src/routes/auth.js create mode 100644 api/test/auth-local.test.js diff --git a/.gitignore b/.gitignore index e375bd2..15c78a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ .env.* *.log .DS_Store + +# build output +admin/dist/ diff --git a/admin/src/supabase.js b/admin/src/supabase.js index f96bb92..022050b 100644 --- a/admin/src/supabase.js +++ b/admin/src/supabase.js @@ -5,4 +5,40 @@ import { createClient } from '@supabase/supabase-js'; const url = import.meta.env.VITE_SUPABASE_URL; const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; -export const supabase = createClient(url, anonKey); +// Mit Supabase-URL: echter Client (openbureau, unverändert). Ohne (DB-loser core, +// auth: 'local', z. B. kgva): ein Shim mit DERSELBEN auth-Schnittstelle, die App/ +// api nutzen — getSession / onAuthStateChange / signInWithPassword / signOut — +// gegen /api/auth/login, Token in localStorage. So bleibt der Supabase-Pfad +// völlig unangetastet, und der lokale Pfad braucht keine Änderung an App/api. +function localAuthClient() { + const KEY = 'cms_local_session'; + const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; + let listeners = []; + const emit = (s) => listeners.forEach((cb) => { try { cb(s ? 'SIGNED_IN' : 'SIGNED_OUT', s); } catch { /* ignore */ } }); + return { + auth: { + async getSession() { return { data: { session: read() } }; }, + onAuthStateChange(cb) { + listeners.push(cb); + return { data: { subscription: { unsubscribe() { listeners = listeners.filter((l) => l !== cb); } } } }; + }, + async signInWithPassword({ email, password }) { + try { + const r = await fetch('/api/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) return { error: { message: j.error || `HTTP ${r.status}` } }; + const session = { access_token: j.access_token, user: { id: j.user.id, email: j.user.email } }; + localStorage.setItem(KEY, JSON.stringify(session)); + emit(session); + return { data: { session }, error: null }; + } catch (e) { return { error: { message: String(e.message || e) } }; } + }, + async signOut() { localStorage.removeItem(KEY); emit(null); return { error: null }; }, + }, + }; +} + +export const supabase = url ? createClient(url, anonKey) : localAuthClient(); diff --git a/api/package-lock.json b/api/package-lock.json index 600209b..2ce1bc9 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,15 +1,16 @@ { "name": "openbureau-cms-api", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openbureau-cms-api", - "version": "0.1.0", + "version": "0.6.0", "dependencies": { "@hono/node-server": "^1.13.7", "@supabase/supabase-js": "^2.47.10", + "bcryptjs": "^2.4.3", "gray-matter": "^4.0.3", "hono": "^4.6.14", "marked": "^14.1.4", @@ -529,6 +530,12 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", diff --git a/api/package.json b/api/package.json index 66ab42c..f14f80e 100644 --- a/api/package.json +++ b/api/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hono/node-server": "^1.13.7", "@supabase/supabase-js": "^2.47.10", + "bcryptjs": "^2.4.3", "gray-matter": "^4.0.3", "hono": "^4.6.14", "marked": "^14.1.4", diff --git a/api/src/auth-local.js b/api/src/auth-local.js new file mode 100644 index 0000000..0f65c93 --- /dev/null +++ b/api/src/auth-local.js @@ -0,0 +1,109 @@ +// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME +// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the +// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase +// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres): +// Node + Hugo + nginx. Activated by config.auth === 'local'. +// +// Users live in a JSON file (USERS_FILE, default /cms-users.json): +// [{ id, email, password: , role, created_at, last_sign_in_at }] +import path from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { sign } from 'hono/jwt'; + +const SITE_DIR = process.env.SITE_DIR || '/site'; +const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json'); +const JWT_SECRET = process.env.JWT_SECRET || ''; +const ROLES = ['user', 'editor', 'admin']; + +// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth +// instance never loads it. +async function bcrypt() { return (await import('bcryptjs')).default; } + +export async function loadUsers() { + try { + const j = JSON.parse(await readFile(USERS_FILE, 'utf8')); + return Array.isArray(j) ? j : (j.users || []); + } catch { return []; } +} +async function persist(users) { + await mkdir(path.dirname(USERS_FILE), { recursive: true }); + await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8'); +} + +export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); } +export async function verifyPassword(pw, hash) { + try { return await (await bcrypt()).compare(pw, hash); } catch { return false; } +} + +// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET). +export async function mintToken(user, ttl = 3600) { + if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig'); + const now = Math.floor(Date.now() / 1000); + return sign({ + sub: user.id, email: user.email, role: 'authenticated', + app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl, + }, JWT_SECRET, 'HS256'); +} + +export async function login(email, password) { + const users = await loadUsers(); + const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase()); + if (!u || !u.password) return null; + if (!(await verifyPassword(password, u.password))) return null; + u.last_sign_in_at = new Date().toISOString(); + await persist(users); + return { + access_token: await mintToken(u), + token_type: 'bearer', + user: { id: u.id, email: u.email, role: u.role || 'user' }, + }; +} + +// ── Admin CRUD (mirrors routes/users.js shapes) ────────────────────────────── +export async function listUsers(adminEmails = []) { + const users = await loadUsers(); + return users.map((u) => { + const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase()); + const role = u.role || 'user'; + return { + id: u.id, email: u.email, created_at: u.created_at || null, + last_sign_in_at: u.last_sign_in_at || null, + role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin, + }; + }); +} +export async function countByRole(adminEmails = []) { + const list = await listUsers(adminEmails); + const out = { total: list.length, admin: 0, editor: 0, user: 0 }; + for (const u of list) out[u.role] = (out[u.role] || 0) + 1; + return out; +} +export async function createUser({ email, password, role }) { + if (!email || !password) throw new Error('E-Mail und Passwort nötig'); + if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle'); + const users = await loadUsers(); + if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) { + throw new Error('E-Mail existiert bereits'); + } + const u = { + id: randomUUID(), email, password: await hashPassword(password), + role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null, + }; + users.push(u); await persist(users); + return u.id; +} +export async function updateUser(id, { password, role }) { + const users = await loadUsers(); + const u = users.find((x) => x.id === id); + if (!u) throw new Error('Nutzer:in nicht gefunden'); + if (password) u.password = await hashPassword(password); + if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; } + await persist(users); +} +export async function deleteUser(id) { + const users = await loadUsers(); + const next = users.filter((u) => u.id !== id); + if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden'); + await persist(next); +} diff --git a/api/src/auth.js b/api/src/auth.js index 3d0af75..faea1ff 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -17,6 +17,9 @@ async function verifyToken(token) { return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} }; } catch { return null; } } + // Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein + // Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen). + if (!supabaseAuth) return null; const { data, error } = await supabaseAuth.auth.getUser(token); if (error || !data?.user) return null; return data.user; diff --git a/api/src/config.js b/api/src/config.js index f502368..0624882 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -20,6 +20,7 @@ export const config = mod.default || mod; config.collections ||= []; config.plugins ||= []; config.admins ||= []; +config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less) export const hasPlugin = (name) => config.plugins.includes(name); diff --git a/api/src/index.js b/api/src/index.js index 74bfa21..08e632b 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -14,11 +14,20 @@ import users from './routes/users.js'; import stats from './routes/stats.js'; import system from './routes/system.js'; import history from './routes/history.js'; +import authRoute from './routes/auth.js'; import { requireAuth, requireAdmin } from './auth.js'; import { config } from './config.js'; +import { supabase } from './supabase.js'; import { manager } from './plugins.js'; import { runMigrations } from './migrate.js'; +// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart. +// (auth:'local' braucht keinen — DB-loser Betrieb.) +if (config.auth === 'supabase' && !supabase) { + console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase'); + process.exit(1); +} + const SITE_DIR = process.env.SITE_DIR || '/site'; const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist'; const PORT = Number(process.env.PORT || 3000); @@ -64,9 +73,12 @@ app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' })); // lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt // den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet. manager.mountPublic(app); +// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt. +// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht. +if (config.auth === 'local') app.route('/api/auth', authRoute); // Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar. app.route('/api/history', history); -// Alles weitere unter /api/* braucht ein gültiges Supabase-Token. +// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal). app.use('/api/*', requireAuth); // Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token): // 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei. diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js new file mode 100644 index 0000000..d20e22b --- /dev/null +++ b/api/src/routes/auth.js @@ -0,0 +1,20 @@ +import { Hono } from 'hono'; +import { rateLimit } from '../ratelimit.js'; +import { login } from '../auth-local.js'; + +// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges +// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet, +// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt. +const auth = new Hono(); + +auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => { + let body = {}; + try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ } + const { email, password } = body; + if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400); + const res = await login(email, password); + if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401); + return c.json(res); +}); + +export default auth; diff --git a/api/src/routes/stats.js b/api/src/routes/stats.js index 1ad2ef3..cf50bc1 100644 --- a/api/src/routes/stats.js +++ b/api/src/routes/stats.js @@ -5,6 +5,10 @@ import { requireAdmin, roleOf } from '../auth.js'; import { config } from '../config.js'; import { contentStats } from '../collections.js'; import { manager } from '../plugins.js'; +import { countByRole } from '../auth-local.js'; + +const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); // Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend. const stats = new Hono(); @@ -16,12 +20,16 @@ stats.get('/', async (c) => { try { content = contentStats(await listEntries(), config.collections); } catch { /* Filesystem nicht lesbar → leer */ } - // Nutzer nach Rolle (GoTrue). - const users = { total: 0, admin: 0, editor: 0, user: 0 }; - try { - const { data } = await supabase.auth.admin.listUsers(); - for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; } - } catch { /* GoTrue nicht erreichbar */ } + // Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue. + let users = { total: 0, admin: 0, editor: 0, user: 0 }; + if (config.auth === 'local') { + try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ } + } else if (supabase) { + try { + const { data } = await supabase.auth.admin.listUsers(); + for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; } + } catch { /* GoTrue nicht erreichbar */ } + } // Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene // Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe. diff --git a/api/src/routes/users.js b/api/src/routes/users.js index d2e025f..34292eb 100644 --- a/api/src/routes/users.js +++ b/api/src/routes/users.js @@ -1,15 +1,23 @@ import { Hono } from 'hono'; import { supabase } from '../supabase.js'; import { requireAdmin, roleOf } from '../auth.js'; +import { config } from '../config.js'; +import * as local from '../auth-local.js'; -// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins. +// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los), +// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins. const ADMINS = (process.env.ADMIN_EMAILS || '') .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); +const isLocal = () => config.auth === 'local'; const users = new Hono(); users.use('*', requireAdmin); users.get('/', async (c) => { + if (isLocal()) { + try { return c.json(await local.listUsers(ADMINS)); } + catch (e) { return c.json({ error: String(e.message || e) }, 500); } + } const { data, error } = await supabase.auth.admin.listUsers(); if (error) return c.json({ error: error.message }, 500); const list = (data?.users || []).map((u) => { @@ -32,6 +40,10 @@ users.post('/', async (c) => { const { email, password, role } = await c.req.json(); if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400); if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400); + if (isLocal()) { + try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); } + catch (e) { return c.json({ error: String(e.message || e) }, 400); } + } const payload = { email, password, email_confirm: true }; if (role && role !== 'user') payload.app_metadata = { role }; const { data, error } = await supabase.auth.admin.createUser(payload); @@ -41,19 +53,25 @@ users.post('/', async (c) => { users.put('/:id', async (c) => { const { password, role } = await c.req.json(); + if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400); + if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400); + if (isLocal()) { + try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); } + catch (e) { return c.json({ error: String(e.message || e) }, 400); } + } const patch = {}; if (password) patch.password = password; - if (role) { - if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400); - patch.app_metadata = { role }; - } - if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400); + if (role) patch.app_metadata = { role }; const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch); if (error) return c.json({ error: error.message }, 400); return c.json({ ok: true }); }); users.delete('/:id', async (c) => { + if (isLocal()) { + try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); } + catch (e) { return c.json({ error: String(e.message || e) }, 400); } + } const { error } = await supabase.auth.admin.deleteUser(c.req.param('id')); if (error) return c.json({ error: error.message }, 400); return c.json({ ok: true }); diff --git a/api/src/supabase.js b/api/src/supabase.js index dfcc9be..8a49448 100644 --- a/api/src/supabase.js +++ b/api/src/supabase.js @@ -3,19 +3,27 @@ import { createClient } from '@supabase/supabase-js'; const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_KEY; -if (!url || !key) { - console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env'); - process.exit(1); -} - const opts = { auth: { persistSession: false, autoRefreshToken: false } }; -// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…). -// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den -// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN), -// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern. -export const supabase = createClient(url, key, opts); +// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben +// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen +// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast +// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt +// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können. +let _supabase = null; +let _supabaseAuth = null; +if (url && key) { + // Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…). + // Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den + // Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN), + // wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern. + _supabase = createClient(url, key, opts); + // Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein + // signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend. + _supabaseAuth = createClient(url, key, opts); +} else { + console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).'); +} -// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein -// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend. -export const supabaseAuth = createClient(url, key, opts); +export const supabase = _supabase; +export const supabaseAuth = _supabaseAuth; diff --git a/api/test/auth-local.test.js b/api/test/auth-local.test.js new file mode 100644 index 0000000..e68fcb4 --- /dev/null +++ b/api/test/auth-local.test.js @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rm, readFile } from 'node:fs/promises'; + +// Isolated users file + a known secret BEFORE importing the module (it reads env +// at import time). No Supabase needed — this is the DB-less path. +const USERS = join(tmpdir(), `cms-users-${process.pid}.json`); +process.env.USERS_FILE = USERS; +process.env.JWT_SECRET ||= 'test-secret-xyz'; + +const local = await import('../src/auth-local.js'); +const { verify } = await import('hono/jwt'); + +test.after(async () => { await rm(USERS, { force: true }); }); + +test('create → list → login mints a GoTrue-shaped, verifiable token', async () => { + const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' }); + assert.ok(id); + const list = await local.listUsers(['someone@else.com']); + assert.equal(list.length, 1); + assert.equal(list[0].email, 'Karim@Example.com'); + assert.equal(list[0].role, 'admin'); + + // wrong password rejected, right password accepted (case-insensitive email) + assert.equal(await local.login('karim@example.com', 'nope'), null); + const res = await local.login('karim@example.com', 'hunter2'); + assert.ok(res?.access_token); + assert.equal(res.user.email, 'Karim@Example.com'); + + const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256'); + assert.equal(claims.sub, id); + assert.equal(claims.email, 'Karim@Example.com'); + assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read + assert.ok(claims.exp > Math.floor(Date.now() / 1000)); +}); + +test('passwords are bcrypt-hashed at rest (never plaintext)', async () => { + const raw = JSON.parse(await readFile(USERS, 'utf8')); + assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix + assert.notEqual(raw[0].password, 'hunter2'); +}); + +test('countByRole reflects roles; fixed-admin email forced to admin', async () => { + await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' }); + const counts = await local.countByRole([]); + assert.equal(counts.total, 2); + assert.equal(counts.admin, 1); + assert.equal(counts.editor, 1); + // an env-admin email overrides the stored role in the listing + const forced = await local.listUsers(['red@x.com']); + assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin'); +}); + +test('update + delete', async () => { + const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' }); + await local.updateUser(id, { role: 'editor' }); + assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor'); + await local.deleteUser(id); + assert.equal((await local.listUsers([])).some((u) => u.id === id), false); + await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/); +}); + +test('duplicate email rejected', async () => { + await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/); +}); diff --git a/examples/kgva.config.js b/examples/kgva.config.js index 0a59a47..0113140 100644 --- a/examples/kgva.config.js +++ b/examples/kgva.config.js @@ -2,6 +2,7 @@ // No dialog plugin; a portfolio-shaped content model. export default { site: 'kgva', + auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres) admins: ['karim@gabrielevarano.ch'], plugins: [],