Vollständiges Zugangs-/Rollen-/Rechtesystem gebaut (Anforderungsdokument): D1-Datenbank, AES-verschlüsselte Codes nur für Owner einsehbar, granulare Permissions, Session-Auth mit Lockout, Zugänge-&-Teammitglieder-Verwaltungsseite, Bewerbungen mit Status/Notizen/Entwürfen

This commit is contained in:
2026-07-31 00:37:04 +02:00
parent 96087f460e
commit 6a1c0e355c
18 changed files with 2283 additions and 539 deletions
+15
View File
@@ -0,0 +1,15 @@
/* =====================================================================
audit.js — Aktivitätsprotokoll
WICHTIG: Codes dürfen hier NIEMALS im Klartext (oder auch nur
teilweise) auftauchen. `detail` ist für zusätzlichen, unkritischen
Kontext gedacht (z.B. "role: rechte-hand"), niemals für Geheimnisse.
===================================================================== */
import { nowIso } from "./crypto.js";
export async function logAction(env, actor, action, target, detail) {
await env.DB.prepare(
`INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)`
)
.bind(actor, action, target || null, detail ? JSON.stringify(detail) : null, nowIso())
.run();
}
+161
View File
@@ -0,0 +1,161 @@
/* =====================================================================
auth.js — Login, Sessions, Rate-Limiting/Lockout
Sicherheitsmodell:
- Owner-Code kommt aus dem POSTFACH_CODE-Secret (Wrangler), Vergleich
per Konstant-Zeit-String-Vergleich.
- Team-Codes: SHA-256-Hash-Lookup (users.code_hash).
- Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre —
angelehnt an Cloudflares "Protect your login"-Standardregel.
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout
30 Minuten, absolute Höchstdauer 12 Stunden.
===================================================================== */
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
import { logAction } from "./audit.js";
const MAX_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;
const INACTIVITY_MS = 30 * 60 * 1000;
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
export function clientKey(request) {
return request.headers.get("CF-Connecting-IP") || "unknown";
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
return mismatch === 0;
}
export async function isLockedOut(env, key) {
const row = await env.DB.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`)
.bind(key)
.first();
if (!row || !row.locked_until) return false;
return new Date(row.locked_until) > new Date();
}
export async function recordFailedAttempt(env, key) {
const row = await env.DB.prepare(`SELECT count FROM login_attempts WHERE key = ?`).bind(key).first();
const count = (row?.count || 0) + 1;
let lockedUntil = null;
if (count >= MAX_ATTEMPTS) {
lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
}
await env.DB.prepare(
`INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?`
)
.bind(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso())
.run();
return { count, lockedUntil };
}
export async function clearAttempts(env, key) {
await env.DB.prepare(`DELETE FROM login_attempts WHERE key = ?`).bind(key).run();
}
/** Prüft einen eingegebenen Code gegen Owner-Secret und Team-Datenbank. */
export async function verifyCode(env, code) {
if (env.POSTFACH_CODE && timingSafeEqual(code, env.POSTFACH_CODE)) {
return { ok: true, isOwner: true };
}
const hash = await sha256Hex(code);
const user = await env.DB.prepare(
`SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?`
)
.bind(hash)
.first();
if (!user) return { ok: false };
if (user.status !== "active") return { ok: false, reason: "locked" };
if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" };
return { ok: true, isOwner: false, user };
}
export async function createSession(env, { isOwner, userId }) {
const token = generateSessionToken();
const tokenHash = await sha256Hex(token);
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString();
await env.DB.prepare(
`INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`
)
.bind(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt)
.run();
return token;
}
/** Löst ein Session-Token auf, prüft Ablauf/Inaktivität, aktualisiert last_active_at. */
export async function resolveSession(env, token) {
if (!token) return null;
const tokenHash = await sha256Hex(token);
const session = await env.DB.prepare(
`SELECT * FROM sessions WHERE token_hash = ?`
)
.bind(tokenHash)
.first();
if (!session) return null;
const now = Date.now();
if (new Date(session.expires_at).getTime() < now) {
await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run();
return null;
}
if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run();
return null;
}
await env.DB.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`)
.bind(new Date().toISOString(), tokenHash)
.run();
if (session.is_owner) {
return { isOwner: true, actor: "owner" };
}
const user = await env.DB.prepare(
`SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions
FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`
)
.bind(session.user_id)
.first();
if (!user || user.status !== "active") return null;
const overridesRows = await env.DB.prepare(
`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`
)
.bind(user.id)
.all();
const overrides = {};
for (const r of overridesRows.results || []) overrides[r.permission] = !!r.allowed;
return {
isOwner: false,
actor: user.username,
userId: user.id,
name: user.name,
roleId: user.role_id,
roleName: user.role_name,
permissions: user.permissions ? JSON.parse(user.permissions) : [],
overrides,
};
}
export async function endSession(env, token) {
if (!token) return;
const tokenHash = await sha256Hex(token);
await env.DB.prepare(`DELETE FROM sessions WHERE token_hash = ?`).bind(tokenHash).run();
}
export async function endAllSessionsForUser(env, userId) {
await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId).run();
}
export async function requireSession(request, env) {
const auth = request.headers.get("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
return resolveSession(env, token);
}
+78
View File
@@ -0,0 +1,78 @@
/* =====================================================================
crypto.js — Verschlüsselung & Hashing für das Zugangssystem
Codes (Zugangscodes von Teammitgliedern) brauchen ZWEI Repräsentationen:
1) code_hash — SHA-256, nicht umkehrbar. Für den schnellen Login-
Vergleich ("stimmt der eingegebene Code mit dem gespeicherten
überein?"). Kann NIE zurück in den Klartext-Code verwandelt werden.
2) code_enc — AES-256-GCM, umkehrbar. Nur damit kann der
Hauptadministrator sich den Klartext-Code später wieder anzeigen
lassen ("Code anzeigen"-Funktion aus dem Anforderungsdokument).
Der Schlüssel dafür liegt NUR im ENCRYPTION_KEY-Secret (nie im
Quelltext, nie in der Datenbank).
Session-Tokens werden nur GEHASHT in der Datenbank gespeichert (wie ein
Passwort) — der Klartext-Token existiert nur kurz beim Login-Response
und im Browser des jeweiligen Geräts.
===================================================================== */
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1
export function generateFriendlyCode() {
const bytes = crypto.getRandomValues(new Uint8Array(12));
let raw = "";
for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length];
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
}
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export function generateId() {
return crypto.randomUUID();
}
export async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function getAesKey(env) {
const raw = Uint8Array.from(atob(env.ENCRYPTION_KEY), (c) => c.charCodeAt(0));
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
}
function bufToBase64(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function base64ToBuf(b64) {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */
export async function encryptCode(env, plaintext) {
const key = await getAesKey(env);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext)
);
return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`;
}
/** Entschlüsselt einen mit encryptCode() erzeugten Wert. Nur intern für den Owner-Reveal-Flow nutzen. */
export async function decryptCode(env, encoded) {
const [ivB64, ctB64] = encoded.split(".");
const key = await getAesKey(env);
const iv = base64ToBuf(ivB64);
const ciphertext = base64ToBuf(ctB64);
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plainBuf);
}
export function nowIso() {
return new Date().toISOString();
}
+19
View File
@@ -0,0 +1,19 @@
/* =====================================================================
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
===================================================================== */
export function corsHeaders() {
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders() },
});
}
+96
View File
@@ -0,0 +1,96 @@
/* =====================================================================
permissions.js — Vollständiger Permission-Katalog
Direkt aus dem Anforderungsdokument "Geschützter Zugang, Rollen und
Benutzerverwaltung" übernommen. Diese Keys sind die einzigen, die in
Rollen oder Personen-Ausnahmen aktiviert werden dürfen.
OWNER_ONLY_ACTIONS sind bewusst NICHT Teil von PERMISSIONS — sie
erscheinen nirgends in der Rechteverwaltung und können über keinen
Schalter an irgendeine Rolle oder Person vergeben werden. Jeder Ort im
Code, der eine dieser Aktionen ausführt, muss den Owner direkt prüfen
(isOwner === true), NIE eine Permission.
===================================================================== */
export const PERMISSIONS = {
// Bewerbungsbereiche
APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen",
APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen",
APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen",
APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen",
// Bearbeitung
APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen",
APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen",
APPLICATIONS_CHANGE_STATUS: "Status verändern",
APPLICATIONS_ARCHIVE: "Bewerbungen archivieren",
APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen",
APPLICATIONS_DELETE: "Bewerbungen löschen",
// Antworten
DRAFTS_CREATE: "Antwortentwürfe erstellen",
DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten",
DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten",
REPLIES_COPY: "Fertige Antworten kopieren",
EMAILS_COPY: "E-Mail-Adressen kopieren",
APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren",
// Interne Notizen
NOTES_READ: "Notizen lesen",
NOTES_WRITE: "Notizen schreiben",
NOTES_EDIT_OWN: "Eigene Notizen bearbeiten",
NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten",
NOTES_DELETE: "Notizen löschen",
// Teamverwaltung
TEAM_VIEW: "Andere Teammitglieder ansehen",
TEAM_PREPARE_USER: "Neue Benutzer vorbereiten",
TEAM_LOCK_USER: "Benutzer sperren",
TEAM_ASSIGN_ROLE: "Rollen vergeben",
TEAM_CREATE_ROLE: "Rollen erstellen",
TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern",
// Einstellungen
SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten",
SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern",
SETTINGS_VIEW_STATS: "Statistiken ansehen",
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
};
export const PERMISSION_GROUPS = [
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
{ label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] },
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
];
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
// wählbar und werden im Code immer direkt über isOwner geprüft:
export const OWNER_ONLY_ACTIONS = [
"CODE_VIEW",
"CODE_COPY",
"CODE_CREATE",
"CODE_EDIT",
"CODE_RESET",
"EDIT_OWNER_ACCOUNT",
"DELETE_OR_LIMIT_OWNER_ROLE",
"PROMOTE_TO_OWNER",
];
/**
* Prüft, ob eine Person (Rolle + individuelle Ausnahmen) ein Recht hat.
* Individuelle Ausnahmen (overrides) haben immer Vorrang vor der Rolle.
*/
export function hasPermission(rolePermissions, overrides, key) {
if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) {
return !!overrides[key];
}
return Array.isArray(rolePermissions) && rolePermissions.includes(key);
}
export function isValidPermissionKey(key) {
return Object.prototype.hasOwnProperty.call(PERMISSIONS, key);
}
@@ -0,0 +1,218 @@
/* =====================================================================
routes/applications.js — Bewerbungen (Postfach), jetzt in D1 mit
vollem Rollen-/Rechte-Modell statt dem alten pauschalen Owner/Helfer-
Zugriff. Status, Zuweisung, Notizen und Antwortentwürfe pro Bewerbung.
===================================================================== */
import { generateId, nowIso } from "../lib/crypto.js";
import { hasPermission } from "../lib/permissions.js";
import { logAction } from "../lib/audit.js";
import { json } from "../lib/http.js";
const ALLOWED_TYPES = ["creator", "scout", "kooperation"];
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
function viewPermissionForType(type) {
return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT";
}
export async function submitApplication(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
const { type, data } = body || {};
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
return json({ ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
}
const values = Object.values(data).map((v) => String(v || "").trim());
if (values.every((v) => v === "")) return json({ ok: false, error: "Leere Bewerbung." }, 400);
const id = generateId();
await env.DB.prepare(
`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`
)
.bind(id, type, JSON.stringify(data), nowIso())
.run();
return json({ ok: true });
}
export async function listApplications(request, env, session) {
if (!session) return json({ ok: false, error: "Nicht angemeldet." }, 401);
const body = await request.json().catch(() => ({}));
const includeArchived = !!body.includeArchived;
const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t)));
if (allowedTypes.length === 0) return json({ ok: true, applications: [] });
if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) {
return json({ ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403);
}
const placeholders = allowedTypes.map(() => "?").join(",");
const archivedClause = includeArchived ? "" : "AND archived = 0";
const rows = await env.DB.prepare(
`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`
)
.bind(...allowedTypes)
.all();
const applications = (rows.results || []).map((r) => ({
id: r.id,
type: r.type,
data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null,
status: r.status,
assignedTo: r.assigned_to,
archived: !!r.archived,
createdAt: r.created_at,
}));
return json({ ok: true, applications });
}
export async function changeApplicationStatus(request, env, session) {
if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const status = String(body.status || "").slice(0, 40);
await env.DB.prepare(`UPDATE applications SET status = ? WHERE id = ?`).bind(status, id).run();
await logAction(env, session.actor, "application.status", id, { status });
return json({ ok: true });
}
export async function takeOverApplication(request, env, session) {
if (!can(session, "APPLICATIONS_TAKE_OVER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const who = session.isOwner ? "owner" : session.userId;
await env.DB.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).bind(who, id).run();
await logAction(env, session.actor, "application.take_over", id, null);
return json({ ok: true });
}
export async function assignApplication(request, env, session) {
if (!can(session, "APPLICATIONS_ASSIGN")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const assignedTo = body.assignedTo ? String(body.assignedTo) : null;
await env.DB.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).bind(assignedTo, id).run();
await logAction(env, session.actor, "application.assign", id, { assignedTo });
return json({ ok: true });
}
export async function archiveApplication(request, env, session) {
if (!can(session, "APPLICATIONS_ARCHIVE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "application.archive", id, null);
return json({ ok: true });
}
export async function restoreApplication(request, env, session) {
if (!can(session, "APPLICATIONS_RESTORE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "application.restore", id, null);
return json({ ok: true });
}
export async function deleteApplication(request, env, session) {
if (!can(session, "APPLICATIONS_DELETE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`DELETE FROM application_notes WHERE application_id = ?`).bind(id).run();
await env.DB.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).bind(id).run();
await env.DB.prepare(`DELETE FROM applications WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "application.delete", id, null);
return json({ ok: true });
}
export async function markAnswered(request, env, session) {
if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "application.mark_answered", id, null);
return json({ ok: true });
}
/* ---- Notizen ---- */
export async function listNotes(request, env, session) {
if (!can(session, "NOTES_READ")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const rows = await env.DB.prepare(
`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`
)
.bind(String(body.applicationId || ""))
.all();
return json({ ok: true, notes: rows.results || [] });
}
export async function addNote(request, env, session) {
if (!can(session, "NOTES_WRITE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const text = String(body.text || "").slice(0, 2000);
if (!text.trim()) return json({ ok: false, error: "Leere Notiz." }, 400);
await env.DB.prepare(
`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`
)
.bind(String(body.applicationId || ""), session.actor, text, nowIso())
.run();
await logAction(env, session.actor, "note.add", body.applicationId, null);
return json({ ok: true });
}
export async function deleteNote(request, env, session) {
if (!can(session, "NOTES_DELETE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
await env.DB.prepare(`DELETE FROM application_notes WHERE id = ?`).bind(Number(body.id)).run();
await logAction(env, session.actor, "note.delete", String(body.id), null);
return json({ ok: true });
}
/* ---- Antwortentwürfe ---- */
export async function listDrafts(request, env, session) {
const body = await request.json().catch(() => ({}));
const rows = await env.DB.prepare(
`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`
)
.bind(String(body.applicationId || ""))
.all();
return json({ ok: true, drafts: rows.results || [] });
}
export async function saveDraft(request, env, session) {
const body = await request.json().catch(() => ({}));
const content = String(body.content || "").slice(0, 5000);
if (body.id) {
const existing = await env.DB.prepare(`SELECT * FROM application_drafts WHERE id = ?`).bind(Number(body.id)).first();
if (!existing) return json({ ok: false, error: "Entwurf nicht gefunden." }, 404);
const isOwnDraft = existing.author === session.actor;
if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) {
return json({ ok: false, error: "Keine Berechtigung." }, 403);
}
await env.DB.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`)
.bind(content, nowIso(), Number(body.id))
.run();
await logAction(env, session.actor, "draft.update", String(body.id), null);
return json({ ok: true });
}
if (!can(session, "DRAFTS_CREATE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
await env.DB.prepare(
`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`
)
.bind(String(body.applicationId || ""), session.actor, content, nowIso(), nowIso())
.run();
await logAction(env, session.actor, "draft.create", body.applicationId, null);
return json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
/* =====================================================================
routes/audit.js — Aktivitätsprotokoll ansehen
===================================================================== */
import { hasPermission } from "../lib/permissions.js";
import { json } from "../lib/http.js";
export async function listAuditLog(request, env, session) {
if (!session) return json({ ok: false, error: "Nicht angemeldet." }, 401);
if (!session.isOwner && !hasPermission(session.permissions, session.overrides, "SETTINGS_VIEW_ACTIVITY_LOG")) {
return json({ ok: false, error: "Keine Berechtigung." }, 403);
}
const body = await request.json().catch(() => ({}));
const limit = Math.min(Number(body.limit) || 100, 500);
const rows = await env.DB.prepare(
`SELECT id, actor, action, target, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?`
)
.bind(limit)
.all();
return json({ ok: true, entries: rows.results || [] });
}
+69
View File
@@ -0,0 +1,69 @@
/* =====================================================================
routes/auth.js — Login / Logout
===================================================================== */
import { verifyCode, createSession, endSession, isLockedOut, recordFailedAttempt, clearAttempts, clientKey } from "../lib/auth.js";
import { logAction } from "../lib/audit.js";
import { nowIso } from "../lib/crypto.js";
import { json } from "../lib/http.js";
export async function login(request, env) {
const body = await request.json().catch(() => ({}));
const code = String(body.code || "");
const key = `login:${clientKey(request)}`;
if (await isLockedOut(env, key)) {
return json({ ok: false, error: "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen." }, 429);
}
const result = await verifyCode(env, code);
if (!result.ok) {
const { count } = await recordFailedAttempt(env, key);
await logAction(env, "anonym", "login.failed", null, { attempt: count });
if (result.reason === "locked") return json({ ok: false, error: "Dieser Zugang ist gesperrt." }, 403);
if (result.reason === "expired") return json({ ok: false, error: "Dieser Zugang ist abgelaufen." }, 403);
return json({ ok: false, error: "Falscher Code." }, 401);
}
await clearAttempts(env, key);
if (result.isOwner) {
const token = await createSession(env, { isOwner: true });
await logAction(env, "owner", "login.success", null, null);
return json({ ok: true, token, isOwner: true, name: "Dogi", roleName: "Hauptadministrator" });
}
const token = await createSession(env, { isOwner: false, userId: result.user.id });
await env.DB.prepare(`UPDATE users SET last_login_at = ? WHERE id = ?`).bind(nowIso(), result.user.id).run();
const roleRow = result.user.role_id
? await env.DB.prepare(`SELECT name, permissions FROM roles WHERE id = ?`).bind(result.user.role_id).first()
: null;
const overridesRows = await env.DB.prepare(
`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`
)
.bind(result.user.id)
.all();
const overrides = {};
for (const r of overridesRows.results || []) overrides[r.permission] = !!r.allowed;
const permissions = roleRow ? JSON.parse(roleRow.permissions || "[]") : [];
await logAction(env, result.user.username, "login.success", null, null);
return json({
ok: true,
token,
isOwner: false,
name: result.user.name,
username: result.user.username,
roleName: roleRow?.name || "Ohne Rolle",
permissions,
overrides,
});
}
export async function logout(request, env) {
const auth = request.headers.get("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
await endSession(env, token);
return json({ ok: true });
}
+95
View File
@@ -0,0 +1,95 @@
/* =====================================================================
routes/roles.js — Rollen erstellen/umbenennen/verwalten
Die Owner-Rolle existiert absichtlich NICHT in dieser Tabelle — sie ist
fest an POSTFACH_CODE gebunden und kann hier weder erstellt, verändert
noch gelöscht werden (siehe Anforderungsdokument: "Meine persönliche
Hauptadministrator-Rolle bleibt davon ausgeschlossen").
===================================================================== */
import { generateId, nowIso } from "../lib/crypto.js";
import { isValidPermissionKey, hasPermission } from "../lib/permissions.js";
import { logAction } from "../lib/audit.js";
import { json } from "../lib/http.js";
function canManageRoles(session) {
if (!session) return false;
if (session.isOwner) return true;
return (
hasPermission(session.permissions, session.overrides, "TEAM_CREATE_ROLE") ||
hasPermission(session.permissions, session.overrides, "TEAM_EDIT_PERMISSIONS")
);
}
function canViewRoles(session) {
if (!session) return false;
if (session.isOwner) return true;
return (
canManageRoles(session) ||
hasPermission(session.permissions, session.overrides, "TEAM_ASSIGN_ROLE") ||
hasPermission(session.permissions, session.overrides, "TEAM_VIEW")
);
}
function sanitizePermissions(list) {
if (!Array.isArray(list)) return [];
return [...new Set(list.filter((k) => typeof k === "string" && isValidPermissionKey(k)))];
}
export async function listRoles(env, session) {
if (!canViewRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const rows = await env.DB.prepare(`SELECT * FROM roles ORDER BY created_at ASC`).all();
const roles = (rows.results || []).map((r) => ({ ...r, permissions: JSON.parse(r.permissions || "[]") }));
return json({ ok: true, roles });
}
export async function createRole(request, env, session) {
if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const name = String(body.name || "").trim().slice(0, 60);
if (!name) return json({ ok: false, error: "Name fehlt." }, 400);
const permissions = sanitizePermissions(body.permissions);
const id = generateId();
await env.DB.prepare(`INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)`)
.bind(id, name, JSON.stringify(permissions), nowIso())
.run();
await logAction(env, session.actor, "role.create", id, { name, permissions });
return json({ ok: true, role: { id, name, permissions } });
}
export async function updateRole(request, env, session) {
if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
if (!id) return json({ ok: false, error: "id fehlt." }, 400);
const existing = await env.DB.prepare(`SELECT * FROM roles WHERE id = ?`).bind(id).first();
if (!existing) return json({ ok: false, error: "Rolle nicht gefunden." }, 404);
const name = body.name !== undefined ? String(body.name).trim().slice(0, 60) || existing.name : existing.name;
const permissions = body.permissions !== undefined ? sanitizePermissions(body.permissions) : JSON.parse(existing.permissions || "[]");
await env.DB.prepare(`UPDATE roles SET name = ?, permissions = ? WHERE id = ?`)
.bind(name, JSON.stringify(permissions), id)
.run();
await logAction(env, session.actor, "role.update", id, { name, permissions });
return json({ ok: true });
}
export async function deleteRole(request, env, session) {
if (!canManageRoles(session)) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
if (!id) return json({ ok: false, error: "id fehlt." }, 400);
const inUse = await env.DB.prepare(`SELECT COUNT(*) as n FROM users WHERE role_id = ?`).bind(id).first();
if (inUse && inUse.n > 0) {
return json({ ok: false, error: "Rolle ist noch Personen zugewiesen — erst umziehen." }, 400);
}
await env.DB.prepare(`DELETE FROM roles WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "role.delete", id, null);
return json({ ok: true });
}
+269
View File
@@ -0,0 +1,269 @@
/* =====================================================================
routes/users.js — Zugänge und Teammitglieder
Zentrale Regel aus dem Anforderungsdokument, überall durchgesetzt:
AUSSCHLIESSLICH der Owner darf Codes sehen, erstellen, ändern,
kopieren oder zurücksetzen. Keine Rolle und keine individuelle
Berechtigung kann das jemals freischalten — die entsprechenden
Funktionen prüfen `session.isOwner` direkt, nie eine Permission.
===================================================================== */
import { generateId, generateFriendlyCode, sha256Hex, encryptCode, decryptCode, nowIso } from "../lib/crypto.js";
import { hasPermission, isValidPermissionKey } from "../lib/permissions.js";
import { endAllSessionsForUser, verifyCode } from "../lib/auth.js";
import { logAction } from "../lib/audit.js";
import { json } from "../lib/http.js";
function can(session, key) {
if (!session) return false;
if (session.isOwner) return true;
return hasPermission(session.permissions, session.overrides, key);
}
async function loadUser(env, id) {
return env.DB.prepare(
`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`
)
.bind(id)
.first();
}
function publicUser(u) {
return {
id: u.id,
name: u.name,
username: u.username,
email: u.email,
roleId: u.role_id,
roleName: u.role_name,
status: u.status,
note: u.note,
expiresAt: u.expires_at,
createdAt: u.created_at,
createdBy: u.created_by,
lastLoginAt: u.last_login_at,
// Codes erscheinen hier bewusst NIE — auch nicht verschlüsselt/gehasht.
};
}
export async function listUsers(env, session) {
if (!can(session, "TEAM_VIEW") && !session?.isOwner) {
return json({ ok: false, error: "Keine Berechtigung." }, 403);
}
const rows = await env.DB.prepare(
`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.created_at DESC`
).all();
return json({ ok: true, users: (rows.results || []).map(publicUser) });
}
/** Nicht-Owner mit TEAM_PREPARE_USER können einen Entwurf anlegen — noch ohne Code, inaktiv. */
export async function prepareUser(request, env, session) {
if (!can(session, "TEAM_PREPARE_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const name = String(body.name || "").trim().slice(0, 80);
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
if (!name || !username) return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
const id = generateId();
try {
await env.DB.prepare(
`INSERT INTO users (id, name, username, email, role_id, status, note, created_at, created_by)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`
)
.bind(id, name, username, String(body.email || "").trim() || null, body.roleId || null, String(body.note || "").slice(0, 500) || null, nowIso(), session.actor)
.run();
} catch {
return json({ ok: false, error: "Benutzername bereits vergeben." }, 400);
}
await logAction(env, session.actor, "user.prepare", id, { username });
return json({ ok: true, id });
}
/** NUR Owner: legt einen Zugang final an (neu oder aus einem vorbereiteten Entwurf) inkl. Code. */
export async function finalizeUser(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge anlegen/aktivieren." }, 403);
const body = await request.json().catch(() => ({}));
let id = body.pendingUserId ? String(body.pendingUserId) : null;
const name = String(body.name || "").trim().slice(0, 80);
const username = String(body.username || "").trim().toLowerCase().slice(0, 40);
const email = String(body.email || "").trim() || null;
const roleId = body.roleId || null;
const note = String(body.note || "").slice(0, 500) || null;
if (!id && (!name || !username)) {
return json({ ok: false, error: "Name und Benutzername sind Pflicht." }, 400);
}
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
const codeHash = await sha256Hex(code);
const codeEnc = await encryptCode(env, code);
let expiresAt = null;
if (body.expiresAt) expiresAt = new Date(body.expiresAt).toISOString();
else if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
if (id) {
const existing = await loadUser(env, id);
if (!existing) return json({ ok: false, error: "Vorbereiteter Benutzer nicht gefunden." }, 404);
await env.DB.prepare(
`UPDATE users SET name = ?, username = ?, email = ?, role_id = ?, code_hash = ?, code_enc = ?,
status = 'active', note = ?, expires_at = ? WHERE id = ?`
)
.bind(name || existing.name, username || existing.username, email, roleId, codeHash, codeEnc, note, expiresAt, id)
.run();
} else {
id = generateId();
try {
await env.DB.prepare(
`INSERT INTO users (id, name, username, email, role_id, code_hash, code_enc, status, note, expires_at, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`
)
.bind(id, name, username, email, roleId, codeHash, codeEnc, note, expiresAt, nowIso(), session.actor)
.run();
} catch {
return json({ ok: false, error: "Benutzername bereits vergeben." }, 400);
}
}
await logAction(env, session.actor, "user.finalize", id, { username: username || undefined });
// Der Klartext-Code wird JETZT einmalig zurückgegeben — danach nie wieder abrufbar,
// außer über den geschützten "Code anzeigen"-Reveal-Flow (siehe revealCode()).
return json({ ok: true, id, code });
}
/** NUR Owner: neuen Code für bestehenden Benutzer setzen (manuell oder automatisch). */
export async function setUserCode(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ändern." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const user = await loadUser(env, id);
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode();
const codeHash = await sha256Hex(code);
const codeEnc = await encryptCode(env, code);
await env.DB.prepare(`UPDATE users SET code_hash = ?, code_enc = ? WHERE id = ?`)
.bind(codeHash, codeEnc, id)
.run();
await endAllSessionsForUser(env, id); // alter Code darf auf keinem Gerät mehr gültig bleiben
await logAction(env, session.actor, "user.code_reset", id, null);
return json({ ok: true, code });
}
/** NUR Owner, mit erneuter Re-Auth (ownerCode): zeigt einen bestehenden Code im Klartext. */
export async function revealUserCode(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Codes ansehen." }, 403);
const body = await request.json().catch(() => ({}));
const reAuth = await verifyCode(env, String(body.reAuthCode || ""));
if (!reAuth.ok || !reAuth.isOwner) {
return json({ ok: false, error: "Re-Authentifizierung fehlgeschlagen." }, 401);
}
const id = String(body.id || "");
const user = await loadUser(env, id);
if (!user || !user.code_enc) return json({ ok: false, error: "Kein Code hinterlegt." }, 404);
const code = await decryptCode(env, user.code_enc);
await logAction(env, session.actor, "user.code_reveal", id, null);
return json({ ok: true, code });
}
export async function lockUser(request, env, session) {
if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`UPDATE users SET status = 'locked' WHERE id = ?`).bind(id).run();
await endAllSessionsForUser(env, id);
await logAction(env, session.actor, "user.lock", id, null);
return json({ ok: true });
}
export async function unlockUser(request, env, session) {
if (!can(session, "TEAM_LOCK_USER")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const user = await loadUser(env, id);
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
if (user.expires_at && new Date(user.expires_at) < new Date()) {
return json({ ok: false, error: "Zugang ist abgelaufen — bitte zuerst Gültigkeit verlängern." }, 400);
}
await env.DB.prepare(`UPDATE users SET status = 'active' WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "user.unlock", id, null);
return json({ ok: true });
}
/** NUR Owner: Zugang vollständig löschen. */
export async function deleteUser(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Zugänge löschen." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run();
await env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(id).run();
await env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(id).run();
await logAction(env, session.actor, "user.delete", id, null);
return json({ ok: true });
}
export async function setUserRole(request, env, session) {
if (!can(session, "TEAM_ASSIGN_ROLE")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const roleId = body.roleId || null;
await env.DB.prepare(`UPDATE users SET role_id = ? WHERE id = ?`).bind(roleId, id).run();
await logAction(env, session.actor, "user.set_role", id, { roleId });
return json({ ok: true });
}
export async function setUserPermissions(request, env, session) {
if (!can(session, "TEAM_EDIT_PERMISSIONS")) return json({ ok: false, error: "Keine Berechtigung." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const overrides = body.overrides && typeof body.overrides === "object" ? body.overrides : {};
await env.DB.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).bind(id).run();
for (const [key, allowed] of Object.entries(overrides)) {
if (!isValidPermissionKey(key)) continue; // Owner-only-Aktionen können hier nie landen
await env.DB.prepare(
`INSERT INTO user_permission_overrides (user_id, permission, allowed) VALUES (?, ?, ?)`
)
.bind(id, key, allowed ? 1 : 0)
.run();
}
await logAction(env, session.actor, "user.set_permissions", id, { overrides });
return json({ ok: true });
}
/** NUR Owner: alle Sitzungen einer Person sofort beenden ("alle Geräte abmelden"). */
export async function endUserSessions(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf Sitzungen beenden." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
await endAllSessionsForUser(env, id);
await logAction(env, session.actor, "user.end_sessions", id, null);
return json({ ok: true });
}
export async function updateUserMeta(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur der Hauptadministrator darf diese Angaben ändern." }, 403);
const body = await request.json().catch(() => ({}));
const id = String(body.id || "");
const user = await loadUser(env, id);
if (!user) return json({ ok: false, error: "Benutzer nicht gefunden." }, 404);
const name = body.name !== undefined ? String(body.name).trim().slice(0, 80) || user.name : user.name;
const email = body.email !== undefined ? String(body.email).trim() || null : user.email;
const note = body.note !== undefined ? String(body.note).slice(0, 500) || null : user.note;
let expiresAt = user.expires_at;
if (body.expiresAt !== undefined) expiresAt = body.expiresAt ? new Date(body.expiresAt).toISOString() : null;
if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString();
await env.DB.prepare(`UPDATE users SET name = ?, email = ?, note = ?, expires_at = ? WHERE id = ?`)
.bind(name, email, note, expiresAt, id)
.run();
await logAction(env, session.actor, "user.update_meta", id, null);
return json({ ok: true });
}
+94 -249
View File
@@ -1,285 +1,130 @@
/* =====================================================================
DOGFATHER UNIVERSE — Bewerbungs-Postfach Worker
Nimmt Bewerbungen von bewerben.html entgegen, speichert sie in KV,
und gibt sie nur nach korrektem Code (serverseitig geprüft) an
postfach.html zurück.
DOGFATHER UNIVERSE — Interner Bereich Worker
Zugriffs-Modell (wichtig für's Verständnis):
- Owner-Code (POSTFACH_CODE, Secret) = Dogi selbst. Sieht alle
Bewerbungen UND kann Helfer-Codes mit Rollen (z.B. "Rechte Hand",
"Linke Hand") anlegen/entziehen.
- Helfer-Codes (von Dogi vergeben) = sehen NUR die Bewerbungen.
Sie können KEINE neuen Codes anlegen, KEINE Rollen verwalten,
und haben KEINERLEI Möglichkeit, irgendetwas an der Website
selbst zu verändern oder zu löschen — dafür gibt es in diesem
Worker schlicht keine Funktion. Er kann ausschließlich
Bewerbungen entgegennehmen und (mit gültigem Code) anzeigen.
- Alle Codes werden nur als SHA-256-Hash in KV gespeichert, nie im
Klartext. Ein neu erzeugter Code wird genau einmal zurückgegeben
(beim Erstellen) — danach ist er nicht mehr abrufbar.
Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um:
Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung
NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit
granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll,
Login-Lockout nach Fehlversuchen.
Endpoints:
POST /submit { type, data } -> Bewerbung speichern
POST /postfach { code } -> Bewerbungen ansehen
POST /admin/roles/list { ownerCode } -> Helfer-Codes auflisten (nur Owner)
POST /admin/roles/create { ownerCode, role, label } -> neuen Helfer-Code erzeugen (nur Owner)
POST /admin/roles/revoke { ownerCode, keyId } -> Helfer-Code entziehen (nur Owner)
GET /live-status -> öffentlich: {live, updatedAt}
POST /live-status/set { ownerCode, live } -> Live-Status umschalten (nur Owner)
GET /health -> einfacher Check ob der Worker läuft
Architektur:
src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting
src/lib/permissions.js Vollständiger Permission-Katalog
src/lib/audit.js Aktivitätsprotokoll schreiben
src/lib/http.js CORS/JSON-Helfer
src/routes/*.js Endpoint-Handler pro Bereich
Secrets/Bindings (siehe wrangler.toml + README.md):
KV Binding: BEWERBUNGEN (Cloudflare KV Namespace, wird auch für Rollen genutzt)
Secret: POSTFACH_CODE (mit `wrangler secret put POSTFACH_CODE` setzen)
Bindings (siehe wrangler.toml):
D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen)
KV: BEWERBUNGEN (nur noch für den Live-Status-Schalter genutzt)
Secret: POSTFACH_CODE (Owner-Code, mit `wrangler secret put` gesetzt)
Secret: ENCRYPTION_KEY (AES-256-Schlüssel, base64, für Code-Verschlüsselung)
===================================================================== */
import { json, corsHeaders } from "./lib/http.js";
import { requireSession } from "./lib/auth.js";
import { login, logout } from "./routes/auth.js";
import {
listRoles, createRole, updateRole, deleteRole,
} from "./routes/roles.js";
import {
listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode,
lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions,
endUserSessions, updateUserMeta,
} from "./routes/users.js";
import {
submitApplication, listApplications, changeApplicationStatus,
takeOverApplication, assignApplication, archiveApplication,
restoreApplication, deleteApplication, markAnswered,
listNotes, addNote, deleteNote, listDrafts, saveDraft,
} from "./routes/applications.js";
import { listAuditLog } from "./routes/audit.js";
const ALLOWED_TYPES = ["creator", "scout", "kooperation"];
const ROLE_KEY_PREFIX = "role:";
const LIVE_STATUS_KEY = "meta:live";
function corsHeaders() {
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen
// (z.B. "https://dogfather-universe.example"), damit nur die eigene Seite
// an die API senden kann.
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...corsHeaders() },
});
}
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function generateFriendlyCode() {
// z.B. "K3F9-QX2L-7HRT" — leicht abzutippen/vorzulesen, ausreichend Entropie
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne verwechselbare Zeichen (I,O,0,1)
const bytes = crypto.getRandomValues(new Uint8Array(12));
let raw = "";
for (const b of bytes) raw += alphabet[b % alphabet.length];
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
}
async function isOwnerCode(code, env) {
return !!env.POSTFACH_CODE && code === env.POSTFACH_CODE;
}
async function findHelperRole(code, env) {
const hash = await sha256Hex(code);
const raw = await env.BEWERBUNGEN.get(ROLE_KEY_PREFIX + hash);
return raw ? { keyId: ROLE_KEY_PREFIX + hash, ...JSON.parse(raw) } : null;
}
async function handleSubmit(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
const { type, data } = body || {};
if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) {
return json({ ok: false, error: "Ungültige Bewerbungsdaten." }, 400);
}
const values = Object.values(data).map((v) => String(v || "").trim());
if (values.every((v) => v === "")) {
return json({ ok: false, error: "Leere Bewerbung." }, 400);
}
const id = crypto.randomUUID();
const timestamp = new Date().toISOString();
const key = `sub:${timestamp}:${id}`;
await env.BEWERBUNGEN.put(key, JSON.stringify({ id, type, data, timestamp }));
return json({ ok: true });
}
async function handlePostfach(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
const code = (body && body.code) || "";
let owner = await isOwnerCode(code, env);
let helperRole = null;
if (!owner) {
helperRole = await findHelperRole(code, env);
}
if (!owner && !helperRole) {
// bewusst generische Fehlermeldung, kein Hinweis ob Code existiert
return json({ ok: false, error: "Falscher Code." }, 401);
}
const list = await env.BEWERBUNGEN.list({ prefix: "sub:", limit: 500 });
const items = await Promise.all(
list.keys.map(async (k) => {
const raw = await env.BEWERBUNGEN.get(k.name);
return raw ? JSON.parse(raw) : null;
})
);
const bewerbungen = items
.filter(Boolean)
.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1)); // neueste zuerst
return json({
ok: true,
isOwner: owner,
roleLabel: owner ? "Dogi (Owner)" : helperRole.label,
bewerbungen,
});
}
async function requireOwner(body, env) {
const ownerCode = (body && body.ownerCode) || "";
return isOwnerCode(ownerCode, env);
}
async function handleRolesList(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const list = await env.BEWERBUNGEN.list({ prefix: ROLE_KEY_PREFIX, limit: 200 });
const roles = await Promise.all(
list.keys.map(async (k) => {
const raw = await env.BEWERBUNGEN.get(k.name);
return raw ? { keyId: k.name, ...JSON.parse(raw) } : null;
})
);
return json({ ok: true, roles: roles.filter(Boolean) });
}
async function handleRolesCreate(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const role = String((body && body.role) || "").slice(0, 60).trim() || "Helfer";
const label = String((body && body.label) || "").slice(0, 80).trim() || role;
const newCode = generateFriendlyCode();
const hash = await sha256Hex(newCode);
const record = { role, label, createdAt: new Date().toISOString() };
await env.BEWERBUNGEN.put(ROLE_KEY_PREFIX + hash, JSON.stringify(record));
// Der Klartext-Code wird NUR jetzt einmalig zurückgegeben — danach nie wieder.
return json({ ok: true, code: newCode, ...record });
}
async function handleRolesRevoke(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf Rollen verwalten." }, 401);
}
const keyId = String((body && body.keyId) || "");
if (!keyId.startsWith(ROLE_KEY_PREFIX)) {
return json({ ok: false, error: "Ungültige keyId." }, 400);
}
await env.BEWERBUNGEN.delete(keyId);
return json({ ok: true });
}
async function handleLiveStatusGet(env) {
const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY);
const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null };
return json({ ok: true, ...data });
}
async function handleLiveStatusSet(request, env) {
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: "Ungültiges JSON." }, 400);
}
if (!(await requireOwner(body, env))) {
return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 401);
}
const live = !!(body && body.live);
const record = { live, updatedAt: new Date().toISOString() };
async function handleLiveStatusSet(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
const body = await request.json().catch(() => ({}));
const record = { live: !!body.live, updatedAt: new Date().toISOString() };
await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record));
return json({ ok: true, ...record });
}
// Routen, die eine gültige Session brauchen. `session` wird injiziert.
const AUTHED_ROUTES = {
"POST /auth/logout": (req, env) => logout(req, env),
"POST /live-status/set": (req, env, session) => handleLiveStatusSet(req, env, session),
"POST /admin/applications/list": (req, env, session) => listApplications(req, env, session),
"POST /admin/applications/status": (req, env, session) => changeApplicationStatus(req, env, session),
"POST /admin/applications/take-over": (req, env, session) => takeOverApplication(req, env, session),
"POST /admin/applications/assign": (req, env, session) => assignApplication(req, env, session),
"POST /admin/applications/archive": (req, env, session) => archiveApplication(req, env, session),
"POST /admin/applications/restore": (req, env, session) => restoreApplication(req, env, session),
"POST /admin/applications/delete": (req, env, session) => deleteApplication(req, env, session),
"POST /admin/applications/mark-answered": (req, env, session) => markAnswered(req, env, session),
"POST /admin/notes/list": (req, env, session) => listNotes(req, env, session),
"POST /admin/notes/add": (req, env, session) => addNote(req, env, session),
"POST /admin/notes/delete": (req, env, session) => deleteNote(req, env, session),
"POST /admin/drafts/list": (req, env, session) => listDrafts(req, env, session),
"POST /admin/drafts/save": (req, env, session) => saveDraft(req, env, session),
"POST /admin/roles/list": (req, env, session) => listRoles(env, session),
"POST /admin/roles/create": (req, env, session) => createRole(req, env, session),
"POST /admin/roles/update": (req, env, session) => updateRole(req, env, session),
"POST /admin/roles/delete": (req, env, session) => deleteRole(req, env, session),
"POST /admin/users/list": (req, env, session) => listUsers(env, session),
"POST /admin/users/prepare": (req, env, session) => prepareUser(req, env, session),
"POST /admin/users/finalize": (req, env, session) => finalizeUser(req, env, session),
"POST /admin/users/set-code": (req, env, session) => setUserCode(req, env, session),
"POST /admin/users/reveal-code": (req, env, session) => revealUserCode(req, env, session),
"POST /admin/users/lock": (req, env, session) => lockUser(req, env, session),
"POST /admin/users/unlock": (req, env, session) => unlockUser(req, env, session),
"POST /admin/users/delete": (req, env, session) => deleteUser(req, env, session),
"POST /admin/users/set-role": (req, env, session) => setUserRole(req, env, session),
"POST /admin/users/set-permissions": (req, env, session) => setUserPermissions(req, env, session),
"POST /admin/users/end-sessions": (req, env, session) => endUserSessions(req, env, session),
"POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session),
"POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session),
};
export default {
async fetch(request, env) {
const url = new URL(request.url);
const routeKey = `${request.method} ${url.pathname}`;
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
if (url.pathname === "/health") {
return json({ ok: true, service: "dogfather-universe-postfach" });
if (url.pathname === "/health" && request.method === "GET") {
return json({ ok: true, service: "dogfather-universe-interner-bereich" });
}
if (url.pathname === "/submit" && request.method === "POST") {
return handleSubmit(request, env);
return submitApplication(request, env);
}
if (url.pathname === "/postfach" && request.method === "POST") {
return handlePostfach(request, env);
}
if (url.pathname === "/admin/roles/list" && request.method === "POST") {
return handleRolesList(request, env);
}
if (url.pathname === "/admin/roles/create" && request.method === "POST") {
return handleRolesCreate(request, env);
}
if (url.pathname === "/admin/roles/revoke" && request.method === "POST") {
return handleRolesRevoke(request, env);
}
if (url.pathname === "/live-status" && request.method === "GET") {
return handleLiveStatusGet(env);
}
if (url.pathname === "/auth/login" && request.method === "POST") {
return login(request, env);
}
if (url.pathname === "/live-status/set" && request.method === "POST") {
return handleLiveStatusSet(request, env);
const handler = AUTHED_ROUTES[routeKey];
if (handler) {
const session = await requireSession(request, env);
if (!session) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
return handler(request, env, session);
}
return json({ ok: false, error: "Not found." }, 404);