Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
7
docs/adr/0001-mariadb-als-fuehrende-datenbank.md
Normal file
7
docs/adr/0001-mariadb-als-fuehrende-datenbank.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# ADR 0001: MariaDB statt PostgreSQL
|
||||
Status: angenommen (26.09.2026, Vorgabe des Betreibers: MariaDB 11.8 ist bereitgestellt)
|
||||
**Entscheidung:** MariaDB ist die führende Datenbank, utf8mb4_unicode_ci, Zugriff über `mysql2` mit eigenen SQL-Migrationen.
|
||||
**Vorteile:** keine zweite Datenbank im Betrieb, vorhandenes Backup/Know-how. **Nachteile:** kein Row-Level-Security, DDL nicht transaktional.
|
||||
**Folgen:** Mandantentrennung erfolgt in der Policy-Schicht und in Abfragen (`canInOrg`) und wird per Integrationstest geprüft.
|
||||
Audit-Unveränderlichkeit: Hash-Kette (`audit_events`); zusätzlich soll die Anwendung später eine DB-Rolle mit nur INSERT/SELECT auf dieser Tabelle nutzen (offen).
|
||||
Alle DB-Verbindungen laufen in UTC (`SET time_zone='+00:00'`).
|
||||
6
docs/adr/0002-modularer-monolith.md
Normal file
6
docs/adr/0002-modularer-monolith.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# ADR 0002: Modularer Monolith (Fastify + Next.js + Worker)
|
||||
Status: angenommen
|
||||
**Entscheidung:** Drei Prozesse (API, Worker, Web) in einem pnpm-Monorepo. Fachlichkeit in Modulen (`KcModule`) mit eigenen Routen und Rechten.
|
||||
Fastify statt NestJS: weniger Abhängigkeiten, gleiche Modulgrenzen durch eigenes Modulmanifest. Jobs zunächst in MariaDB (`jobs`, `FOR UPDATE SKIP LOCKED`); Redis ist nicht nötig und kann später ergänzt werden.
|
||||
**Regeln:** Module importieren einander nicht; Kommunikation über `core/*` und Jobs. Connectoren werden als eigene Module/Pakete hinter einem Connectorvertrag ergänzt (Ticket KUNDENCENT-6).
|
||||
**Bekannte Abweichung:** Config-Lader ist in `apps/api` und `apps/worker` dupliziert; bei weiterem Wachstum in ein Paket `packages/config` auslagern.
|
||||
1
docs/api-specs/keyhelp-api-2.15.openapi.json
Normal file
1
docs/api-specs/keyhelp-api-2.15.openapi.json
Normal file
File diff suppressed because one or more lines are too long
77
docs/betrieb-backup-restore.md
Normal file
77
docs/betrieb-backup-restore.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Betrieb: Backup und Wiederherstellung
|
||||
|
||||
## Neu: Passwort und Ziele in der Oberfläche
|
||||
Unter **Einstellungen → Backup** (nur Superadministratoren dürfen ändern):
|
||||
- **Backup-Passwort**: selbst wählbar, mindestens 11 Zeichen (länger als 10), mit Wiederholung und Bestätigung durch das Anmeldepasswort. Neue Sicherungen werden damit per **gpg (AES-256, Integritätsschutz)** verschlüsselt (`*.tar.gz.gpg`). Das Passwort liegt mit dem Master-Schlüssel verschlüsselt in der Datenbank, wird nie angezeigt oder protokolliert und lässt sich **nicht wiederherstellen**. Nach einer Änderung bleiben ältere Sicherungen mit dem alten Passwort verschlüsselt. Ohne Passwort und ohne age-Schlüssel wird nichts gesichert (nie unverschlüsselt).
|
||||
- **Externe Ziele**: SFTP (Passwort oder Schlüssel; Server-Schlüssel wird beim ersten Test gemerkt und angezeigt, ein anderer Server wird danach abgelehnt), FTP/FTPS, Google Drive (Token aus `rclone authorize`), Ordner/Netzlaufwerk (eingehängt, z. B. `/mnt/nas/...`). Zugangsdaten werden verschlüsselt gespeichert und nie wieder angezeigt. „Testen“ legt den Ordner an, schreibt und löscht eine Testdatei. Ziele lassen sich deaktivieren oder entfernen (bereits abgelegte Sicherungen bleiben).
|
||||
- Alte Sicherungen mit `.age` bleiben mit der Schlüsseldatei lesbar; der Wiederherstellungstest erkennt die Art an der Dateiendung.
|
||||
|
||||
**Entschlüsseln im Notfall (nur mit Standardwerkzeug):**
|
||||
```bash
|
||||
gpg -d -o archive.tar.gz kundencenter-DATUM-....tar.gz.gpg # fragt nach dem Backup-Passwort
|
||||
```
|
||||
|
||||
## Überblick
|
||||
- **Was wird gesichert:** Datenbank `kundencenter` (Dump) + Konfiguration mit dem Master-Schlüssel (`app.env`, `db.env`, `discord.env`). Ohne `KC_SECRET_KEY` wären 2FA- und Verbindungs-Geheimnisse in einem Backup unlesbar, deshalb ist er enthalten. **Nicht** enthalten: der private Backup-Schlüssel, Plane-Zugang, das Lizenzsystem (eigenes System, eigenes Backup).
|
||||
- **Verschlüsselung:** `age` (öffentlicher Schlüssel `BACKUP_AGE_RECIPIENT`). Ziele (NAS, FTP, Google Drive) sehen nur verschlüsselte Dateien.
|
||||
- **Zeitplan:** täglich ca. 02:30 (`kc-backup.timer`), Wiederherstellungstest sonntags ca. 04:30 (`kc-restore-test.timer`). Verpasste Läufe werden nachgeholt (`Persistent`).
|
||||
- **Aufbewahrung** (lokal und auf jedem Ziel): 14 tägliche, 8 wöchentliche, 12 monatliche Sicherungen; immer mindestens 3 bleiben erhalten. Fremde Dateien werden nie gelöscht.
|
||||
- **Ablage lokal:** `/var/backups/kundencenter/` (Rechte 700). Das ersetzt kein externes Ziel: bei Verlust des Servers ist es weg.
|
||||
- **Kontrolle:** Übersicht (Personal) zeigt letzten Lauf, Ziele und Wiederherstellungstest. Bei fehlgeschlagenem Lauf oder mehr als 26 h ohne Erfolg geht eine Meldung an Discord (ohne Nutzdaten, sobald der Bot eingerichtet ist). Status: `/var/lib/kundencenter/backup-status.json`, Logs: `journalctl -u kc-backup`.
|
||||
- **Wiederherstellungstest** (automatisch): entschlüsselt die neueste Sicherung, spielt sie in die Wegwerf-Datenbank `kundencenter_restoretest` ein und prüft Prüfsummen, Zeilenzahlen aller Kerntabellen, Migrationen, Audit-Hash-Kette und ob gesicherte Geheimnisse mit dem gesicherten Schlüssel lesbar sind. Produktivdaten bleiben unberührt.
|
||||
|
||||
## WICHTIG: privaten Schlüssel extern sichern
|
||||
Der private Schlüssel liegt in `/etc/kundencenter/backup-age-key.txt` (Rechte 600). **Ohne ihn sind alle Backups wertlos.** Bitte einmalig eine Kopie an einem sicheren Ort ablegen (Passwortmanager, Tresor), **nicht** im selben Cloud-Ziel wie die Backups:
|
||||
```bash
|
||||
cat /etc/kundencenter/backup-age-key.txt # Inhalt (zwei Kommentarzeilen + Schlüsselzeile AGE-SECRET-KEY-...) sicher kopieren
|
||||
```
|
||||
Der Schlüssel liegt auf dem Server, damit der automatische Wiederherstellungstest laufen kann. Wer den Server kontrolliert, hat ohnehin Zugriff auf die Datenbank.
|
||||
|
||||
## Backup-Ziele per Kommandozeile (Alternative zur Oberfläche)
|
||||
Zugangsdaten der Ziele stehen in `/var/lib/kundencenter/rclone.conf` (nur root). Alle Befehle mit `--config /var/lib/kundencenter/rclone.conf`.
|
||||
|
||||
**SFTP (empfohlen: Schlüssel statt Passwort)**
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -N "" -f /var/lib/kundencenter/backup_ed25519
|
||||
cat /var/lib/kundencenter/backup_ed25519.pub # auf dem Ziel in ~/.ssh/authorized_keys eintragen
|
||||
ssh-keyscan -H ZIELHOST > /var/lib/kundencenter/known_hosts
|
||||
rclone config create nas sftp host=ZIELHOST user=BENUTZER key_file=/var/lib/kundencenter/backup_ed25519 known_hosts_file=/var/lib/kundencenter/known_hosts --config /var/lib/kundencenter/rclone.conf
|
||||
rclone mkdir nas:kundencenter --config /var/lib/kundencenter/rclone.conf && rclone lsd nas: --config /var/lib/kundencenter/rclone.conf
|
||||
```
|
||||
**FTP/FTPS**
|
||||
```bash
|
||||
rclone config create ftpziel ftp host=ZIELHOST user=BENUTZER pass="$(rclone obscure 'PASSWORT')" explicit_tls=true --config /var/lib/kundencenter/rclone.conf
|
||||
```
|
||||
(Nur unverschlüsseltes FTP möglich? Dann `explicit_tls=false`; die Backups sind trotzdem verschlüsselt, Zugangsdaten gehen aber im Klartext über das Netz.)
|
||||
|
||||
**Google Drive** (OAuth, einmalig auf einem Rechner mit Browser)
|
||||
```bash
|
||||
# auf dem Rechner mit Browser (rclone dort installieren):
|
||||
rclone authorize "drive" "eyJzY29wZSI6ImRyaXZlLmZpbGUifQ" # Scope drive.file: nur von rclone angelegte Dateien
|
||||
# ausgegebenen Token kopieren, dann auf dem Server:
|
||||
rclone config create gdrive drive scope=drive.file token='{"access_token":...}' --config /var/lib/kundencenter/rclone.conf
|
||||
```
|
||||
Empfehlung: eigene OAuth-Client-ID (Google Cloud) verwenden (`client_id=`/`client_secret=`), sonst gelten geteilte Limits.
|
||||
|
||||
**Aktivieren und testen**
|
||||
```bash
|
||||
# in /etc/kundencenter/backup.env: BACKUP_REMOTES=nas:kundencenter,gdrive:Backups/kundencenter
|
||||
systemctl start kc-backup && journalctl -u kc-backup -n 30 --no-pager -o cat # Ziele müssen "ok": true zeigen
|
||||
```
|
||||
Ein ausgefallenes Ziel lässt den Lauf als Fehler zählen (Meldung), das lokale Backup bleibt erhalten.
|
||||
|
||||
## Wiederherstellung (Notfall)
|
||||
1. Neuer/repariertes Server mit MariaDB, Node und `age`; Kundencenter-Code nach `/srv/kundencenter` (Repo), `pnpm install && pnpm build`.
|
||||
2. Sicherungsdatei holen (lokal, Ziel) und den **privaten Schlüssel** bereitlegen.
|
||||
3. Entschlüsseln und entpacken:
|
||||
```bash
|
||||
age -d -i backup-age-key.txt -o archive.tar.gz kundencenter-DATUM-....tar.gz.age
|
||||
mkdir restore && tar -xzf archive.tar.gz -C restore # enthält db.sql, config/*.env, manifest.json
|
||||
```
|
||||
4. Konfiguration zurück: `cp restore/config/*.env /etc/kundencenter/` (chmod 600), Datenbank und Benutzer anlegen (nur Rechte auf `kundencenter`).
|
||||
5. Datenbank einspielen: `mysql -u… -p kundencenter < restore/db.sql`
|
||||
6. Dienste starten (`kc-api`, `kc-worker`, `kc-web`), prüfen: `/v1/ready`, Anmeldung, Übersicht, **Audit-Protokoll → „Integrität prüfen“**.
|
||||
7. Erwartung: Datenverlust höchstens bis zum letzten Backup (bis zu 24 h), Wiederherstellung ca. 30 Minuten.
|
||||
|
||||
## Vor größeren Änderungen
|
||||
Vor Migrationen oder Updates manuell sichern: `systemctl start kc-backup`. Rollback einer fehlerhaften Migration = Wiederherstellung (Abschnitt oben) in eine Wegwerf-Datenbank prüfen, dann produktiv einspielen.
|
||||
71
docs/connector-keyhelp.md
Normal file
71
docs/connector-keyhelp.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# KeyHelp-Connector: API-Fähigkeiten und Abbildung
|
||||
|
||||
## Umsetzungsstand (27.09.2026)
|
||||
Paket `packages/connector-keyhelp`, getestet gegen einen nach der Definition nachgebauten Server (`test/fake.ts`), **nicht gegen eine echte Instanz**. Zugang: KeyHelp-Adresse + API-Schlüssel (IP-beschränkt); die Version wird über `GET /server` erkannt.
|
||||
- **Lesen/Abgleich**: Kunden mit Nutzung und Limits (Statistik je Kunde), Tarife, Health mit Version. Der Abgleich arbeitet rein lesend.
|
||||
- **Kunden übernehmen** (Kunden → Aus Verbindung übernehmen): Kontaktdaten → Kunde und Rechnungsanschrift, Inhaber als „eingeladen“ **ohne E-Mail-Versand** (Einladung später gezielt), Konto wird zugeordnet, optional **Bestandsvertrag** zum passenden Produkt (Beginn wählbar, Laufzeit wird in die Zukunft fortgeschrieben, keine Rechnung). Doppelte Übernahme ist ausgeschlossen (`organizations.legacy_ref`); Verknüpfung mit vorhandenem Kunden möglich.
|
||||
- **Tarife/Produkte**: vorhandene Tarife über „Produkte → Aus Verbindung übernehmen“; **neue Tarife** über „Neuen Hosting-Tarif anlegen“ (legt den Tarif in KeyHelp an und das Produkt im Kundencenter). Name muss frei sein, „unbegrenzt“ nur, wenn die Instanz es bereits kennt.
|
||||
- **Bereitstellung** (Bestellung): Konto anlegen, Benutzername `kc` + Ziffern der Vertragsnummer (stabil, bei Wiederholung wird das eigene Konto übernommen), Vermerk „Kundencenter K-… V-…“, Zugangsdaten gehen per KeyHelp an die Kunden-E-Mail (Passwort wird nie gelesen/gespeichert).
|
||||
- **Aktionen**: Sperren/Entsperren, Tarifwechsel (Connector; UI folgt mit Upgrade/Downgrade), Löschen nur manuell und nie automatisch wiederholt.
|
||||
- **Kundenpanel-Login** (SSO, 60 Min., nicht gespeichert, im Audit).
|
||||
- **Hosting-Ressourcen** je Konto: Domains, E-Mail, Datenbanken, FTP lesen und ändern (als Auftrag, Passwörter nur verschlüsselt bis zur Ausführung), SSL-Zertifikate nur lesen. Freigabe pro Produkt/Ressource (`domain.manage`, `email.manage`, `database.manage`, `ftp.manage`, `panel.login`) plus Rolle Inhaber/Admin.
|
||||
- **Mandantentrennung**: Der API-Schlüssel ist ein Administratorzugang, deshalb prüft der Connector vor jeder Änderung, dass das Objekt dem Konto gehört; neue Objekte bekommen immer die Kunden-ID; Namenskonflikte mit fremden Objekten werden abgelehnt (getestet).
|
||||
|
||||
### Annahmen, die an der echten Instanz zu prüfen sind
|
||||
1. Speicher/Traffic in **Byte** (Definition gibt keine Einheit an; Anzeige und Tarif-Anlage gehen davon aus).
|
||||
2. **Zeitstempel ohne Zeitzone** werden als UTC gelesen.
|
||||
3. Negative Limits = **unbegrenzt** (nur beim Anlegen genutzt, wenn die Instanz es schon kennt).
|
||||
4. Pflichtfelder von `POST /clients` (E-Mail, Benutzername; Passwort wird vom Panel erzeugt) und Benutzernamensregeln (`kc` + Ziffern).
|
||||
5. FTP-Anlage setzt ein vollständig eingerichtetes System-Konto voraus (Panel meldet sonst 400).
|
||||
6. Verhalten bei Tarifwechsel, wenn neue Limits unterschritten werden.
|
||||
7. Listen liefern alles (kein Paging): bei sehr vielen Kunden dauert der Abgleich (Statistik je Kunde, 4 parallel).
|
||||
|
||||
### Noch nicht umgesetzt
|
||||
DNS-Editor, Cronjobs, Verzeichnisschutz, PHP-Einstellungen je Kunde, Zertifikate hochladen, Fail2Ban-Entsperren, Backup-Liste anzeigen; Hauptdomain bei der Bestellung erfragen; Kündigung mit `delete_on` statt Sperre; Tarifwechsel in der Oberfläche.
|
||||
|
||||
# (Auswertung der API-Definition)
|
||||
|
||||
Quelle: offizielle OpenAPI-Definition **KeyHelp RESTful API 2.15** (geändert 25.09.2026), Kopie: `docs/api-specs/keyhelp-api-2.15.openapi.json`. Stand: nur die Definition gelesen, **nicht** gegen eine echte KeyHelp-Instanz geprüft (kein Testzugang, Version der Instanz unbekannt).
|
||||
|
||||
## Grundregeln der API
|
||||
- URL `https://<host>/api/v2/<Endpunkt>`, Anmeldung per Header `X-API-Key`. Formate: JSON (Standard), XML, YAML.
|
||||
- **API ist standardmäßig aus.** Schlüssel legt man in KeyHelp an (Admin-Bereich → Konfiguration → API) und **soll auf IP/IP-Bereich beschränkt** werden. Es gibt keine Rechte-Stufen pro Schlüssel: Ein Schlüssel ist praktisch ein Administratorzugang → höchste Vertraulichkeit (verschlüsselt speichern, nur Superadmin).
|
||||
- Objekte sind per numerischer ID **oder Name** ansprechbar (`/clients/{id}` und `/clients/name/{name}`).
|
||||
- Listen liefern **alles auf einmal** (nur `sort`/`order`, kein Paging). Bei sehr vielen Kunden Antwortzeit beachten.
|
||||
- Fehlerformat `{code, message}`, Statuscodes 200/201/202/204/400/401/404.
|
||||
- Beim Neuladen des Webservers antwortet KeyHelp kurz mit **500/503** → Aufrufer muss nach einigen Sekunden erneut versuchen.
|
||||
- Objekte haben ein Feld `status`: 0 unbekannt, 1 ok, 2 Fehler, 3 Konfiguration neu, 4 Konfiguration wird aktualisiert (→ Zustand „in Bearbeitung“).
|
||||
- Keine Webhooks, keine Paginierung, keine Rechnungs-/Ticket-Funktionen.
|
||||
|
||||
## Abbildung auf den Connector-Vertrag
|
||||
| Fähigkeit | KeyHelp | Bemerkung |
|
||||
|---|---|---|
|
||||
| Health / Version | `GET /ping`, `GET /server` | Server-Info mit Komponenten (Apache, PHP, MariaDB, Postfix …), Auslastung, Ports |
|
||||
| Ressourcen lesen | `GET /clients`, `/clients/{id}`, `/clients/{id}/resources` | Kunde = Hosting-Konto; Ressourcen-Liste enthält die Unterobjekte |
|
||||
| Nutzung | `GET /clients/{id}/stats`, `/traffic` | Speicher und Traffic (`value`/`max`), Anzahl Domains, Postfächer, DBs, FTP, Cronjobs; Traffic je Protokoll (HTTP, FTP, POP3, IMAP, SMTP) |
|
||||
| Limits/Tarif | `client.resource_limits` (nur lesend), `GET /hosting-plans` | **Limits nur über den Tarif** änderbar, keine Einzelabweichung pro Kunde per API (dafür eigenen Tarif anlegen) |
|
||||
| `lifecycle.create` | `POST /clients` → 201 `{id}` | Pflicht/Optionen: Benutzername, E-Mail, Passwort oder generiert, `id_hosting_plan` (sonst Standardtarif), Kontaktdaten, `create_system_domain`, `send_login_credentials`. Natürliche Idempotenz über den eindeutigen Benutzernamen (vorher `GET /clients/name/{name}`) |
|
||||
| `lifecycle.suspend/unsuspend` | `PUT /clients/{id}` `{is_suspended}` | Zielzustand → idempotent. Zusätzlich **geplant**: `suspend_on` und `delete_on` (Datum oder „+30 days“) → passt zu Kündigung, Kulanz- und Sperrregeln |
|
||||
| `lifecycle.terminate` | `DELETE /clients/{id}` | **Löscht Konto und ALLE Ressourcen** (destruktiv): nie automatisch wiederholen, immer manuelle Bestätigung; besser `delete_on` mit Kulanzfrist |
|
||||
| `plan.change` | `PUT /clients/{id}` `{id_hosting_plan}` | Up-/Downgrade über Tarifwechsel (Verhalten bei Überschreitung der neuen Limits an der Instanz prüfen) |
|
||||
| Passwort/Zugang | `PUT /clients/{id}` `{password}` | Optional Zugangsdaten per E-Mail (`send_login_credentials`) |
|
||||
| `sso.login` | `GET /login/{id}` bzw. `/login/name/{name}` | **Offiziell unterstützt**: URL 60 Minuten gültig, mit Brute-Force-Schutz. Passt zu „sicherer Panel-Login“ |
|
||||
| Domains | `/domains` (CRUD), `php_version`, `is_disabled`, `delete_on`, `security` (Let’s Encrypt, HTTPS erzwingen, HSTS), `is_email_domain` | Haupt- und Subdomains, System-Domains filterbar |
|
||||
| DNS | `/dns/{id}` lesen/ersetzen/zurücksetzen | Datensätze, DKIM |
|
||||
| SSL | `/certificates` (CRUD) | `valid_till`, `issuer`, `secured_domains` → **Ablaufdaten** verfügbar |
|
||||
| E-Mail | `/emails` (CRUD) | Postfachgröße/-limit, Aliase, Weiterleitungen, Catch-all, Passwort |
|
||||
| Datenbanken | `/databases` (CRUD) | Name, Benutzer, Passwort, Größe, Remote-Hosts |
|
||||
| FTP | `/ftp-users` (CRUD) | Benutzer, Heimverzeichnis |
|
||||
| Weiteres | `/scheduled-tasks` (Cronjobs), `/directory-protections`, `/backups/operations` (nur Liste), `/fail2ban/banned-ips` (Liste, Entsperren) | Backup-Wiederherstellung ist nicht per API möglich |
|
||||
| Nicht vorhanden | Webhooks, Paging, Limits pro Kunde, Rechnungen/Tickets | Abgleich daher per Polling (Sync-Job) |
|
||||
| Nie freigeben | `/admins/*`, `POST /server/reboot` | Administratorkonten und Server-Neustart gehören nicht in das Kundencenter |
|
||||
|
||||
## Vorschlag für den Umfang
|
||||
1. **Lesend zuerst:** Health, Kunden mit Nutzung/Limits/Status, Domains, SSL-Ablauf, Übernahme der **Hosting-Tarife als Produktvorlagen** (wie beim Lizenzsystem: Limits aus `hosting-plans`).
|
||||
2. **Lebenszyklus:** anlegen, sperren/entsperren, Tarifwechsel, geplantes Löschen (`delete_on`) statt sofortigem `DELETE`.
|
||||
3. **Panel-Login (SSO)** und Unterobjekte (E-Mail, Datenbanken, FTP) für Kunden gemäß Produktregeln.
|
||||
|
||||
## Benötigt für die Umsetzung
|
||||
- KeyHelp-URL und **Version** (Definition kennt 2.1–2.15; Abweichungen kapselt der Connector).
|
||||
- Ein **Test-API-Schlüssel**, auf die IP des Kundencenter-Servers beschränkt (idealerweise gegen eine Testinstanz; sonst Schlüssel mit Bedacht, da er Administratorrechte hat).
|
||||
- Entscheidung, welche Tarife als Produkte angeboten werden und ob Kunden Unterobjekte selbst verwalten dürfen.
|
||||
34
docs/connector-vertrag.md
Normal file
34
docs/connector-vertrag.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Connector-Vertrag v1
|
||||
|
||||
Code: `packages/connector-sdk/src/index.ts`. Jeder Provider implementiert `Connector` und liefert **nur normalisierte Daten** (`NormalizedResource`). Provider-Rohmodelle bleiben im Connector-Paket.
|
||||
|
||||
| Element | Beschreibung |
|
||||
|---|---|
|
||||
| `capabilities(ctx)` | Fähigkeiten (`resources.list`, `lifecycle.suspend`, …). Backend/UI bieten nur Gemeldetes an. Das Lizenz-Connector meldet Schreibfähigkeiten nur mit konfiguriertem API-Benutzer. |
|
||||
| `healthCheck(ctx)` | Erreichbarkeit + Latenz. Ergebnis wird an der Instanz gespeichert (`health`, `last_ok_at`). |
|
||||
| `listResources(ctx)` | Abgleich. Ergebnis wird in `resources` gespiegelt; nicht mehr gelieferte Objekte werden mit `missing_since` markiert, nie gelöscht. |
|
||||
| `execute(ctx, {action, externalRef, params, idempotencyKey})` | Schreibende Aktion. Läuft ausschließlich als persistenter Auftrag (`connector.execute`), Idempotenzschlüssel = Job-ID. Aktionen setzen **absolute Zielzustände** (z. B. `until`), damit Wiederholung wirkungsgleich ist. |
|
||||
| Fehler | `ConnectorError` mit Codes `UNREACHABLE, TIMEOUT, AUTH_FAILED, RATE_LIMITED, NOT_FOUND, INVALID_RESPONSE, UNSUPPORTED, CONFLICT, UPSTREAM_ERROR, BAD_CONFIG`; `retryable` und `userMessage` (ohne technische Details). |
|
||||
| HTTP | `httpJson`: Timeout, exponentielles Backoff mit Jitter, `Retry-After`; Wiederholung nur für GET, Schreibzugriffe nie automatisch im Connector. |
|
||||
|
||||
## Aufträge
|
||||
Status: geplant → wird ausgeführt → erfolgreich | wird wiederholt → manuelle Prüfung erforderlich / fehlgeschlagen. Retries begrenzt (`max_attempts`, Backoff 30 s · 2ⁿ, max. 1 h).
|
||||
**Destruktive Aktionen (`terminate`) werden nie automatisch wiederholt**, auch nicht nach Worker-Absturz (`needs_review`), und lassen sich nicht über „Erneut ausführen“ starten.
|
||||
|
||||
## Neuer Connector
|
||||
1. Paket `packages/connector-<name>` mit `Connector`-Implementierung, `configFields` (Secrets als `secret: true`).
|
||||
2. Vertragstest: `runContract(...)` aus `@kc/connector-sdk/dist/contract.js` plus Fixtures mit anonymisierten Antworten.
|
||||
3. In `packages/connectors/src/index.ts` registrieren. Keine weitere Änderung in API/Worker/UI nötig.
|
||||
|
||||
## Vorhandene Connectoren
|
||||
- `mock` – Testdaten (`[Mock]`), simuliert Ausfälle (`simulateOutage`).
|
||||
- `licensing` – eigenes Lizenzsystem. Erkennt Erweiterungen über `GET /` → `features`:
|
||||
- Zugang bevorzugt per **Service-Token** (`token`), alternativ API-Benutzer/Passwort. Ohne Zugang nur lesend.
|
||||
- `catalog.list`: Produktkatalog des Lizenzsystems (Programm → Gruppe → Produkt mit Preis, Dauer, Features) als Übernahmevorlagen; Produkt- und Programmschlüssel werden nie ausgegeben. Ältere Systeme: Programme mit Vorschlägen aus bestehenden Lizenzen.
|
||||
- `lifecycle`: Sperren/Entsperren/Verlängern über die Endpunkte `/licenses/{id}/suspend|unsuspend|extend` (Zielzustand, protokolliert); sonst Rückfall auf `PUT`.
|
||||
- Zustand berücksichtigt `is_active`, `blocked`, `status`, `suspended_at`, `revoked_at` und Ablauf. Bei `utc_timestamps` werden Zeiten als UTC gelesen, sonst als Ortszeit.
|
||||
- Anlage (`lifecycle.create`) mit `product_id` (Plan/Edition des Lizenzsystems); Produkt und Präfix dürfen nie stillschweigend verloren gehen (Antwort wird geprüft).
|
||||
- KeyHelp, Plesk: **offen** (keine Testzugänge; siehe Plane).
|
||||
|
||||
## Zugangsdaten
|
||||
Secrets werden pro Verbindung mit AES-256-GCM verschlüsselt (`connector_instances.secrets_enc`), nur Superadmins dürfen Verbindungen anlegen/ändern, die UI zeigt sie nie wieder an, das Audit-Protokoll maskiert sie.
|
||||
22
docs/domains.md
Normal file
22
docs/domains.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Domains: Preise, Prüfung, Aufstellung
|
||||
|
||||
**Modul:** `apps/api/src/modules/domains` · Migrationen 010–013 · Seiten `/domains`, `/admin/produkte/domains`, `/admin/domains`, Abschnitt „Domains“ auf der Kundenseite.
|
||||
|
||||
## Preisberechnung
|
||||
- Einkaufsliste (KCS/supportdomain): Endung, Laufzeit, 4 Staffeln (0–49, 50–199, 200–999, 1000+), Setup. Die Liste gilt als **Bruttopreis inkl. USt** (Einstellung `cost_basis`, umstellbar auf netto).
|
||||
- Verkauf = Einkauf + Aufschlag (Prozent in Basispunkten oder Festbetrag; je Endung, sonst global). Kein Aufschlag → kein Verkaufspreis („auf Anfrage“).
|
||||
- Rundung (optional, Standard an): Cent-Anteil 00–50 → ,50; 51–99 → ,99.
|
||||
- Bei Brutto-Liste wird Netto herausgerechnet (`brutto / (1 + USt)`), es wird nie zweimal USt aufgeschlagen. Setup ohne Aufschlag.
|
||||
- Steuersatz: „Regelsteuersatz“ aus `tax_rates`.
|
||||
|
||||
## Verfügbarkeit
|
||||
RDAP (IANA-Bootstrap, .de zusätzlich über rdap.denic.de), Rückfall DNS mit Hinweis „ohne Gewähr“. Endgültige Prüfung erst bei der Registrierung beim Registrar.
|
||||
|
||||
## Aufstellung (`domain_records`)
|
||||
Preis-Schnappschuss je Domain (EK brutto/netto, VK brutto/netto, Gewinn netto) und Beschaffungsstatus (offen / bei KCS bestellt / anderer Anbieter). CSV-Export als KCS-Bestellliste und Gesamtaufstellung.
|
||||
|
||||
## Import der Preisliste
|
||||
Oberfläche „Preisliste einlesen“ oder `pnpm --filter @kc/api cli import-domains --file=<datei>` (Format siehe `data/domain-preise.txt`). Aufschläge und Aktiv-Status bleiben erhalten.
|
||||
|
||||
## Offen
|
||||
Registrar-API von KCS (unbekannt, Anfrage nötig), Domain als Position in der Bestellung.
|
||||
50
docs/lizenzsystem-erweiterung/README.md
Normal file
50
docs/lizenzsystem-erweiterung/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Lizenzsystem-Erweiterung: Edition (Schlüssel-Präfix) und festes Ablaufdatum
|
||||
|
||||
**Zweck:** Das Kundencenter soll Familytool-Stufen verkaufen (Premium, Unlimited, Lifetime, Trial). Das Familytool erkennt die Stufe am **Präfix des Lizenzschlüssels** (`PREMIUM-…`, `TRIAL-…`, `LIFETIME…`, sonst Unlimited). Das Lizenzsystem konnte bisher nur Schlüssel ohne Präfix erzeugen, jede Lizenz wäre also automatisch Unlimited gewesen.
|
||||
|
||||
**Wo einspielen:** auf der Instanz, gegen die das Kundencenter und das Familytool laufen, also **licensing.flessinglabs.com** (nicht auf dem lokalen Dienst dieses Servers). Ohne den Patch verweigert das Kundencenter Bestellungen mit Edition (das Produkt bleibt Entwurf, eine Bestellung würde nie eine falsche Lizenz anlegen).
|
||||
|
||||
Getestet an einer Kopie des Codes mit eigener Testdatenbank auf einem separaten Port: Präfix für alle drei Editionen, ungültiges Präfix → 422, festes Ablaufdatum, unveränderte Anlage ohne Präfix, `validate` mit Präfix-Schlüssel, Erkennung über `GET /`.
|
||||
|
||||
## Ablauf (ca. 10 Minuten, kurze Unterbrechung nur beim Neustart)
|
||||
|
||||
1. **Sichern** (DB und Code des Lizenzsystems auf dem Zielserver):
|
||||
```bash
|
||||
mysqldump --single-transaction <DBNAME> > licensing-vor-erweiterung.sql
|
||||
cp -a <pfad>/backend/app app-vor-erweiterung
|
||||
```
|
||||
2. **Vorab prüfen: startet der Dienst nach einem Neustart korrekt?** (Wir hatten am 26.09. auf dem lokalen Dienst einen Ausfall, weil das venv neuer war als der laufende Prozess. Siehe LICENSING-11.)
|
||||
```bash
|
||||
<pfad>/backend/venv/bin/pip list | grep -iE "bcrypt|passlib|slowapi|starlette" # bcrypt muss < 5 sein (z. B. 4.0.1)
|
||||
grep -n "def validate_license" -A3 <pfad>/backend/app/routers/licenses.py # Parameter muss "request: Request" heißen
|
||||
```
|
||||
Wenn `bcrypt` ≥ 5 ist oder die Schema-Parameter noch `request` heißen: **erst das beheben**, sonst fallen Login und `validate` beim Neustart aus.
|
||||
3. **Datenbank erweitern** (Schlüssel werden mit Präfix länger als 36 Zeichen):
|
||||
```sql
|
||||
ALTER TABLE licenses MODIFY license_key VARCHAR(64) NOT NULL;
|
||||
```
|
||||
4. **Code anpassen** (erst Trockenlauf, dann anwenden):
|
||||
```bash
|
||||
python3 apply_erweiterung.py <pfad>/backend # zeigt nur, was geändert würde
|
||||
python3 apply_erweiterung.py <pfad>/backend --apply # legt Sicherungen *.bak-<zeit> an
|
||||
```
|
||||
Weicht der Code ab, bricht das Skript ohne Änderung ab und nennt die Stelle.
|
||||
5. **Dienst neu starten** (z. B. `systemctl restart licensing-backend`) und prüfen:
|
||||
```bash
|
||||
curl -s https://licensing.flessinglabs.com/api/ # muss "features":["key_prefix","explicit_expiry"] enthalten
|
||||
```
|
||||
Danach kurz testen: Login im Admin-Panel, eine Lizenz prüfen (`validate`) und im Familytool die Lizenzprüfung.
|
||||
6. **Im Kundencenter**: Verbindungen → „Jetzt abgleichen“. Unter „Fähigkeiten“ erscheinen `license.key_prefix` und `license.expiry`. Danach lassen sich die Familytool-Produkte aktivieren.
|
||||
|
||||
## Zurückrollen
|
||||
Dienst stoppen, `*.bak-<zeit>` zurückkopieren, Dienst starten. Die Spalte darf auf 64 Zeichen bleiben. Bereits mit Präfix angelegte Lizenzen bleiben gültig.
|
||||
|
||||
## API-Änderungen im Detail
|
||||
- `POST /licenses/` Body: neues optionales Feld `key_prefix` (`PREMIUM` | `TRIAL` | `LIFETIME`). Antwort enthält den Schlüssel `<PRAEFIX>-<uuid>`.
|
||||
- `POST /licenses/`: ein angegebenes `expires_at` (Ortszeit wie bisher) wird verwendet; ohne Angabe gilt die bisherige Berechnung aus `duration_type`.
|
||||
- `GET /`: Feld `features` (Liste).
|
||||
- Nicht geändert: `validate`, `register`, Shop, Login, alle bestehenden Felder.
|
||||
|
||||
## Bekannte Grenzen
|
||||
- Ein Upgrade von Premium auf Unlimited bedeutet einen **neuen Schlüssel** (das Präfix steckt im Schlüssel). Das Kundencenter legt dann eine neue Lizenz an; die alte wird gesperrt. Eine spätere Lösung mit einem Feld `edition` im Lizenzsystem (statt Präfix) ist möglich.
|
||||
- Das Ablaufdatum wird (wie im Lizenzsystem üblich) als Ortszeit gespeichert.
|
||||
82
docs/lizenzsystem-erweiterung/apply_erweiterung.py
Normal file
82
docs/lizenzsystem-erweiterung/apply_erweiterung.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Erweiterung des Lizenzsystems um "Edition" (Schlüssel-Präfix) und festes Ablaufdatum – für das Kundencenter.
|
||||
|
||||
Was der Patch tut (alles abwärtskompatibel, bestehende Clients ändern sich nicht):
|
||||
1. POST /licenses/ akzeptiert optional key_prefix: "PREMIUM" | "TRIAL" | "LIFETIME"
|
||||
-> Schlüssel lautet dann "<PRAEFIX>-<uuid>" (das Familytool erkennt daran die Stufe; ohne Präfix = Unlimited).
|
||||
2. POST /licenses/ beachtet ein ausdrücklich angegebenes expires_at (bisher wurde es ignoriert).
|
||||
3. GET / meldet "features": ["key_prefix", "explicit_expiry"] (daran erkennt das Kundencenter die Erweiterung).
|
||||
4. models.License.license_key wird von 36 auf 64 Zeichen erweitert (DB-Änderung: siehe README, VORHER ausführen).
|
||||
|
||||
Nutzung (Standard = nur prüfen, nichts ändern):
|
||||
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend # Trockenlauf
|
||||
python3 apply_erweiterung.py /pfad/zu/licensing-system/backend --apply # anwenden (mit Sicherungskopien *.bak-<zeit>)
|
||||
Das Skript ist idempotent: bereits vorhandene Änderungen werden erkannt und übersprungen.
|
||||
"""
|
||||
import re, sys, time, py_compile, pathlib
|
||||
|
||||
def main() -> int:
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
apply = '--apply' in sys.argv
|
||||
if len(args) != 1:
|
||||
print(__doc__); return 2
|
||||
root = pathlib.Path(args[0]) / 'app'
|
||||
files = {n: root / p for n, p in {'models': 'models.py', 'schemas': 'schemas.py', 'licenses': 'routers/licenses.py', 'main': 'main.py'}.items()}
|
||||
for n, f in files.items():
|
||||
if not f.exists():
|
||||
print(f'FEHLER: {f} nicht gefunden – falscher Pfad? (erwartet: .../licensing-system/backend)'); return 1
|
||||
src = {n: f.read_text() for n, f in files.items()}
|
||||
new = dict(src); report = []
|
||||
|
||||
def step(name, key, old, repl, marker):
|
||||
if marker in new[key]:
|
||||
report.append(f' = {name}: bereits vorhanden'); return True
|
||||
if old not in new[key]:
|
||||
report.append(f' ! {name}: erwartete Stelle NICHT gefunden – bitte manuell prüfen (siehe README)'); return False
|
||||
new[key] = new[key].replace(old, repl, 1); report.append(f' + {name}'); return True
|
||||
|
||||
ok = True
|
||||
ok &= step('models: license_key 64 Zeichen', 'models',
|
||||
'license_key = Column(String(36), unique=True, index=True, default=lambda: str(uuid.uuid4()))',
|
||||
'license_key = Column(String(64), unique=True, index=True, default=lambda: str(uuid.uuid4()))', 'String(64), unique=True')
|
||||
ok &= step('schemas: key_prefix in LicenseCreate', 'schemas',
|
||||
'class LicenseCreate(LicenseBase):\n pass',
|
||||
'class LicenseCreate(LicenseBase):\n # Optionales Schlüssel-Präfix (Edition): Schlüssel lautet "<PRAEFIX>-<uuid>". Ohne Präfix gilt die Lizenz im Familytool als UNLIMITED.\n key_prefix: Optional[Literal["PREMIUM", "TRIAL", "LIFETIME"]] = None',
|
||||
'key_prefix: Optional[Literal')
|
||||
if 'Literal' not in re.split(r'\nclass ', new['schemas'], maxsplit=1)[0]:
|
||||
m = re.search(r'^from typing import (.+)$', new['schemas'], re.M)
|
||||
new['schemas'] = new['schemas'].replace(m.group(0), f'from typing import Literal, {m.group(1)}', 1) if m else 'from typing import Literal\n' + new['schemas']
|
||||
report.append(' + schemas: Literal importiert')
|
||||
ok &= step('licenses: festes Ablaufdatum hat Vorrang', 'licenses',
|
||||
' # Calculate expires_at based on duration_type\n expires_at = None\n if license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
|
||||
' # Ein ausdrücklich angegebenes Ablaufdatum hat Vorrang; sonst wird es aus duration_type berechnet\n expires_at = license.expires_at\n if expires_at is None and license.duration_type != schemas.LicenseDurationType.UNLIMITED:',
|
||||
'expires_at = license.expires_at')
|
||||
ok &= step('licenses: Schlüssel mit Präfix', 'licenses',
|
||||
' db_license = models.License(\n program_id=license.program_id,',
|
||||
' extra = {"license_key": f"{license.key_prefix}-{uuid.uuid4()}"} if license.key_prefix else {}\n db_license = models.License(\n **extra,\n program_id=license.program_id,',
|
||||
'f"{license.key_prefix}-')
|
||||
if not re.search(r'^import uuid$', new['licenses'], re.M):
|
||||
new['licenses'] = re.sub(r'^import datetime$', 'import datetime\nimport uuid', new['licenses'], count=1, flags=re.M); report.append(' + licenses: uuid importiert')
|
||||
ok &= step('main: features in GET /', 'main',
|
||||
'return {"message": "Licensing System Backend is running!"}',
|
||||
'return {"message": "Licensing System Backend is running!", "features": ["key_prefix", "explicit_expiry"]}', '"features"')
|
||||
|
||||
print('\n'.join(report))
|
||||
if not ok:
|
||||
print('\nABBRUCH: Nicht alle Stellen passen (Code weicht ab). Es wurde NICHTS geändert.'); return 1
|
||||
changed = [n for n in files if new[n] != src[n]]
|
||||
if not changed:
|
||||
print('\nNichts zu tun – die Erweiterung ist bereits vollständig eingespielt.'); return 0
|
||||
if not apply:
|
||||
print(f'\nTROCKENLAUF: würde {", ".join(files[n].name for n in changed)} ändern. Mit --apply ausführen.'); return 0
|
||||
ts = time.strftime('%Y%m%d-%H%M%S')
|
||||
for n in changed:
|
||||
files[n].with_name(files[n].name + f'.bak-{ts}').write_text(src[n])
|
||||
files[n].write_text(new[n])
|
||||
py_compile.compile(str(files[n]), doraise=True)
|
||||
print(f'\nANGEWENDET: {", ".join(files[n].name for n in changed)} (Sicherungen: *.bak-{ts}). Jetzt Dienst neu starten (siehe README).')
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
39
docs/produkte-bestellungen-vertraege.md
Normal file
39
docs/produkte-bestellungen-vertraege.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Produkte, Bestellungen, Verträge
|
||||
|
||||
## Modell
|
||||
- **Produkt** (`products`) + **unveränderliche Versionen** (`product_versions`): Preise in Cent (netto), Steuersatz (Basispunkte), Intervall (`once|monthly|yearly`), Mindestlaufzeit, Verlängerung, Kündigungsfrist, Provisionierungsparameter. Änderung = neue Version.
|
||||
- **Bestellung** (`orders`/`order_items`): Preis wird **immer serverseitig** aus der aktuellen Version berechnet (`@kc/platform/pricing`) und als **Snapshot** in `order_items.snapshot_json` und `contracts.price_snapshot_json` festgeschrieben. Preisänderungen wirken nicht rückwirkend.
|
||||
- **Vertrag** (`contracts`): je Bestellposition ein Vertrag, Verweis auf die bereitgestellte Ressource.
|
||||
|
||||
## Statusautomaten (`@kc/platform/statemachine`, mit Tabellentest)
|
||||
- Bestellung: `pending_approval → approved → provisioning → completed | failed`, `rejected`, `cancelled`, `failed → provisioning` (Wiederholung).
|
||||
- Vertrag: `pending → active ⇄ suspended → cancelled | expired`, `pending → failed`.
|
||||
- Ungültige Übergänge liefern HTTP 409, nie eine stille Änderung.
|
||||
|
||||
## Abläufe
|
||||
1. **Bestellung**: Kunde (Inhaber/Admin der Organisation) nur für Produkte mit „Selbstbestellung“, ohne Rabatt. Personal bestellt für Kunden inkl. Rabatt und gilt als freigegeben.
|
||||
2. **Freigabe**: Produkte mit „manuelle Freigabe“ warten auf `orders.approve` (Admin). Kunden können ihre offene Bestellung zurückziehen.
|
||||
3. **Bereitstellung** (Worker, Auftrag `order.provision`): Ressource beim Anbieter anlegen (`Connector.provision`), der Kunde bekommt sie zugeordnet, Vertrag wird aktiv, Laufzeitende wird gesetzt.
|
||||
- Idempotent: bereits aktive Verträge werden übersprungen.
|
||||
- **Anlage wird nicht blind wiederholt**: Nur bei sicher nicht gesendeter Anfrage (Verbindung verweigert, Rate-Limit) bis zu 5 Versuche; bei unklarem Ausgang (Timeout, 5xx, ungültige Antwort) sofort „fehlgeschlagen“ mit Hinweis, beim Anbieter zu prüfen. Neustart nur mit ausdrücklicher Bestätigung.
|
||||
4. **Kündigung**: zum nächsten Laufzeitende unter Beachtung der Frist (`effectiveCancelDate`), widerrufbar bis zum Wirksamwerden; sofort nur durch Personal. Bei Wirksamwerden wird der Vertrag beendet und die Ressource **gesperrt** (nie automatisch gelöscht).
|
||||
5. **Verlängerung**: automatisch um die Verlängerungslaufzeit, solange nicht gekündigt (Worker-Lauf jede Minute, idempotent).
|
||||
|
||||
## Kundenart
|
||||
Privatkunden: Preise brutto, Verlängerung nur um 1 Monat und Kündigungsfrist max. 30 Tage (`consumerTerms`). Geschäftskunden: Preise netto (brutto in Klammern).
|
||||
|
||||
## Vor Produktivbetrieb rechtlich klären (Steuer-/Rechtsberatung)
|
||||
- Verbraucherverträge: Widerrufsbelehrung/-recht, Button-Lösung, Preisangabenverordnung, Laufzeit-/Kündigungsregeln (Gesetz für faire Verbraucherverträge) – die Regel in `consumerTerms` ist eine Annahme.
|
||||
- Steuer: B2B/B2C, Reverse-Charge, EU-Lieferort (One-Stop-Shop), USt-IdNr.-Prüfung.
|
||||
- Rechnungsstellung ist **noch nicht** angebunden (KUNDENCENT-26); Verträge erzeugen bisher keine Rechnungen.
|
||||
|
||||
## Preisbasis (netto/brutto)
|
||||
Jede Produktversion hat eine Preisbasis. **Brutto**: der Betrag ist der Endkundenpreis und bleibt exakt (z. B. 9,99 €); Netto und Steuer werden herausgerechnet (Netto kaufmännisch gerundet, Steuer = Brutto − Netto). **Netto**: Steuer wird aufgeschlagen. Der Snapshot in Bestellung und Vertrag enthält Basis, Netto, Steuer und Brutto.
|
||||
|
||||
## Vorlagenpakete (`/bundles/*.json`)
|
||||
Pakete beschreiben mehrere Produkte einer Software (Editionen, Preise, Laufzeiten, Provisionierung). Auf der Übernahmeseite wählt man ein Programm der Verbindung und importiert Paketprodukte als **Entwürfe**. Vorhanden: `familytool.json` (Premium/Unlimited monatlich+jährlich, Lifetime, Trial 14 Tage; Preise brutto laut LICENSE_TIERS.md).
|
||||
|
||||
## Edition (Familytool) und Laufzeitpflege
|
||||
- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl. Patch: `docs/lizenzsystem-erweiterung/`.
|
||||
- Nach der Bereitstellung wird das Ablaufdatum der Lizenz an das Vertragsende angeglichen; bei jeder **Vertragsverlängerung** wird die Lizenz mit verlängert (Auftrag `extend`, idempotent). Bis zur Rechnungs-/Zahlungsanbindung geschieht das **ohne Zahlungsprüfung**.
|
||||
- Rollierende Monatsverträge (Mindestlaufzeit 0, automatische Verlängerung) erhalten einen monatlichen Verlängerungstakt. Upgrade Premium → Unlimited bedeutet einen neuen Schlüssel.
|
||||
Loading…
Add table
Add a link
Reference in a new issue