Files
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

372 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# archivdms
GoBD-konformes Dokumentenmanagementsystem (DMS) für den DACH-Raum. Eigenständiges Produkt, getrennt von archivmail (E-Mail-Archivierung) — spätere Mail-Anbindung nur optional über archivmails REST-API, kein gemeinsamer Code.
Konzeption basiert auf Recherche zu Paperless-ngx und ecoDMS (siehe `dms-featureliste-prompt.md`). Grundgerüst (Auth/Tenant/Audit/Mailer-Architektur) aus archivmail portiert und auf ein dokumentzentriertes Modell umgebaut.
## Stack
- Backend: Go (`net/http`, PostgreSQL via pgx/v5, JWT-Auth)
- Frontend: Next.js + shadcn/ui
- Multi-Tenancy: applikationsseitig (`tenant_id`-Filter je Query, kein Postgres-RLS)
- Suche: Manticore Search (BM25) — Backend-Endpunkt `GET /api/documents/search` und Frontend (globale Suchleiste in der TopBar, Ergebnisseite `/search` mit Tag-/Dokumenttyp-Filter und Pagination) integriert; Hybrid/Vektor geplant
## CI-Pipeline (FDN-07)
`.gitea/workflows/ci.yml` definiert eine Gitea-Actions-Pipeline (Syntax kompatibel zu GitHub Actions) mit zwei Jobs:
- **backend-lint-test**: `go vet ./...`, `go test ./... -cover` gegen eine pro Lauf frisch gestartete Postgres-Service-Container-Instanz, danach `make build` als reproduzierbares Artefakt.
- **frontend-lint-test-build**: `npm ci`/`npm install`-Fallback, ESLint (`npm run lint`), `tsc --noEmit`, `make build-web` (`next build`) als Artefakt.
Läuft bei jedem Push/PR; ein fehlschlagender Schritt bricht den jeweiligen Job ab (kein `continue-on-error`), das blockiert den Merge sobald in Gitea die Branch-Protection-Regel "Require status checks to pass" auf diese Jobs gesetzt ist. **Wichtig:** archivdms hat aktuell noch keinen Gitea-Remote (nur lokales `git init`), die Pipeline greift daher erst, sobald das Repo zu einer Gitea-Instanz mit aktivierten Actions und registriertem Runner gepusht wird.
## Betrieb: Logging, Metriken & Fehler-Tracking (FDN-08)
Jede HTTP-Anfrage läuft durch eine Basis-Middleware-Kette in `internal/api/observability.go`
(`requestID -> metrics -> recover -> ServeMux`, gebaut in `api.New`):
- **Korrelations-ID:** pro Anfrage wird eine Request-ID erzeugt oder ein mitgelieferter
`X-Request-ID`-Header übernommen (nur alphanumerisch/`-_.`, max. 64 Zeichen — sonst verworfen,
Schutz gegen Log-Injection). Die ID steht im `context.Context` und im Response-Header
`X-Request-ID`. Handler loggen über `s.reqLog(r.Context())` bzw. `s.reqLog(ctx)`; jede Zeile
bekommt dadurch automatisch `request_id=...` — auch in der asynchronen Verarbeitung
(`ProcessDocumentJob`, `ReprocessDocument`), soweit der Request-Context durchgereicht wird.
- **Panic-Recovery:** `net/http` hat kein zentrales Recovery. Die `recoverMiddleware` fängt jedes
Panic ab, loggt es mit Korrelations-ID und gekürztem Stacktrace, zählt `archivdms_panics_total`
hoch und antwortet mit einem sauberen HTTP 500 statt einer abgebrochenen Verbindung.
- **Keine Geheimnisse in Logs:** geloggt werden nur Methode, normalisierter Pfad, Status, Dauer und
Client-IP. Query-Strings, Header und Bodies werden nie ausgegeben (dort stehen Signed-URL-Signaturen,
Share-Tokens, Bearer-Keys, Passwörter). `normalizeRoute` ersetzt IDs durch `{id}` und das Segment
hinter `/share/` immer durch `{token}`.
### `GET /metrics`
Prometheus-Textformat, ohne Fremdabhängigkeit (`internal/api/metrics_handlers.go`, reines `fmt.Fprintf`).
Bewusst **ohne Login** (ein Scraper hat keine Session), dafür **IP-beschränkt**: Loopback ist immer
erlaubt, weitere Scraper werden über den Config-Key `api.metrics_allowed_ips` (Liste aus IPs oder
CIDR-Bereichen, Default leer = nur localhost) freigeschaltet; alles andere bekommt 403. Der Endpunkt
gehört nicht ins öffentliche Reverse-Proxy-Mapping.
Ausgegeben werden: `archivdms_http_requests_total{method,route,status}` (Fehlerrate = Anteil
`status=~"5.."`), `archivdms_http_request_duration_seconds_{bucket,sum,count}` (Latenz-Histogramm),
`archivdms_http_requests_in_flight`, `archivdms_goroutines`, `archivdms_uptime_seconds`,
`archivdms_panics_total`, `archivdms_build_info{version}` sowie die Queue-Länge
`archivdms_processing_jobs{status}` (aus `processing_jobs`, aggregiert über alle Mandanten — es
werden keine mandantenbezogenen Daten ausgegeben). Die Label-Kardinalität ist auf 500 Serien
gedeckelt, danach landet alles unter `route="/other"`.
## Frontend: Login-Pflicht & UI-Struktur
Alle Seiten außer `/login` sind hinter einer Session-Cookie-Prüfung (`middleware.ts`, Cookie `archivdms_session`). Ohne gültiges Cookie wird sofort (ohne sichtbares Flackern) zu `/login` umgeleitet; die vollständige JWT-Prüfung bleibt Aufgabe des Go-Backends bei jedem echten API-Call. Eingeloggte Bereiche laufen unter der Routegruppe `src/app/(app)/` mit gemeinsamer App-Shell (Sidebar, TopBar, Cmd+K-Befehlspalette, Dark Mode). `/documents` und `/reminders` sind Server Components mit serverseitigem Datenfetch (`src/lib/session.ts` reicht das Session-Cookie manuell an die Go-API weiter) statt der bisherigen "use client" + `useEffect`-Ladeschleife. Die Dokumenten-Detail-/Vorschauseite `/documents/{id}` (Server Component `src/app/(app)/documents/[id]/page.tsx` + Client-Island `DocumentPreview`) zeigt die Datei-Vorschau in einem `<iframe src="/api/documents/{id}/file">` (same-origin, Cookie-Auth greift automatisch, Browser-natives PDF/Bild-Rendering). Die Metadaten-Seitenleiste (Reiter Details/Inhalt/Verlauf) ist in die Unterkomponente `DocumentDetailsTab` ausgelagert und in einem kompakten 2-Spalten-Raster angeordnet (kurze Felder Belegdatum/Dokumenttyp/Korrespondent/Erstellt nebeneinander, Titel/Akte/Tags in voller Breite). Editierbar: Inline-Titel, Belegdatum (`<input type="date">`, `PUT /api/documents/{id}/document-date`), Dokumenttyp/Korrespondent/Akte (Command-Popover, Auswahl wird direkt beim Klick gespeichert) sowie Tags hinzufügen (`POST /api/documents/{id}/tags/{tagId}`) / entfernen (X am Badge, `DELETE ...`). Heuristische Vorschlags-Chips (inkl. erkanntem Belegdatum aus dem OCR-Text) lassen sich per Klick übernehmen. In der Kopfzeile der Detailseite liegen die Aktionen "Export" (Download-Icon, `<a href="/api/documents/{id}/export" download>` — ZIP mit Originaldatei, `metadata.json` und `ocr_text.txt`, same-origin per Session-Cookie, kein Blob-Umweg) und "Neu verarbeiten". Erreichbar über den "Vorschau"-Button bzw. das Auge-Icon in der Dokumentenliste.
### Basis-Komponenten & Design-Tokens (FDN-06)
Alle wiederverwendbaren UI-Bausteine liegen ausschließlich unter `src/components/ui/` (shadcn/ui-Stand, Radix-basiert). Neue Features benutzen diese Komponenten, statt eigene Varianten zu bauen; Anpassungen erfolgen in der Datei selbst (nicht per Kopie), Dateinamen werden nie umbenannt.
| Komponente | Datei | Einsatzzweck |
|---|---|---|
| Table | `ui/table.tsx` | Standard-Listenansicht (Dokumente, Wiedervorlage, Admin-Tabellen). Wrapper hat `overflow-auto` → horizontal scrollbar auf schmalen Viewports |
| Dialog / Sheet | `ui/dialog.tsx`, `ui/sheet.tsx` | Modale Formulare bzw. mobile Drawer (Sidebar auf < md) |
| Formularelemente | `ui/input.tsx`, `ui/textarea.tsx`, `ui/label.tsx`, `ui/calendar.tsx` | Eingaben; Datumsauswahl über Calendar + Popover |
| Button | `ui/button.tsx` | Varianten (`default`/`ghost`/`outline`/`destructive`) + Größen über `cva` |
| Badge | `ui/badge.tsx` | Status-Anzeigen (Wiedervorlage, Verarbeitungsstatus, Share-Status) |
| Card | `ui/card.tsx` | Dashboard-Kacheln, Sektionen in Einstellungen |
| Tabs | `ui/tabs.tsx` | Reiter in der Dokument-Detailansicht (Details/Inhalt/Verlauf) |
| DropdownMenu / Popover / Command | `ui/dropdown-menu.tsx`, `ui/popover.tsx`, `ui/command.tsx` | Benutzermenü, Auswahl-Popover, Cmd+K-Befehlspalette |
| Toast | `ui/sonner.tsx` | Rückmeldungen nach Mutationen (`toast()` aus `sonner`); `<Toaster />` global in `src/app/layout.tsx` |
| Sidebar | `ui/sidebar.tsx` | Shell-Navigation inkl. Collapse-State und Mobile-Drawer |
| Weitere | `ui/avatar.tsx`, `ui/progress.tsx`, `ui/skeleton.tsx` | Benutzer-Avatar, Upload-Fortschritt, Skeleton-Loading in `loading.tsx` |
Die App-Shell selbst liegt in `src/components/shell/` (`AppSidebar`, `TopBar`, `SearchBar`, `CommandPalette`) und wird von `src/app/(app)/layout.tsx` zusammengesetzt (`SidebarProvider``AppSidebar` + `SidebarInset``TopBar` + Inhaltsbereich).
**Design-Tokens** sind zentral gepflegt: `src/app/globals.css` definiert die HSL-Werte je Theme (`:root` = hell, `.dark` = dunkel) für `background/foreground`, `card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`, die `sidebar-*`-Familie sowie `--radius`. `tailwind.config.ts` mappt sie auf Tailwind-Utilities (`bg-background`, `text-muted-foreground`, `border-border`, `rounded-lg` …). Abstände und Typografie kommen unverändert aus der Tailwind-Standardskala — bewusst kein eigener Satz, um Sonderwege zu vermeiden. Regel: keine Hex-/RGB-Literale und keine Inline-Styles für Farben in Komponenten. Einzige Ausnahme sind semantische Statusfarben (emerald/amber/red) an Badges und Farbbalken; Inline-`style` ist nur für berechnete Geometrie erlaubt (Progress-Balken, Crop-Rechtecke, OCR-Overlay-Boxen).
## Projektstatus
Scaffold + erstes fachliches Feature (Wiedervorlage/Reminder, PROJ-1) als Code geschrieben, **noch nicht gebaut/getestet** (kein Go/Node-Toolchain in dieser Umgebung ausgeführt). Vor Inbetriebnahme: `go build ./...`, `npm install && npm run build`, Migrationen gegen echte PostgreSQL-Instanz prüfen.
Upload/OCR-Pipeline (`POST /api/documents/upload`, WORM-Storage, Duplikaterkennung per Content-Hash) ist implementiert, siehe Abschnitt "Storage-Struktur & OCR" unten. Nicht enthalten: Tests, LDAP/TOTP.
## Hardware-Anforderungen
Grobe Schätzung aus Architektur (Go+PostgreSQL+Manticore+Next.js, analog archivmail-Stack) — **nicht durch Lasttests validiert**, Code noch nicht gebaut/getestet.
**Betriebssystem/Abhängigkeiten:**
- Debian 13 (trixie)
- Go ≥ 1.24 (empfohlen: 1.26.x, aktuell 1.26.5, Stand Juli 2026)
- Node.js ≥ 20 + npm (empfohlen: 24.x Active LTS, Stand Juli 2026 — Node 26 noch Current, LTS erst ab Oktober 2026)
- PostgreSQL ≥ 14 (empfohlen: 18.x, aktuell stabil 18.4, Stand Mai 2026 — Version 19 noch in Beta)
- Manticore Search ≥ 27.x (aktuell 27.1.5, Stand Juni 2026 — Built-in Auth, Sharded Tables, Vektor-/Hybrid-Suche)
- Tesseract OCR + poppler-utils (für Scan/OCR-Pipeline; ohne diese Pakete läuft der Server weiter, OCR wird dann übersprungen):
`apt install tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng poppler-utils`
- zbar-tools (für Barcode-Erkennung auf Trennblättern/Scans, `internal/barcode`; ohne dieses Paket läuft der Server weiter, Barcode-Decoding wird dann übersprungen):
`apt install zbar-tools`
| Szenario | vCPU | RAM | Disk |
|---|---|---|---|
| Minimal (Kleinbetrieb, <5 User, wenig Volumen) | 2 | 4 GB | 50 GB (wächst mit Archiv) |
| Empfohlen (KMU, OCR aktiv, Volltextindex) | 4 | 8 GB | nach Archivgröße¹ |
| Enterprise/Multi-Tenant mit Last | 8+ | 16+ GB | SSD Pflicht, separates Storage-Volume² |
¹ Faustregel: gescannte PDFs ~200KB2MB/Dokument, Manticore-Index zusätzlich ~2030% vom Rohtext-Volumen.
² WORM/Aufbewahrungsfristen bedeuten nur Wachstum, kein Löschen vor Fristablauf — Storage entsprechend planen.
## Storage-Struktur & OCR
`config.yml``storage.base_path` (ersetzt das alte `store_path`) ist die Wurzel für drei Unterverzeichnisse, die beim Start automatisch angelegt werden (`os.MkdirAll`):
```
<base_path>/inbox/<tenant_id>/<random>.<ext> Roh-Upload, vor Hashing/Verschieben
<base_path>/store/<tenant_id>/<yyyy>/<mm>/<sha256>.<ext> fertiges Archiv, WORM (chmod 0440)
<base_path>/ocr-tmp/<random>/ pdftoppm-Zwischenbilder, nach Gebrauch gelöscht
```
Ablauf beim Upload (`POST /api/documents/upload`, multipart, Feld `file` + Pflichtfeld `title`):
1. Datei wird nach `inbox/<tenant_id>/` geschrieben, dabei SHA-256 berechnet.
2. Zielpfad `store/<tenant_id>/<yyyy>/<mm>/<hash>.<ext>` wird ermittelt; existiert der Hash für den Mandanten bereits (Unique-Index `idx_documents_tenant_hash`), wird der Upload mit HTTP 409 als Duplikat abgelehnt.
3. Datei wird per `os.Rename` (Fallback: Kopieren+Löschen bei Cross-Device) verschoben und mit `chmod 0440` unveränderlich gemacht (WORM).
4. Dokument-Metadaten **und** ein Verarbeitungsjob (`processing_jobs`) werden in **einer** Postgres-Transaktion angelegt, Audit-Log-Eintrag geschrieben, Antwort 201 an den Client. Das Dokument steht auf `processing_status = 'queued'`.
5. OCR-Extraktion (`internal/ocr`), Taxonomie-Autozuordnung und `on_upload`-Workflows laufen **asynchron** über die Job-Queue (siehe unten) — nicht mehr im Request.
Weil OCR erst nachgelagert läuft, richtet sich der Archivordner `<yyyy>/<mm>` nach dem Upload-Zeitpunkt; das erkannte Belegdatum landet danach in `documents.document_date`, die archivierte Datei wird dabei nie verschoben (WORM).
### Storage-Abstraktion & signierte Download-Links (FDN-03)
Der Dateizugriff liegt hinter dem Interface `objectstore.Store` (`internal/objectstore`): `Archive` (Scratch-Datei → WORM-Ablage inkl. `chmod 0440`), `Open`, `Stat`, `Delete`, `SignedURL`, `VerifySignedURL`. Einzige Implementierung ist `LocalStore` (lokales Dateisystem) — **bewusst kein S3-Treiber**, weil die WORM-/GoBD-Garantie an POSIX-Rechten (0440) hängt. Das oben beschriebene Pfadschema bleibt unverändert; zusätzlich prüfen `Open`/`Stat`/`Delete` jetzt, dass der übergebene `storage_path` tatsächlich unter `store/<tenant_id>/` liegt (Mandanten-Containment gegen Pfad-Traversal/IDOR). Verdrahtet wird der Treiber in `Server.SetStorageConfig` (Signierschlüssel per HKDF-SHA256 aus `api.secret`, Link-Basis aus `server.fqdn`).
**Signierte, zeitlich begrenzte Download-URLs:** `POST /api/documents/{id}/signed-url` (authentifiziert, mandantengeprüft, Body optional `{"ttl_minutes": 15}`) liefert `{url, expires_at}`. Eingelöst wird der Link unter `GET /public/files?t=&d=&exp=&sig=` — ohne Session, die HMAC-SHA256-Signatur über `tenant|dokument|ablauf` ist das Credential. Gleiche Schutzmechanik wie die Share-Links (harter Pflicht-Ablauf, per-IP-Rate-Limit, Audit-Trail `signed_url_created`/`signed_url_accessed` inkl. Fehlschlägen), aber zustandslos, ohne DB-Zeile und ohne Widerruf — für kurzlebigen Maschinenzugriff. Für Weitergabe an Dritte mit eigenem Lebenszyklus (Widerruf, Passwort, Zugriffslimit) bleiben die Share-Links das Mittel der Wahl. Abgelaufener Link → HTTP 410, gefälschter → 403, fehlende Datei → 404. Gültigkeit: `storage.signed_url_ttl_minutes` (Default 15), pro Anfrage überschreibbar, hart gedeckelt auf 24 h.
### Trennseiten-Split (Barcode-Trennblätter)
`internal/pagesplit` zerlegt mehrseitige **PDF**-Scans beim Ingest an Barcode-Trennblättern in Einzeldokumente (Vorbild: Paperless-ngx, an die archivdms-Pipeline angepasst). Ablauf im synchronen Staging-Schritt, **bevor** irgendetwas archiviert wird (Schritt 1a in `storeUploadedFile`): `pdfinfo` liefert die Seitenzahl, `pdftoppm -r 150` rastert jede Seite einmal, `zbarimg` (via `internal/barcode`) dekodiert die Barcodes; Seiten mit dem konfigurierten Marker (`pagesplit.marker`, Default `ARCHIVDMS-SPLIT`, Vergleich case-insensitiv, optional Präfix-Match) gelten als Trennblatt. Die Segmente dazwischen werden mit `pdfseparate` + `pdfunite` (poppler-utils, kein qpdf/pdftk) zu je einem Teil-PDF zusammengesetzt; **das Trennblatt selbst wird verworfen**. Jedes Teildokument durchläuft danach exakt denselben Pfad wie ein normaler Einzel-Upload (eigener Hash + Duplikatprüfung, eigene WORM-Ablage mit `chmod 0440`, eigener Verarbeitungsjob mit OCR/Taxonomie/Workflows).
Fail-safe: Detektor aus, fehlende Binaries, Nicht-PDF, weniger als zwei Seiten, mehr als `max_pages`, kein Trennblatt gefunden oder ein Fehler beim Split → das Dokument wird **ungesplittet** wie bisher archiviert, ein Upload wird nie deswegen abgelehnt. Ein nur teilweise erzeugter Split wird nie ausgeliefert.
GoBD: Der hochgeladene Stapel wird bei einem Split *nicht* zusätzlich archiviert (sonst läge jede Seite doppelt im Archiv). Nachvollziehbar bleibt die Aufteilung über das Audit-Event **`document_split`**, das Originaldateinamen, SHA-256 des Originals, Seitenzahl, die verworfenen Trennblatt-Seitennummern sowie je Teildokument Seitenbereich und Dokument-ID festhält — Erfolg *und* Fehlschlag (`success=false`, wenn ein Split angefangen und abgebrochen wurde).
Voraussetzung: `apt install poppler-utils zbar-tools`. Default **aus** (`pagesplit.enabled: false`), bewusst konservativ wie `ocr.binarize_ocr`. Aktuell globaler Config-Schalter; eine Mandanten-Einstellung samt UI ist der nächste Schritt.
### Job-Queue (Mandanten-Fairness)
`internal/jobqueue` arbeitet die Nachverarbeitung in Worker-Goroutinen **im selben Prozess** ab (kein separater Dienst, kein Redis — die Queue ist die Postgres-Tabelle `processing_jobs`). Dispatch läuft **Round-Robin über die Mandanten**: pro Runde wird je Mandant genau ein Job gezogen (`FOR UPDATE SKIP LOCKED`), damit ein Massen-Upload eines Mandanten die anderen nicht aushungert. Fehlgeschlagene Jobs werden mit exponentiellem Backoff (2^`retry_count` Sekunden) neu eingeplant und bleiben ab `max_retries` dauerhaft auf `failed`. Ein Reaper setzt hängende `processing`-Jobs nach `job_timeout_seconds` zurück. Erfolg *und* Fehlschlag werden als Audit-Event `document_processed` protokolliert.
**Sichtbarkeit in der UI:** `documents.processing_status` (`queued`/`processing`/`done`/`failed`) wird mit der Dokumentenliste servergerendert ausgeliefert. Die Liste (`DocumentsTable`) und die Vorschau zeigen für alles außer `done` ein Badge („Warteschlange" / „Wird verarbeitet …" / „Fehlgeschlagen"); bei `failed` gibt es einen Button „Erneut versuchen". Endpunkte: `GET /api/documents/{id}/processing-job` (Status, `retry_count`, `error_message`) und `POST /api/documents/{id}/processing-job/retry` (nur aus `failed` heraus, sonst 409). Das Frontend pollt den GET-Endpunkt alle 3 s **nur** für Dokumente, die gerade nicht fertig sind — ist die Ansicht durchverarbeitet, läuft kein Timer und es entsteht kein Traffic.
Relevante Config-Keys (siehe `config/config.yml.example`):
```yaml
storage:
base_path: "/var/lib/archivdms" # enthält inbox/, store/, ocr-tmp/
retention_days: 3650
max_upload_size_mb: 50
signed_url_ttl_minutes: 15 # Default-Gültigkeit signierter Download-Links
ocr:
tesseract_path: "tesseract"
pdftoppm_path: "pdftoppm"
languages: "deu+eng"
timeout_seconds: 60
pagesplit:
enabled: false # Trennseiten-Split, Default AUS
marker: "ARCHIVDMS-SPLIT"
marker_prefix: false
raster_dpi: 150
max_pages: 200
timeout_seconds: 120
jobqueue:
disabled: false # true = keine Nachverarbeitung, Dokumente bleiben "queued"
workers: 2
poll_interval_ms: 2000
job_timeout_seconds: 600
max_retries: 5
index:
# Volltext-Index (Manticore, MySQL-Protokoll Port 9306). Leer = deaktiviert.
# Phase 1: nur Schreib-/Sync-Layer, noch KEIN Such-Endpunkt.
manticore_dsn: "" # z.B. "archivdms@tcp(127.0.0.1:9306)/?charset=utf8mb4"
```
### Volltext-Index (Manticore)
Sekundärer Volltext-Index (`internal/index`) über Manticore Search (MySQL-Protokoll, Port 9306, nur `127.0.0.1` gebunden, `github.com/go-sql-driver/mysql`, CGO-frei). **PostgreSQL bleibt Source of Truth** — der Index wird nur best-effort synchron gehalten: jeder Sync-Fehler wird geloggt, blockiert aber nie den auslösenden Request. Ist `index.manticore_dsn` leer, ist der Indexer `nil` und alle Sync-/Suchaufrufe sind No-ops (reproduzierbarer Start ohne manuelle Zusatzschritte: DSN in `/etc/archivdms/config.yml` setzen, Backend/Systemd-Dienst startet den Rest selbst — RT-Tabellen werden lazy beim ersten Tenant-Zugriff angelegt).
Pro Mandant existiert eine eigene RT-Tabelle `documents_tenant_<tenant_id>` (Tabellenname gegen Injection validiert, Mandantentrennung über getrennte Tabellen statt Row-Filter). Synchronisiert wird nach Upload/Create, ACL-/Tag-/Dokumenttyp-Änderung (RecomputeVisibility), Korrespondent- und Custom-Field-Änderung; beim Verschieben in den Papierkorb sowie bei finaler Löschung (Vier-Augen-bestätigt) wird der Eintrag aus dem Index entfernt (GoBD: endgültig gelöschte Dokumente dürfen nicht mehr auffindbar sein), beim Wiederherstellen neu indexiert. Ein manueller Neuaufbau des kompletten Index aus dem Postgres-Bestand ist jederzeit über `archivdms reindex [-tenant N]` möglich (streamt in Batches von 500 Dokumenten, bricht laut ohne exit(1) ab statt einen fehlenden `manticore_dsn` als stillen No-op zu behandeln). Such-Endpunkt: `GET /api/documents/search` (ACL-gefiltert; Manticore liefert nur IDs+Score, die vollständigen Dokumentzeilen kommen aus Postgres).
**Index-Schema** (`internal/index/manticore.go`, `ensureTable`):
| Feld | Typ | Zweck |
|---|---|---|
| `doc_id` | string | Anzeige-ID (String-Form von `id`) |
| `title` | text | Volltext-durchsucht |
| `doc_type` | text | Volltext-durchsucht (Legacy-Freitext) |
| `correspondent` | text | Volltext-durchsucht (Legacy-Freitext) |
| `ocr_text` | text | Volltext-durchsucht, OCR-Ergebnis |
| `tags` | text | Volltext-durchsucht, Tag-Namen als Leerzeichen-separierter String |
| `tag_ids` | multi (MVA) | Attribut-Filter (Tag-Facette) |
| `doc_type_id` / `correspondent_id` | bigint | Attribut-Filter (Taxonomie-IDs) |
| `acl_group_ids` | multi (MVA) | Attribut-Filter für ACL (Sichtbarkeitsgruppen) |
| `retain_until_ts` / `created_ts` / `updated_ts` | bigint (Unix) | Zeitstempel-Attribute |
| `deleted` | uint | Soft-Delete-Flag, `Search` filtert immer `deleted = 0` |
**Gewichtung**: Die Volltext-Suche matcht gleichgewichtet über `@(title,ocr_text,tags,correspondent,doc_type)` (kein `field_weights`-Boost) — Ranking erfolgt rein über Manticores BM25 (`WEIGHT()`), sortiert nach `WEIGHT() DESC, created_ts DESC`. Bewusst MVP: keine Feldgewichtung, um keine Suchsyntax-Erwartungshaltung/Tuning-Aufwand vor dem ersten echten Nutzungs-Feedback aufzubauen. Eine spätere Gewichtung (z. B. `title` stärker als `ocr_text`) ist eine reine Query-Änderung in `Search`, kein Schema-Umbau.
**Deutsche Sprachbehandlung**: Die Tabelle wird mit `morphology='lemmatize_de_all,stem_en'` angelegt — deutsche Lemmatisierung (u. a. Kompositazerlegung, Umlaut-Normalisierung über Manticores eingebautes de-Wörterbuch) plus englisches Stemming für Mischtexte. Keine explizite Stoppwortliste konfiguriert (Manticore filtert ohne `stopwords=`-Option keine Füllwörter heraus) — bei BM25-Ranking wirkt sich das nur moderat auf die Relevanz aus, da seltene Terme ohnehin höher gewichtet werden; bei Bedarf lässt sich eine deutsche Stoppwortliste (`stopwords = de`) nachrüsten, das erfordert danach einen vollen Reindex (`archivdms reindex`).
### OCR-Wortkoordinaten & Vorschau-Overlay
Tesseract liefert pro Wort eine Bounding-Box (TSV), die in der Tabelle `ocr_words` persistiert und über `GET /api/documents/{id}/ocr-words` ausgeliefert wird (JSON-Array, Keys `text/left/top/width/height/confidence/page/block/par/line`, leeres Ergebnis `[]`). Die Koordinaten sind Pixelwerte im Koordinatenraum des Original-Bildes.
Im Frontend legt `DocumentImagePreview` einen absolut positionierten Overlay-Layer über die Bildvorschau und rechnet die Boxen per `min(clientWidth/naturalWidth, clientHeight/naturalHeight)` inkl. `object-contain`-Letterbox-Versatz um; ein `ResizeObserver` hält das Overlay bei Sidebar-Drag, Resize und Vollbildwechsel deckungsgleich. Auf dieser Berechnung liegen drei Schichten: der Debug-Umschalter „OCR-Boxen" (Rahmen je Wort, rein visuell), das **Suchtreffer-Highlight** (amberfarbene Marker, ohne Toggle sichtbar) und die **Copy/Select-Textebene** (pro Wort ein transparentes, exakt auf die Box skaliertes `<span>` mit echtem Text — Markieren und Kopieren funktioniert damit wie in einer durchsuchbaren PDF; nur diese Spans haben `pointer-events`, die Bildfläche dazwischen bleibt normal bedienbar). Den Suchbegriff fürs Highlight reicht die Suchergebnisseite über `?q=` an `/documents/{id}` durch (`DocumentsTable`-Prop `highlightQuery` → Vorschau-Links → `searchParams``DocumentPreview``DocumentImagePreview`); Matching ist ein case-insensitiver Teilstring-Vergleich pro Token, serverseitig hat Manticore bereits gefiltert. Nur für Bilddokumente — PDFs werden im `<iframe>` gerendert und bringen ihre eigene Textebene mit.
## SFTP-Zugang pro Mandant
Alternative zum Web-Upload: ein eingebetteter SFTP-Server (`internal/sftpserver`, `golang.org/x/crypto/ssh` + `github.com/pkg/sftp`) läuft direkt im `archivdms`-Binary — kein separater OpenSSH-Prozess, keine echten Linux-System-User pro Mandant. Ein Mandant, der per SFTP eine Datei ablegt, landet virtuell "eingesperrt" in seinem eigenen `inbox/<tenant_id>/`-Ordner (Software-Chroot, keine OS-Chroot-Mechanik); ein Watcher (Polling alle 5s) holt neue Dateien dort ab und schickt sie durch **dieselbe** Pipeline wie `POST /api/documents/upload` (Hash, Duplikaterkennung, Move nach `store/`, chmod 0440, Job-Anlage, Audit-Log) — OCR/Taxonomie/Workflows laufen anschließend über dieselbe Job-Queue.
Aktivierung in `config.yml`:
```yaml
sftp:
enabled: true
bind: ":2222"
host_key_path: "" # leer = <storage.base_path>/.ssh/host_key, wird beim ersten Start generiert
```
Zugangsdaten sind vom normalen Login getrennt — ein SFTP-Zugang ist ein eigenständiges, jederzeit widerrufbares Credential (Tabelle `sftp_credentials`, bcrypt-gehashtes Passwort), kein volles Benutzerkonto. Ein Admin (domain_admin/superadmin) legt einen Zugang über die Admin-API an:
```
POST /api/admin/sftp-credentials { "username": "mandant1-sftp" }
-> Antwort enthält das Passwort im Klartext, aber nur dieses eine Mal
GET /api/admin/sftp-credentials
DELETE /api/admin/sftp-credentials/{id}
```
Danach kann der Mandant sich direkt per SFTP-Client verbinden und Dateien flach in sein Wurzelverzeichnis hochladen (v1: kein Anlegen/Navigieren von Unterordnern):
```
sftp -P 2222 mandant1-sftp@dms.example.com
put rechnung.pdf
```
## Tenant-Verwaltung (Mandanten)
Mandanten werden über eine superadmin-only API verwaltet (`internal/api/tenant_handlers.go`):
```
POST /api/tenants { "name": "...", "slug": "...", "domain": "..." } -> legt einen Mandanten an
GET /api/tenants -> listet alle Mandanten
```
Frontend: `/settings/tenants` (nur für Rolle `superadmin` sichtbar/aufrufbar, Server-Component-Rollencheck über `GET /api/auth/me`; für andere Rollen nur ein Hinweistext statt Formular). Beim Anlegen eines Users (`POST /api/users`) darf ein Superadmin `tenant_id` explizit im Body angeben (auch `null` für einen weiteren Superadmin); für `domain_admin` wird `tenant_id` serverseitig immer hart auf den eigenen Tenant erzwungen — ein im Body mitgeschickter abweichender Wert wird ignoriert (IDOR-Schutz gegen Cross-Tenant-User-Anlage).
Benutzerverwaltung: `/settings/users` (für `domain_admin` und `superadmin`; gleicher Server-Component-Rollencheck, andernfalls nur Hinweistext). Liste aller Konten des eigenen Mandanten mit Anlegen (Dialog: Benutzername, E-Mail, Passwort, Rolle), Bearbeiten (E-Mail, Rolle, Aktiv-Status, optionaler Passwort-Reset) und Löschen mit Bestätigungsdialog. Ein `domain_admin` kann nur die Rollen `user`/`domain_admin` vergeben, ein `superadmin` zusätzlich `superadmin`. Der Menüpunkt "Benutzer" in der Sidebar erscheint nur bei passender Rolle; die eigentliche Autorisierung erzwingt weiterhin das Backend (`requireRole`).
Benutzerdefinierte Felder: `/settings/custom-fields` (admin-only, Server-Component-Rollencheck). Definition von Zusatzfeldern mit Typ `text`/`number`/`date`/`boolean`/`enum`/`monetary` (`enum` mit Optionsliste, `monetary` mit optionaler Währung). Technischer Name und Typ sind nach dem Anlegen unveränderlich (Backend erzwingt), Bearbeiten betrifft nur Label/Optionen/Währung; Löschen ist blockiert, solange noch Werte referenzieren (409). Die Zuordnung welcher Felder bei welchem Dokumenttyp erscheinen (inkl. `required`/`visible`/Reihenfolge) erfolgt als Sektion unter `/settings/document-types` (Bulk-`PUT /api/document-types/{id}/fields`). Pro Dokument werden die zugeordneten (sichtbaren) Feldwerte über den Button "Felder" in der Dokumentenliste bearbeitet (`GET`/`PUT /api/documents/{id}/fields`, typgerechte Eingaben, Pflichtfeld-Validierung serverseitig).
Klassifizierungsvorlagen: `/settings/classification-templates` (admin-only, Server-Component-Rollencheck). Eine Vorlage bündelt Dokumenttyp, Tags, Custom-Field-Vorgaben (je Feld mit optionalem Overwrite-Flag) und eine Aufbewahrungsdauer (`retain_years`), ohne persistent an Dokumente gekoppelt zu sein — spätere Vorlagen-Änderungen wirken nie rückwirkend, jede Anwendung wird nur im Audit-Log festgehalten (GoBD-Nachvollziehbarkeit). Verwaltung (CRUD + Bulk-Replace von Tags/Feld-Vorgaben) über `GET/POST/PUT/DELETE /api/classification-templates[/{id}]` sowie `PUT .../{id}/tags` und `PUT .../{id}/field-defaults`. Anwendung auf ein Dokument über den Button "Vorlage anwenden" in der Dokumentenliste (`POST /api/documents/{id}/apply-template`): eine Dry-Run-Vorschau zeigt zunächst das Diff (Tags hinzufügen/vorhanden, Felder setzen/überschreiben/überspringen, Aufbewahrung vorher→nachher). Eine Aufbewahrungsfrist wird nie verkürzt (auch nicht mit Overwrite — dann Hinweis "blockiert"); bereits gesetzte Feldwerte werden nur bei explizit angehaktem Overwrite überschrieben.
Aufbewahrungsregeln (Retention Rules): `/settings/retention-rules` (admin-only, Server-Component-Rollencheck über `GET /api/auth/me`, sonst Hinweistext). Verwaltung GoBD-konformer Aufbewahrungsfristen je Dokumenttyp (`doc_type_id = null` = mandantenweiter Default). Pro Regel: Auslöser (`document_date`/`upload_date`/`fixed_date`/`event`; bei `fixed_date` ein Datum `YYYY-MM-DD`), Frist in Jahren + zusätzlichen Tagen, Rechtsgrundlage (z.B. §147 AO), Freigabepflicht für die Vernichtung, DSGVO-Konflikt-Flag und Aktiv-Status. CRUD über `GET /api/retention-rules`, `POST /api/retention-rules`, `PATCH /api/retention-rules/{id}`, `DELETE /api/retention-rules/{id}` (Mutationen domain_admin/superadmin). Dashboard-Kachel "Warten auf Löschfreigabe" zählt Dokumente mit abgelaufener Frist, die noch nicht im Papierkorb sind (`GET /api/retention-rules/eligible`); Dry-Run-Vorschau über `GET /api/retention-rules/preview`.
Verfahrensdokumentation (GoBD, Entwurf): `GET /api/compliance/procedure-documentation` liefert einen live aus dem Datenbankstand erzeugten Markdown-Baustein einer Verfahrensdokumentation als Download (`text/markdown`, `verfahrensdokumentation-entwurf-<mandant>-<datum>.md`). Erfordert domain_admin/superadmin; ein superadmin kann über `?tenant_id=N` einen fremden Mandanten exportieren, alle anderen Rollen erhalten dabei 403. Automatisch befüllt werden Aufbewahrungsfristen, Zugriffsschutz (Rollen, Gruppen, Grants), Löschkonzept (WORM + Vier-Augen-Prinzip), Erfassungsautomatisierung (Workflows/Klassifizierungsvorlagen) und Nachvollziehbarkeit (Audit-Log); Organisationsbeschreibung, Verantwortliche und Server-/Backup-/Notfallkonzept sind als `[MANUELL ZU ERGÄNZEN]` gekennzeichnete Platzhalter. Das Ergebnis ist ausdrücklich ein Entwurf und kein rechtsverbindliches Fertigdokument. Frontend: Abschnitt "GoBD-Verfahrensdokumentation" am Fuß von `/settings/retention-rules` mit Button **"Verfahrensdokumentation (Entwurf) herunterladen"** (`ProcedureDocumentationDownload`, Blob-Download mit Session-Cookie) und Entwurfs-Warnhinweis.
## Installation
Native Installation auf Debian 13 (trixie), z.B. in einem unprivilegierten Proxmox-LXC-Container — kein Docker. Es gibt (noch) kein Git-Remote-Repository für archivdms; `install.sh`/`update.sh` erwarten daher, dass der Quellcode bereits lokal auf dem Zielserver liegt (z.B. per `rsync`/`scp` kopiert), statt per `git clone`/`git pull` zu arbeiten.
```
scp -r . root@<server>:/opt/archivdms-src
ssh root@<server>
cd /opt/archivdms-src
ARCHIVDMS_SRC=/opt/archivdms-src bash install.sh
```
`install.sh` installiert Go/Node/PostgreSQL/Manticore/Tesseract, legt Systembenutzer und Storage-Struktur an, generiert `config.yml` sowie ein selbstsigniertes TLS-Zertifikat (Let's-Encrypt optional per `certbot --nginx`), richtet nginx und die systemd-Dienste `archivdms`/`archivdms-web` ein und ruft für den ersten Build `update.sh` auf.
Für spätere Deploys aus einem neuen lokalen Codestand genügt danach:
```
bash update.sh
```
`update.sh` baut aus dem lokalen Quellverzeichnis neu (kein `git pull`), synchronisiert nach `/opt/archivdms` und startet die Dienste neu. Details und Kommentare zum "Warum" einzelner Entscheidungen stehen direkt in den Skripten.
## Beleg-Erfassung per Handy
Route `/scan` (in der Sidebar als "Beleg erfassen") öffnet auf mobilen Browsern direkt die Rückkamera (`<input type="file" capture="environment">`), kein natives App nötig. Ein Foto genügt, Titel wird automatisch aus Datum/Uhrzeit gesetzt, Upload läuft über dieselbe Pipeline wie `/documents` (Hash, WORM-Ablage, OCR, automatisches Tag-/Korrespondent-Matching). Dokumenttyp/Korrespondent lassen sich danach unter `/documents` nachträglich ergänzen.
## Migrationen & Entwicklungs-Seed (FDN-02)
Schemaänderungen laufen weiterhin über die idempotenten `initSchema()`-Funktionen
der Store-Pakete (kein Migrationstool). Ergänzend gilt: zu jeder neuen
`internal/storage/migrations/NNN_name.sql` gehört eine `NNN_name.down.sql` mit
reviewtem Rückbau-SQL (manuell per `psql -f` auszuführen, nie automatisch).
Details und Anforderungen: `internal/storage/migrations/README.md`.
Für eine frische lokale Datenbank legt
```
archivdms seed dev [-config PATH] [-name NAME] [-slug SLUG] [-email EMAIL] [-role ROLE] [-reset-password]
```
einen Test-Mandanten ("Testfirma") und einen tenant-gebundenen Test-Benutzer
(`testuser@testfirma.local`, Rolle `domain_admin`) an. Der Befehl ist idempotent
(Mandant über Slug, Benutzer über E-Mail); das Passwort wird zufällig erzeugt und
einmalig auf der Konsole ausgegeben — es steht nirgends im Code oder in der Config.
## Struktur
```
cmd/archivdms/ CLI-Einstiegspunkt (serve, reminders notify, seed dev)
config/ YAML-Konfiguration
internal/api/ HTTP-Handler (auth, user, audit, document, reminder, sftp-credentials)
internal/audit/ Append-only Audit-Log (GoBD-Nachvollziehbarkeit)
internal/auth/ JWT-Session-Handling
internal/mailer/ SMTP-Versand (Benachrichtigungen)
internal/sftpserver/ Eingebetteter Per-Mandant-SFTP-Server + Inbox-Watcher
internal/storage/ Postgres-Schema (documents, reminders, sftp_credentials) + Migrations-Doku
internal/tenantstore/ Mandantenverwaltung
internal/userstore/ Benutzerverwaltung
deploy/cron.d/ Cron-Jobs (Wiedervorlage-Benachrichtigung)
features/ Feature-Spezifikationen (PROJ-N-name.md)
src/ Next.js-Frontend
```
## Features (Ziel, siehe `dms-featureliste-prompt.md`)
Umgesetzt: Grundgerüst (Auth, Tenant, Audit, Mailer), Dokument-Kernmodell, Dashboard-Startseite (Kennzahlen-Kacheln + Dokumente-nach-Typ, Server-Component), Wiedervorlage (PROJ-1), Papierkorb mit gestaffelter Löschung (Soft-Delete + Vier-Augen-Prinzip, Aufbewahrungsfrist-Prüfung), Berechtigungsmodell (gruppenaufgelöste Dokument-ACL).
Geplant: GoBD-Compliance (WORM, Aufbewahrungsfristen-Engine), ZUGFeRD/XRechnung-Parser, Hybrid-Suche, Workflow-Engine, DATEV-Schnittstelle, OIDC/SSO, Kanban-Wiedervorlage-Ansicht, später nativer Linux-Client (nutzt dieselbe REST-API).
### Berechtigungsmodell (Dokument-ACL)
Zugriff auf Dokumente wird über **Berechtigungsgruppen** (nie direkt pro User) vergeben und über drei Ebenen aufgelöst, spezifischer schlägt allgemeiner: (1) Einzeldokument-Grant (`document_grants`, inkl. `deny`), (2) Tag-Grant (`tag_grants`), (3) Dokumenttyp-Default (`document_type_grants`). Das Ergebnis wird nach `document_visibility` materialisiert (`RecomputeVisibility`, neu berechnet bei Grant-, Tag- oder Dokumenttyp-Änderung). Rollen bleiben Außengrenze: `superadmin` sieht alles mandantenübergreifend, `domain_admin` sieht per Default alles im eigenen Mandanten (bypasst die ACL), nur Rolle `user` wird gegen `document_visibility` gefiltert.
API (alle `domain_admin`+): `POST/GET/DELETE /api/permission-groups`, `GET/POST/DELETE /api/permission-groups/{id}/members`, `GET/POST/DELETE /api/document-types/{id}/grants`, `GET/POST/DELETE /api/tags/{id}/grants`, `GET/POST/DELETE /api/documents/{id}/grants` (access `read|write|deny`). Die GET-Endpunkte liefern Mitglieder (`{user_id,username,email}`) bzw. aufgelöste Grants (`{group_id,group_name,access}`). Schema-Doku: `internal/storage/migrations/008_permissions.sql`.
Frontend (admin-only, Server-Component-Rollencheck + Sidebar-Eintrag "Berechtigungsgruppen"): `/settings/permission-groups` verwaltet Gruppen (anlegen/löschen) und ihre Mitglieder (User-Picker aus dem Mandanten). Default-Grants je Dokumenttyp bzw. Tag werden als Sektion "Zugriffsrechte" unter `/settings/document-types` und `/settings/tags` gesetzt (Gruppe + Lesen/Schreiben). Einzeldokument-Freigaben inkl. `deny` (Sperren) über den Button "Freigaben" in der Dokumentenliste. Die UIs laden den tatsächlichen Serverstand (Mitglieder beim Öffnen des Dialogs, Grants beim Auswählen der Entität) und halten ihn nach jeder Mutation synchron.
### Externe Share-Links
Ablaufende, optional passwortgeschützte öffentliche Links auf **ein** Dokument. Der Token (32 Byte, base64url) wird nur beim Erzeugen genau einmal zurückgegeben; gespeichert wird ausschließlich sein SHA-256-Hash. `expires_at` ist Pflicht (kein unbegrenzter Share), `password` (bcrypt) und `max_accesses` optional. Kein Hard-Delete — Revoke setzt nur `revoked_at`/`revoked_by`. Jeder öffentliche Zugriffsversuch wird in `document_share_accesses` protokolliert (`success`/`expired`/`revoked`/`max_reached`/`bad_password`/`rate_limited`) und zusätzlich im Audit-Log (`EventShareAccessed`).
Authentifiziert (tenant-scoped): `POST /api/documents/{id}/shares` (Body: `expires_at` Pflicht, optional `max_accesses`, `password`), `GET /api/documents/{id}/shares`, `DELETE /api/shares/{share_id}`, `GET /api/shares` (`domain_admin`+, alle Shares des Mandanten). Öffentlich (ohne Auth, per-IP rate-limited): `GET /public/share/{token}` (Metadaten: Titel, ob Passwort nötig, Ablauf), `POST /public/share/{token}/download` (Body optional `{password}`, streamt die Datei serverseitig aus dem WORM-Store — `storage_path`/`content_hash` werden nie exponiert). Prüfreihenfolge beim Abruf: revoked → expired → max_accesses → Passwort → Auslieferung (`access_count++` atomar). Schema-Doku: `internal/storage/migrations/009_shares.sql`.
Frontend: Button **"Teilen"** in der Dokumentenliste öffnet `ShareDialog` — listet bestehende Freigaben (Status-Badge grün=aktiv / rot=abgelaufen bzw. Limit erreicht / blass=widerrufen, Ablauf, Zugriffszähler, Widerrufen-Button) und erzeugt neue (Ablaufdatum Pflicht via `datetime-local`, optional max. Zugriffe + Passwort). Der Klartext-Token wird nach dem Erzeugen **einmalig** prominent als kopierbarer Link (`/public/share/<token>`) angezeigt. Admin-Übersicht aller Mandanten-Shares unter `/settings/shares`. Die öffentliche, login-freie Seite liegt außerhalb der `(app)`-Route-Gruppe unter `src/app/public/share/[token]/page.tsx` (kein App-Shell) und ist in `middleware.ts` via `PUBLIC_PATHS = ["/login", "/public"]` vom Auth-Gate ausgenommen. Da die Backend-`/public/*`-Routen nicht unter `/api` liegen, proxyt `next.config.ts` sie über den kollisionsfreien Prefix `/public/api/* → <backend>/public/*`.
### Buchhaltungs-Pull-API
Lesender Maschinen-Zugang, damit ein Buchhaltungssystem belegdatum-bewertete Dokumente abholen kann.
**API-Keys je Mandant** (`accounting_api_keys`): Rohkey `adms_` + 32 Byte base64url, wird **genau einmal** beim Erzeugen zurückgegeben, gespeichert wird nur der SHA-256-Hash (Muster wie Share-Links). Kein Hard-Delete — Revoke setzt `revoked_at`. Verwaltung mit normaler Session, `domain_admin`+: `POST /api/accounting/api-keys` (Body `{label}``{key, plaintext_key}`), `GET /api/accounting/api-keys` (ohne Klartext/Hash), `DELETE /api/accounting/api-keys/{id}`. Frontend: `/settings/accounting-keys` (admin-only, Server-Component-Rollencheck über `GET /api/auth/me`) — Tabelle mit Bezeichnung, Erstellt, Zuletzt verwendet und Status-Badge (grün=aktiv, blass=widerrufen), Anlege-Dialog mit Bezeichnung und Widerruf-Dialog mit Warnung. Der Klartext-Key wird nach dem Anlegen **einmalig** in einem eigenen Dialog mit Warnhinweis und Kopieren-Button angezeigt und beim Schließen aus dem Client-State verworfen.
**Pull-Endpunkte** (Auth ausschließlich per `Authorization: Bearer <key>`, keine Cookie-Session, per-IP rate-limited): `GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit=` liefert `{documents, next_cursor, has_more}` mit Cursor-Pagination über `(created_at, id)` und nur reduzierten Metadaten (id, title, document_date, document_date_score, doc_type(_id), correspondent(_id), created_at) — `storage_path`/`content_hash`/`ocr_text` werden nie exponiert. `GET /api/v1/accounting/documents/{id}/file` streamt die WORM-Datei serverseitig. `since`/`until` grenzen `document_date` ein (`YYYY-MM-DD` oder RFC3339), `min_date_score` ist der Konfidenz-Filter (z.B. `0.75`), `limit` max. 500 (Default 100).
**Mandantentrennung:** Der Mandant wird ausschließlich aus dem aufgelösten API-Key abgeleitet und in den Request-Context gelegt; ein `?tenant_id=` wird ignoriert. Fremde/unbekannte Dokumente liefern 404 (kein 403). Jeder Datei-Download, jede Listenabfrage (mit `count`/id-Range) und jeder abgelehnte Auth-Versuch landen im Audit-Log (`accounting_pull`, `accounting_key_created`, `accounting_key_revoked`). Schema-Doku: `internal/storage/migrations/026_accounting_api_keys.sql`.
Bewusst **nicht** enthalten (erster Schritt): Changes-Feed für geänderte/gelöschte Belege, Betragserkennung.
## Zukunftsnotiz
Linux-Desktop-Client ist geplant, aber nachrangig — erst Web-UI/API vollständig, Client greift dann auf dieselbe API zu (Token-Auth von Anfang an vorgesehen).