@iddp/vefk-app-kit
v0.10.0
Published
Geteiltes Backend & geteilte Logik der VEFK-Tools-Suite: Storage-Adapter, Multi-Tenant-Middleware, Auth (Passwort+2FA / Magic-Code / Passwort ohne Mandant), Mail, Upload, Export.
Readme
@iddp/vefk-app-kit
Geteiltes Backend & geteilte Logik der VEFK-Tools-Suite: Storage-Adapter, Multi-Tenant-Middleware, Auth (Passwort+2FA / Magic-Code / Passwort ohne Mandant), Mail, Upload, Export. Ein Paket, zwei Einstiegspunkte — so zieht der Frontend-Bundle nie Server-Code.
// Backend (nur dort!):
import {
createDataStore, jsonAdapter, // Daten-Schicht (atomar + Write-Queue)
tenantResolver, tenantGuard, requireLogin,
authRouter, // Auth-Profile hinter config.authMode: "tenant" | "generic" | "both"
createUpload, sichererDateipfad, // Uploads
erstelleExcel, erstelleTenantBackup, // Export
} from "@iddp/vefk-app-kit/server";
// Frontend UND Backend:
import { ROLLEN, darfSehen, darfEditieren, tenantPfad } from "@iddp/vefk-app-kit/shared";Installation (npm-Paket)
npm install @iddp/vefk-app-kit// package.json des Tools
"dependencies": {
"@iddp/vefk-app-kit": "^0.8.0"
}Das Paket liegt öffentlich in der npm-Registry — auf dem Server braucht npm ci dafür
keinen Git- oder SSH-Zugang. Der Quellcode ist einsehbar, die Lizenz ist UNLICENSED:
kein Nutzungsrecht außerhalb der VEFK-Tools-Suite. Kein Build: reines ESM, die
exports-Map zeigt direkt auf die Quelldateien.
Minimal-Verdrahtung im Backend
import express from "express";
import session from "express-session";
import {
createDataStore, jsonAdapter, authRouter, tenantResolver, tenantGuard,
} from "@iddp/vefk-app-kit/server";
import config from "../app.config.js";
const store = createDataStore({ adapter: jsonAdapter({ root: DATA_ROOT }) });
const app = express();
app.set("trust proxy", 1);
app.use(express.json({ limit: "1mb" }));
app.use(session({ /* Store/Secret ist Sache der App */ }));
app.use(sitzungPruefen({ store })); // direkt nach session(): befristete Sitzungen (Seminar-Link)
app.use("/api/auth", authRouter({ store, config }));
const resolver = tenantResolver(store);
app.get("/api/:tenant/:datentyp", resolver, tenantGuard, async (req, res) => {
res.json(await store.tenant(req.tenant).collection(req.params.datentyp).read({}));
});Auth-Profile (config.authMode)
"tenant"— Mandanten-Tool: Passwort-Login (+ optionale 2FA), Tenant-Guard. Routen/login*. Drei Login-Wege im selben Mandanten (ab v0.6.0):/login— E-Mail + Passwort (+ 2FA) für Nutzer mitpasswortHash;/login/magic— E-Mail-Code für Nutzer ohne Passwort (vom Admin percreate-userohne Passwort angelegt); kein Auto-Create, gleiche Antwort und gleiche Laufzeit für unbekannte E-Mails und Passwort-Nutzer (keine Enumeration), fail-closed ohne SMTP. Bestätigung über/login/codewie beim 2FA-Code;/login/zugang— Mandanten-Zugang: ein gemeinsames Passwort je Mandant ohne Konto und ohne E-Mail (setzeMandantenZugang, Collectionzugang, feste Rolle, kein 2FA).GET /login/optionen/:tenant→{ anmeldung, konto, zugang }sagt der Login-Seite, welche Masken sie zeigt.- Anmeldeart je Mandant (ab v0.8.0):
dashboard-config.anmeldung="konto"(Default: Nutzerkonten, Zugang zusätzlich möglich) oder"zugang"(nur das Mandanten-Passwort;/loginund/login/magic→ 403).ANMELDUNGEN,anmeldungDesMandantenexportiert; der Admin-Router nimmt das Feld beiPOST/PATCH /tenants.
"generic"— mandantenloses Tool: ein offener Bereich im internen Tenant (config.generic.tenant). Das Login-Verfahren wähltconfig.generic.login:"magic"(Default) — passwortloser Magic-Code per E-Mail, Auto-Create (Adressen ausconfig.admin.emailsundownerEmailwerden admin, alle anderen viewer). Routen/magic*."password"(ab v0.4.0) — „normaler" Login mit E-Mail + Passwort, optional 2FA (config.generic.zweiFaktor), kein Auto-Create: Nutzer legt der Admin an (create-user). Routen/password*; Nutzer ohne Passwort melden sich per E-Mail-Code an (/password/magic→/password/code, ab v0.6.0).- Seminar-Link (ab v0.9.0, zusätzlich zu magic/password) — befristeter Zugang ohne
Konto: der Admin legt ein Seminar mit Zeitfenster an (
legeSeminarAn, Collectionseminareim generic-Tenant), der Link…/login?seminar=<code>wird perPOST /seminar { code }eingelöst. Anonyme Sitzung (email: null, feste Rolle, nie admin) mitseminar: { id, name, bis }undgueltigBis. 401 für unbekannte Codes, 403 mit Grund für geplant/beendet/deaktiviert. Ohne angelegte Seminare ist der Weg einfach leer — kein Config-Schalter. Die App kann überuser.seminar.idselbst ein Kontingent zählen. Ein eigener Code (legeSeminarAn(…, { code }), ab v0.9.1) hat 6–16 Zeichen (ab v0.9.2);findeSeminarMitStatus(ab v0.9.2) findet ein Seminar per Code samt Status.
"both"(ab v0.2.0) — beides parallel: Mandanten-Bereiche wie beitenantplus der offene Bereich (Magic-Code oder Passwort, s. o.). Die Auth-Routen sind disjunkt (/login*vs./magic*bzw./password*), die Nutzerkreise auch — Nutzer des offenen Bereichs leben nur im generic-Tenant und kommen am Tenant-Guard nicht vorbei. Der Seminar-Link gilt nur für den offenen Bereich. Wichtig bei der Verdrahtung: die offenen/api/data/…-Routen vor den/api/:tenant/…-Routen registrieren (sonst würdedataals Slug gelesen).
Vollständige Muster (Session-Store, Static-Serving, Error-Handler, Uploads, Export-Routen) zeigt das vefk-app-template — neue Tools entstehen daraus, nicht von Hand.
Datenspeicher (createDataStore)
Zwei Adapter, ein Vertrag — der Tool-Code merkt den Unterschied nicht:
import { createDataStore, jsonAdapter, sqliteAdapter, migriereJsonNachSqlite } from "@iddp/vefk-app-kit/server";
const store = createDataStore({ adapter: sqliteAdapter({ root: dataRoot }) }); // <root>/app.sqlite
// oder: jsonAdapter({ root: dataRoot }) // <root>/tenants/<slug>/*.json
await store.tenant("kunde-a").collection("daten").read({});
await store.tenant("kunde-a").collection("daten").write(daten);
await store.tenant("kunde-a").collection("zugang").delete();
await store.createTenant("kunde-b"); // aus _muster (ohne users/zugang/seminare), legt Dokumente/ an
await store.deleteTenant("kunde-b"); // Collections + Dateiablage
await migriereJsonNachSqlite({ root, file }); // einmalig, idempotent, JSON bleibt liegensqliteAdapter (better-sqlite3, ab 0.7.0): eine Datei, WAL, Transaktionen; Dokumente bleiben
Dateien unter tenants/<slug>/Dokumente/. Das native Modul wird erst beim Aufruf geladen.
Die Vorlage _muster ist Code, nicht Daten: createTenant liest sie immer von Platte
(tenants/_muster/*.json), Migration und Import lassen sie aus — sie steht nie in der DB.
Admin-Bereich (adminRouter, ab 0.7.0)
app.use("/api/admin", adminRouter({ store, config }));
// config.admin = { emails: ["[email protected]"], pfad: "/admin" }
// GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (optional — sonst E-Mail-Code über SMTP) stehen
// suite-weit in /etc/vefk-suite/env (Server) bzw. ~/.config/vefk-suite/env (Rechner) —
// server.js lädt sie nach der eigenen .env über ladeToolUndSuiteEnv(); Redirect-URI kommt
// aus dem Request-Host (mehrere Domains), GOOGLE_REDIRECT_URI erzwingt einen festen WertEigene Session (req.session.admin), Login per Google-Konto (OAuth 2.0 / OIDC, natives fetch)
oder E-Mail-Code; nur für die Admin-Liste (config.admin.emails + Ergänzungen über die API).
Routen: Mandanten anlegen/ändern/löschen (Tipp-Bestätigung), Nutzer je Mandant (anlegen, ändern,
Passwort, fester Code, deaktivieren, Auskunft, anonymisieren), Mandanten-Zugang,
Seminar-Links (nur generic/both: GET/POST /seminare, PATCH/DELETE /seminare/:id,
POST /seminare/:id/neuer-link — Antworten enthalten den Code, das Audit nie), Admins,
Audit. requireAdmin für tool-eigene Admin-Routen, RESERVIERTE_SLUGS für den Scaffolder.
Kein Bypass auf Mandanten-Daten. → Template docs/09.
Fester Code (setzeFestenCode(store, tenant, id, "123456")): ein Nutzer ohne Passwort
meldet sich über den Code-Weg an, bekommt aber keine Mail — der hinterlegte bcrypt-Hash wird
zum Pending-State. Braucht kein SMTP. Einordnung: kurzes Passwort ohne Ablauf, nur für Demo-/
Kiosk-Nutzer. → Template docs/08.
Gemeinsam für alle Profile: GET /me, PATCH /me { name } (eigener Anzeigename; nicht für den
Mandanten-Zugang und den Seminar-Link), POST /logout.
Befristete Sitzungen (ab v0.9.0): trägt der Session-Nutzer gueltigBis, lehnen
requireLogin, tenantGuard und GET /me ihn nach Ablauf ab (401) und räumen die Sitzung ab —
synchron, auch ohne weitere Middleware. sitzungPruefen({ store }) (App-weit direkt nach
session()) gleicht Seminar-Sitzungen zusätzlich bei jedem Request gegen die Datei ab:
Deaktivieren, Löschen und „Neuer Link" melden sofort ab, Verlängern und Rollenwechsel ziehen
laufende Sitzungen nach. Das Cookie behält die App-Laufzeit; maßgeblich ist der Server.
Sicherheits-Invarianten (getestet in tests/)
- Tenant-Slug-Validierung (
^[a-z0-9-]+$+ Existenz-Check) bevor ein Pfad entsteht - Atomares Schreiben (Temp +
rename) + Write-Queue pro Datei - Tenant-Guard serverseitig, kein Admin-Bypass
- Login-Rate-Limit pro IP und pro E-Mail; bei Passwort-Logins zählen nur
Fehlversuche (
nurFehlversuche), beim Code-Anfordern jeder Versuch - bcrypt-Cost 12 für Passwörter, Cost 8 nur für kurzlebige Codes
- 2FA/Magic-Code fail-closed (kein SMTP + nicht Dev → 503)
- Code-Login pro Nutzer nur ohne
passwortHash; Passwort-Nutzer und unbekannte E-Mails bekommen dieselbe Antwort ohne Code (keine Enumeration, kein Auto-Create) - Admin-Bereich: eigene Session, kein Tenant-Guard-Bypass,
_adminnie einloggbar/listbar, Google-stategegen CSRF,id_tokenauf Aussteller/Audience/Ablauf/email_verifiedgeprüft, Antworten ohne Hashes, Audit ohne Geheimnisse; fester Code nur für Nutzer ohne Passwort - Mandanten-Zugang: Hash in
zugang.json(nie im Git), 401 für „kein Zugang" und „falsches Passwort", Rate-Limit pro IP + Mandant (nur Fehlversuche, 20/15 min), Session ohne E-Mail;setzeMandantenZugangnur für vorhandene Mandanten (code: "TENANT"), Entfernen löscht die Datei (collection.delete()) - Seminar-Link: ≈59-Bit-Code (crypto, Rejection Sampling), Plausibilitätsprüfung vor jedem
Store-Zugriff, Vergleich in konstanter Zeit; Rate-Limit pro IP, das nur unbekannte Codes
zählt (ein Kurs hinter einer IP sperrt sich nicht selbst aus); Rolle nie
admin;seminare.jsonnie im Git und nie aus_musterkopiert; Code-Fingerabdruck nur serverseitig in der Session (nicht in/me) - Befristete Sitzungen: abgelaufenes
gueltigBis→ 401 in allen Guards, auch ohnesitzungPruefen; Deaktivieren/Löschen/Neuer Link wirkt mitsitzungPruefensofort - Dateinamen-Sanitisierung + Pfad-Containment für Upload/Download
- Keine personenbezogenen Daten in Logs: ohne SMTP wirft
send()außerhalb von Dev (MAIL_NOT_CONFIGURED) statt Adresse + Code in die Konsole zu schreiben - Mail-Header minimal:
send({ to, subject, text, html })setzt keine defensiven Zusatz-Header. Einzige Option (ab v0.5.0):replyTofür Mails, auf die Menschen antworten sollen (Einladungen, Benachrichtigungen). Code-Mails (sendLoginCode) bleiben ohne. - Betroffenenrechte eingebaut:
deaktiviereUser,loescheUser(anonymisiert,idbleibt;{ hart: true }entfernt) undexportiereUserdaten(Auskunft ohne Passwort-Hash) — das Template ruft sie übernpm run userauf (→ Templatedocs/05)
Entwicklung
npm install && npm testRegeln für Änderungen an diesem Repo: AGENTS.md.
