Stop pretending Vercel is an option
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s

It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.

vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.

DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.

The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.

Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:02:40 +02:00
parent 77d9a95f7f
commit e958bb5c6b
12 changed files with 54 additions and 127 deletions

View File

@@ -57,7 +57,9 @@ AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id
# Auth.js die Rückruf-Adresse aus den Request-Headern.
AUTH_URL=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
# Gemeinsames Geheimnis, mit dem sich der nächtliche Lauf ausweist: der
# `cron`-Container schickt es als `Authorization: Bearer <wert>` an
# /api/cron/apply-pending-changes. Ohne angemeldete Person gibt es keine
# Sitzung, an der die Route den Aufruf prüfen könnte.
# Erzeugen mit: openssl rand -hex 32
CRON_SECRET=

3
.gitignore vendored
View File

@@ -34,9 +34,6 @@ yarn-error.log*
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,71 +1,14 @@
# Deployment
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
jederzeit möglich.
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
aus `.env`.
| | passt, wenn |
|---|---|
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
[docs/entra-sso.md](docs/entra-sso.md)).
## Vercel
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
```bash
npx vercel login
npx vercel link
npx vercel --prod
```
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
Personen ist das eher Vorteil als Nachteil.
### b) Zusätzlich nach GitHub spiegeln
```bash
git remote add github git@github.com:<konto>/alpenwerk-hr.git
git push github feat/sap-om-org-model
```
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
### Danach
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
nicht nötig, Vercel setzt den Host selbst.
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
vielen tausend Zeilen ansteht.
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
verbraucht.
## Deployment mit Docker
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
einem eigenen Linux-Server läuft.
Zu klären, bevor es losgeht: die Umgebungsvariablen aus
[Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der
Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung
eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)).
## Was wird containerisiert – und was nicht
@@ -84,12 +27,10 @@ einem eigenen Linux-Server läuft.
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
keinen Anmeldedienst, der mit ausgerollt werden müsste.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
- **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00
Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen,
Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird
der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person.
## Betrieb im Firmennetz — zwei Dinge vorab
@@ -103,12 +44,13 @@ nichts, ausgehend zwingend:
| Ziel | Wofür | Ohne das |
|---|---|---|
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
abgeschotteten Netz, ist entweder ein Proxy nötig oder eine PostgreSQL-
Instanz im selben Netz statt Supabase.
abgeschotteten Netz, braucht es dafür einen Proxy.
Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im
selben Compose-Netz. Nach aussen geht dafür nichts.
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
@@ -126,11 +68,9 @@ bei zwanzig nicht.
## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
enthaltene `docker compose`) auf dem Zielserver.
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
bisher).
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
was gebraucht wird, kommt aus Containern.
## 1. `.env` anlegen
@@ -142,8 +82,10 @@ Werte eintragen:
| Variable | Woher |
|---|---|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |

View File

@@ -110,12 +110,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
denselben Endpoint mit demselben Schema aufruft.
- Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
@@ -140,10 +136,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
## Deployment
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
Vercel-Projekt setzen (siehe oben).
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
## Known TODOs vor Produktivbetrieb

View File

@@ -21,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string {
}
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and
// on Vercel — so every entry would read an hour or two early for the people
// an unpinned formatter renders it in the *server's* zone — UTC in the
// container — so every entry would read an hour or two early for the people
// the log is for.
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",

View File

@@ -4,10 +4,11 @@ import { callFunction } from "@/lib/db/rpc";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
// job (see vercel.json), not on behalf of any HR user, so it authenticates
// via a shared secret rather than a Supabase session and uses the
// service-role client (the one legitimate server-only use case for it).
// apply_due_pending_changes() in supabase/migrations.
//
// Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
// Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb
// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET).
export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {

View File

@@ -25,10 +25,10 @@ class Rueckabwicklung extends Error {
}
}
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist —
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300
// heben, falls eine Datei mit vielen tausend Zeilen ansteht.
// Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an
// serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt
// als Absichtserklärung stehen: ein Import, der länger als eine Minute
// braucht, ist einer, der in Teilen laufen sollte.
export const maxDuration = 60;
type Antwort = {

View File

@@ -100,9 +100,10 @@ services:
env_file:
- .env
# Replaces the Vercel Cron job from vercel.json (not available outside
# Vercel): calls the same endpoint on the same daily schedule using the
# same bearer-secret auth the route already expects.
# Der nächtliche Lauf: ruft täglich um 03:00 Uhr
# /api/cron/apply-pending-changes auf, damit fällig gewordene Versetzungen,
# Beförderungen, Karenzen und Reorganisationen wirksam werden. Ausgewiesen
# über CRON_SECRET — es gibt dabei keine angemeldete Person.
cron:
image: alpine:3.20
restart: unless-stopped

View File

@@ -99,10 +99,9 @@ Ein einziges Rollenmodell, kein Mehrfach-Rollen-System:
Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist
(`revoke ... from public, anon, authenticated; grant ... to service_role`).
Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen —
täglich per Vercel Cron (`vercel.json`), außerhalb von Vercel per
Ersatz-Scheduler (siehe `DEPLOYMENT.md`, Docker-Cron-Sidecar). Die Route
selbst authentifiziert per `CRON_SECRET`-Bearer-Token, nicht per
Supabase-Session — es gibt keine anfragende Person, nur den Scheduler.
täglich vom `cron`-Container aus `docker-compose.yml`. Die Route selbst
authentifiziert per `CRON_SECRET`-Bearer-Token, nicht über eine Sitzung — es
gibt keine anfragende Person, nur den Zeitplan.
Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder
`cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf

View File

@@ -1,7 +1,7 @@
// This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as
// timestamptz, and is used from a single timezone. Every conversion here is
// pinned to Europe/Vienna rather than the runtime's zone: the server renders
// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned
// in UTC (im Container) while the browser renders in Vienna, so an unpinned
// formatter produces a different day on each side — wrong dates for the user
// near midnight, and a React hydration mismatch.
const TIMEZONE = "Europe/Vienna";

View File

@@ -46,10 +46,9 @@ const nextConfig: NextConfig = {
// Emits a self-contained .next/standalone server (only the deps actually
// used at runtime, no full node_modules) — what the Dockerfile copies in.
//
// Auf Vercel ist das falsch: dort baut die Plattform selbst und erwartet
// die übliche Ausgabe. `VERCEL` setzt sie in jeder Baustrecke, die Angabe
// entfällt dort also von selbst — und der Docker-Weg bleibt unberührt.
output: process.env.VERCEL ? undefined : "standalone",
// Ohne Ausnahme: die Anwendung läuft in einem Container auf einem eigenen
// Server, und dort ist das der richtige Ausgabemodus.
output: "standalone",
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
// browser feature access) plus the report-only CSP described above.
async headers() {

View File

@@ -1,8 +0,0 @@
{
"crons": [
{
"path": "/api/cron/apply-pending-changes",
"schedule": "0 3 * * *"
}
]
}