v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase

Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/
webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs
(idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper),
lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit),
lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts).

Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's
experimentelles WebAuthn-API (dafür gab es bisher kein eigenes
Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt
über scripts/configure-passkeys.mjs und die Supabase-Management-API.

scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch
einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an
ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen).

Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu
deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich
serverseitig in PATCH /api/account.

E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach
Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein
SMTP-Server vorhanden).

Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/
Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client –
folgt in weiteren Commits.
This commit is contained in:
2026-07-18 10:42:21 +02:00
parent 4c5376a8c7
commit 28c9ec15c8
28 changed files with 1618 additions and 153 deletions
+156
View File
@@ -0,0 +1,156 @@
-- =============================================================
-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase)
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login
-- =============================================================
-- ---------- Benutzer & Authentifizierung ----------
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
full_name text not null default '',
role text not null default 'staff' check (role in ('admin', 'staff')),
password_auth_enabled boolean not null default true,
created_at timestamptz not null default now()
);
-- Angemeldete Sitzungen. Es wird nur der Hash des Tokens gespeichert –
-- der Rohwert im Cookie ist damit auch bei einem DB-Zugriff allein nicht
-- verwendbar.
create table sessions (
token_hash text primary key,
user_id uuid not null references users (id) on delete cascade,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
last_seen_at timestamptz not null default now()
);
create index sessions_user_id_idx on sessions (user_id);
-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher
-- unsichtbares Passkey-Schema).
create table passkey_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id) on delete cascade,
credential_id text not null unique,
public_key bytea not null,
counter bigint not null default 0,
transports text[] not null default '{}',
name text,
created_at timestamptz not null default now(),
last_used_at timestamptz
);
create index passkey_credentials_user_id_idx on passkey_credentials (user_id);
-- Kurzlebige Challenges für laufende WebAuthn-Zeremonien (Registrierung
-- oder Login). user_id ist beim Login-Flow anfangs null (Discoverable-
-- Credential-Ansatz: die Identität steht erst nach der Antwort fest).
create table webauthn_challenges (
id uuid primary key default gen_random_uuid(),
user_id uuid references users (id) on delete cascade,
challenge text not null,
type text not null check (type in ('registration', 'authentication')),
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
-- ---------- Essenszeiten der Speisekarte ----------
create table sections (
key text primary key default (gen_random_uuid())::text,
label text not null,
label_en text,
label_pl text,
time_range text not null,
sort int not null
);
-- ---------- Allergene & Zusatzstoffe ----------
create table allergens (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
create table additives (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
-- ---------- Gerichte ----------
create table dishes (
id uuid primary key default gen_random_uuid(),
name text not null,
name_en text,
name_pl text,
price numeric(6, 2) not null default 0,
allergens text[] not null default '{}',
additives text[] not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create or replace function touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger dishes_touch_updated_at
before update on dishes
for each row execute function touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
create table menu_entries (
id uuid primary key default gen_random_uuid(),
menu_date date not null,
section_key text not null references sections (key) on delete cascade,
dish_id uuid not null references dishes (id) on delete cascade,
position int not null default 0,
created_at timestamptz not null default now(),
unique (menu_date, section_key, dish_id)
);
create index menu_entries_date_idx on menu_entries (menu_date);
-- ---------- Einstellungen (Farben, Logo, Favicon) – genau eine Zeile ----------
create table settings (
id int primary key check (id = 1),
colors jsonb not null default '{}'::jsonb,
logo_path text,
favicon_path text,
updated_at timestamptz not null default now()
);
insert into settings (id, colors) values (
1,
'{
"primary": "#003388",
"primaryDark": "#0d315c",
"accent": "#ff6900",
"headerText": "#ffffff",
"rowBg": "#e3e4f1",
"rowBgAlt": "#f4f4fa",
"addBtn": "#6da54c",
"deleteBtn": "#e2371d"
}'::jsonb
);
create trigger settings_touch_updated_at
before update on settings
for each row execute function touch_updated_at();
-- ---------- Rezeptvorschläge ----------
create table dish_suggestions (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_at timestamptz not null default now()
);