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:
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Wendet alle noch nicht angewendeten Dateien aus db/migrations/*.sql auf
|
||||
// die per DATABASE_URL erreichbare Postgres-Datenbank an. Idempotent –
|
||||
// kann bei jedem App-Start gefahrlos erneut laufen (Docker-Entrypoint,
|
||||
// npm predev/prestart).
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pg from "pg";
|
||||
|
||||
const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "migrations");
|
||||
|
||||
async function main() {
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
create table if not exists schema_migrations (
|
||||
filename text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
)
|
||||
`);
|
||||
|
||||
const { rows } = await client.query("select filename from schema_migrations");
|
||||
const applied = new Set(rows.map((r) => r.filename));
|
||||
|
||||
const files = readdirSync(migrationsDir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = readFileSync(join(migrationsDir, file), "utf8");
|
||||
console.log(`db:migrate – wende ${file} an…`);
|
||||
await client.query("begin");
|
||||
try {
|
||||
await client.query(sql);
|
||||
await client.query("insert into schema_migrations (filename) values ($1)", [file]);
|
||||
await client.query("commit");
|
||||
} catch (err) {
|
||||
await client.query("rollback");
|
||||
throw new Error(`Migration ${file} fehlgeschlagen: ${err.message}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("db:migrate – Datenbank ist aktuell.");
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
Reference in New Issue
Block a user