Compare commits

...

2 Commits

Author SHA1 Message Date
karim 3d902a0bbe Merge commit 'f518eb7a1394ee29b825e551e6557dd71cd95a28' 2026-06-30 01:24:44 +02:00
karim f518eb7a13 Squashed 'cms/core/' changes from f709b5d..ac7538f
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
2026-06-30 01:24:44 +02:00
14 changed files with 323 additions and 29 deletions
+3
View File
@@ -3,3 +3,6 @@ node_modules/
.env.* .env.*
*.log *.log
.DS_Store .DS_Store
# build output
admin/dist/
+37 -1
View File
@@ -5,4 +5,40 @@ import { createClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL; const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; 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();
+9 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.1.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.1.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4", "marked": "^14.1.4",
@@ -529,6 +530,12 @@
"sprintf-js": "~1.0.2" "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": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4", "marked": "^14.1.4",
+109
View File
@@ -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 <SITE_DIR>/cms-users.json):
// [{ id, email, password: <bcrypt hash>, 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);
}
+3
View File
@@ -17,6 +17,9 @@ async function verifyToken(token) {
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} }; return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
} catch { return null; } } 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); const { data, error } = await supabaseAuth.auth.getUser(token);
if (error || !data?.user) return null; if (error || !data?.user) return null;
return data.user; return data.user;
+1
View File
@@ -20,6 +20,7 @@ export const config = mod.default || mod;
config.collections ||= []; config.collections ||= [];
config.plugins ||= []; config.plugins ||= [];
config.admins ||= []; config.admins ||= [];
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
export const hasPlugin = (name) => config.plugins.includes(name); export const hasPlugin = (name) => config.plugins.includes(name);
+13 -1
View File
@@ -14,11 +14,20 @@ import users from './routes/users.js';
import stats from './routes/stats.js'; import stats from './routes/stats.js';
import system from './routes/system.js'; import system from './routes/system.js';
import history from './routes/history.js'; import history from './routes/history.js';
import authRoute from './routes/auth.js';
import { requireAuth, requireAdmin } from './auth.js'; import { requireAuth, requireAdmin } from './auth.js';
import { config } from './config.js'; import { config } from './config.js';
import { supabase } from './supabase.js';
import { manager } from './plugins.js'; import { manager } from './plugins.js';
import { runMigrations } from './migrate.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 SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist'; const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000); 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 // 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. // den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
manager.mountPublic(app); 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. // Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history); 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); app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token): // Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei. // 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
+20
View File
@@ -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;
+14 -6
View File
@@ -5,6 +5,10 @@ import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { contentStats } from '../collections.js'; import { contentStats } from '../collections.js';
import { manager } from '../plugins.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. // Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono(); const stats = new Hono();
@@ -16,12 +20,16 @@ stats.get('/', async (c) => {
try { content = contentStats(await listEntries(), config.collections); } try { content = contentStats(await listEntries(), config.collections); }
catch { /* Filesystem nicht lesbar → leer */ } catch { /* Filesystem nicht lesbar → leer */ }
// Nutzer nach Rolle (GoTrue). // Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
const users = { total: 0, admin: 0, editor: 0, user: 0 }; let users = { total: 0, admin: 0, editor: 0, user: 0 };
try { if (config.auth === 'local') {
const { data } = await supabase.auth.admin.listUsers(); try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; } } else if (supabase) {
} catch { /* GoTrue nicht erreichbar */ } 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 // Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe. // Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
+24 -6
View File
@@ -1,15 +1,23 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { supabase } from '../supabase.js'; import { supabase } from '../supabase.js';
import { requireAdmin, roleOf } from '../auth.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 || '') const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const isLocal = () => config.auth === 'local';
const users = new Hono(); const users = new Hono();
users.use('*', requireAdmin); users.use('*', requireAdmin);
users.get('/', async (c) => { 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(); const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500); if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => { const list = (data?.users || []).map((u) => {
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
const { email, password, role } = await c.req.json(); const { email, password, role } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400); 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 (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 }; const payload = { email, password, email_confirm: true };
if (role && role !== 'user') payload.app_metadata = { role }; if (role && role !== 'user') payload.app_metadata = { role };
const { data, error } = await supabase.auth.admin.createUser(payload); const { data, error } = await supabase.auth.admin.createUser(payload);
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
users.put('/:id', async (c) => { users.put('/:id', async (c) => {
const { password, role } = await c.req.json(); 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 = {}; const patch = {};
if (password) patch.password = password; if (password) patch.password = password;
if (role) { if (role) patch.app_metadata = { 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);
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch); const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
if (error) return c.json({ error: error.message }, 400); if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
users.delete('/:id', async (c) => { 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')); const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
if (error) return c.json({ error: error.message }, 400); if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true }); return c.json({ ok: true });
+21 -13
View File
@@ -3,19 +3,27 @@ import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL; const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY; 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 } }; const opts = { auth: { persistSession: false, autoRefreshToken: false } };
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…). // Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den // null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN), // Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern. // für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
export const supabase = createClient(url, key, opts); // 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 export const supabase = _supabase;
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend. export const supabaseAuth = _supabaseAuth;
export const supabaseAuth = createClient(url, key, opts);
+67
View File
@@ -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/);
});
+1
View File
@@ -2,6 +2,7 @@
// No dialog plugin; a portfolio-shaped content model. // No dialog plugin; a portfolio-shaped content model.
export default { export default {
site: 'kgva', site: 'kgva',
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
admins: ['karim@gabrielevarano.ch'], admins: ['karim@gabrielevarano.ch'],
plugins: [], plugins: [],