diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1bceff8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.{bat,cmd}] +charset = latin1 +end_of_line = crlf +insert_final_newline = true diff --git a/.env.example b/.env.example index 5bd41b4..65ab931 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,11 @@ COOLIFY_RESOURCE_UUID= COOLIFY_TRIGGER_MODE=webhook COOLIFY_DEPLOY_METHOD=GET COOLIFY_DEPLOY_ENDPOINT=/api/v1/deploy?uuid={uuid}&force={force} +COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id} +VENDOO_DEPLOY_TIMEOUT_SECONDS=900 +VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15 +VENDOO_BUILD_REVISION= +VENDOO_COOLIFY_AUTO_DEPLOY=false VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo VENDOO_GITHUB_BRANCH=main VENDOO_GHCR_IMAGE=ghcr.io/masterluke77/vendoo diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f6a525d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8999aa5..cae8f53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,10 @@ jobs: - name: Syntaxprüfung run: npm run check + + - name: PowerShell-Regressionen statisch prüfen + run: npm run verify:powershell-static + - name: Produktiven Source-Root und Archivgrenzen prüfen run: npm run verify:source-boundaries @@ -114,3 +118,31 @@ jobs: - name: Docker-Image testweise bauen run: docker build --pull --tag vendoo:ci . + + windows-release-contract: + name: Windows – vollständiger Releasevertrag + runs-on: windows-latest + timeout-minutes: 35 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten reproduzierbar installieren + run: npm ci + + - name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen + shell: powershell + run: .\tools\verify-powershell-parser.ps1 + + - name: Vollständigen Vendoo-Releasevertrag unter Windows ausführen + shell: pwsh + run: npm run verify:all + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5e95d7..222bdb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,20 @@ env: IMAGE_NAME: masterluke77/vendoo jobs: + powershell-parser: + name: Windows PowerShell 5.1 Parser + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + + - name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen + shell: powershell + run: .\tools\verify-powershell-parser.ps1 + release-assets: + needs: powershell-parser name: Release-Paket und Prüfsummen permissions: contents: write @@ -42,31 +55,21 @@ jobs: EXPECTED="v$(node -p "require('./package.json').version")" test "$RELEASE_TAG" = "$EXPECTED" || { echo "Release-Tag $RELEASE_TAG passt nicht zu $EXPECTED." >&2; exit 1; } - - name: Vollständige Prüfung - run: | - npm run verify:git - npm run verify:git-regression - npm run verify:git-staging - npm run verify:ignore-boundaries - npm run check - npm run verify:source-boundaries - npm run verify:esm-exports - npm run verify:flux-loop - npm run verify:capabilities - npm run verify:coolify - npm run verify:initial-modules - npm run verify:architecture - npm run verify:themes - npm run verify:security - npm run verify:identity - npm run verify:catalog - npm run verify:modules - npm run verify:deployment - npm run verify:config - npm run verify:installer-shell - npm run verify:db + - name: ESM-Exportverträge explizit prüfen + run: npm run verify:esm-exports - - name: Release-Staging erzeugen + - name: FLUX-Loop-Hotfix explizit prüfen + run: npm run verify:flux-loop + + - name: Vollständige Prüfung + run: npm run verify:all + + - name: Release-Manifest reproduzierbar prüfen + run: | + node tools/generate-release-manifest.mjs . + git diff --exit-code -- release-manifest.json + + - name: Manifestbasiertes Release-Staging erzeugen run: node tools/build-release.mjs - name: ZIP und SHA-256 erzeugen diff --git a/.github/workflows/sync-release-manifest.yml b/.github/workflows/sync-release-manifest.yml new file mode 100644 index 0000000..3f9f556 --- /dev/null +++ b/.github/workflows/sync-release-manifest.yml @@ -0,0 +1,47 @@ +name: Vendoo Release Manifest Sync + +on: + push: + branches: + - release/1.43.0-production-update-v3 + +permissions: + contents: write + +concurrency: + group: vendoo-release-manifest-sync-${{ github.ref }} + cancel-in-progress: true + +jobs: + sync: + name: Release-Manifest reproduzierbar synchronisieren + if: ${{ !contains(github.event.head_commit.message, '[manifest-sync]') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Release-Branch auschecken + uses: actions/checkout@v6 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Release-Manifest erzeugen + run: node tools/generate-release-manifest.mjs . + + - name: Geändertes Manifest committen + shell: bash + run: | + if git diff --quiet -- release-manifest.json; then + echo "Release-Manifest ist bereits aktuell." + exit 0 + fi + git config user.name "vendoo-release-bot" + git config user.email "vendoo-release-bot@users.noreply.github.com" + git add release-manifest.json + git commit -m "chore: Release-Manifest synchronisieren [manifest-sync]" + git push origin HEAD:${GITHUB_REF_NAME} diff --git a/.github/workflows/vendoo-production-deploy.yml b/.github/workflows/vendoo-production-deploy.yml new file mode 100644 index 0000000..adced6f --- /dev/null +++ b/.github/workflows/vendoo-production-deploy.yml @@ -0,0 +1,83 @@ +name: Vendoo Production Deploy + +on: + workflow_dispatch: + inputs: + target_version: + description: Expected Vendoo version + required: true + type: string + public_base_url: + description: Public Vendoo URL + required: true + default: https://vendoo.flatlined.de + type: string + +permissions: + contents: read + +concurrency: + group: vendoo-production-deploy + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Validate inputs + shell: bash + env: + TARGET_VERSION: ${{ inputs.target_version }} + PUBLIC_BASE_URL: ${{ inputs.public_base_url }} + run: | + set -euo pipefail + [[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$PUBLIC_BASE_URL" =~ ^https://[^/]+/?$ ]] + + - name: Trigger Coolify + shell: bash + env: + COOLIFY_DEPLOY_WEBHOOK_URL: ${{ secrets.COOLIFY_DEPLOY_WEBHOOK_URL }} + run: | + set -euo pipefail + test -n "$COOLIFY_DEPLOY_WEBHOOK_URL" + curl --fail --silent --show-error --location --request GET \ + --connect-timeout 20 --max-time 90 \ + "$COOLIFY_DEPLOY_WEBHOOK_URL" >/tmp/coolify-response.txt + echo "Coolify accepted the deployment trigger." + + - name: Wait for exact production version + shell: bash + env: + TARGET_VERSION: ${{ inputs.target_version }} + PUBLIC_BASE_URL: ${{ inputs.public_base_url }} + run: | + set -euo pipefail + BASE_URL="${PUBLIC_BASE_URL%/}" + deadline=$((SECONDS + 1500)) + last_version="unknown" + while (( SECONDS < deadline )); do + if body=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 \ + -H 'Accept: application/json' -H 'Cache-Control: no-cache' \ + "$BASE_URL/readyz?github_run=${GITHUB_RUN_ID}&attempt=${GITHUB_RUN_ATTEMPT}"); then + ready=$(jq -r '.ok // false' <<<"$body") + version=$(jq -r '.version // empty' <<<"$body") + if [[ -n "$version" ]]; then last_version="$version"; fi + echo "ready=$ready version=${version:-unknown} expected=$TARGET_VERSION" + if [[ "$ready" == "true" && "$version" == "$TARGET_VERSION" ]]; then exit 0; fi + fi + sleep 15 + done + echo "Timeout waiting for Vendoo $TARGET_VERSION. Last version: $last_version" >&2 + exit 1 + + - name: Final health response + shell: bash + env: + PUBLIC_BASE_URL: ${{ inputs.public_base_url }} + run: | + set -euo pipefail + BASE_URL="${PUBLIC_BASE_URL%/}" + curl --fail --silent --show-error -H 'Accept: application/json' \ + "$BASE_URL/healthz?github_run=${GITHUB_RUN_ID}" | jq . diff --git a/.github/workflows/windows-release-diagnostics.yml b/.github/workflows/windows-release-diagnostics.yml new file mode 100644 index 0000000..dcd5ed3 --- /dev/null +++ b/.github/workflows/windows-release-diagnostics.yml @@ -0,0 +1,125 @@ +name: Vendoo Windows Release Diagnostics + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: vendoo-windows-diagnostics-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows-release-diagnostics: + name: Windows – Release-Gates einzeln + runs-on: windows-latest + timeout-minutes: 35 + steps: + - name: Repository auschecken + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Node.js 22 aktivieren + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: npm + + - name: Abhängigkeiten reproduzierbar installieren + run: npm ci + + - name: Alle PowerShell-Dateien mit Windows PowerShell 5.1 parsen + shell: powershell + run: .\tools\verify-powershell-parser.ps1 + + - name: Git-Sicherheitsgate + run: npm run verify:git + + - name: Git-Sicherheitsgate Regressionstest + run: npm run verify:git-regression + + - name: Git-Staging-Filter Regressionstest + run: npm run verify:git-staging + + - name: Release-Manifest prüfen + run: npm run verify:release-manifest + + - name: Git-Ignore-Grenzen prüfen + run: npm run verify:ignore-boundaries + + - name: Syntax und Identity-Datenbank prüfen + run: npm run check + + - name: PowerShell-Regressionen statisch prüfen + run: npm run verify:powershell-static + + - name: Source-Root-Grenzen prüfen + run: npm run verify:source-boundaries + + - name: ESM-Exportverträge prüfen + run: npm run verify:esm-exports + + - name: FLUX-Loop-Hotfix prüfen + run: npm run verify:flux-loop + + - name: Capability-Graph prüfen + run: npm run verify:capabilities + + - name: Coolify- und Produktionsoperationen prüfen + run: npm run verify:coolify + + - name: Initial deaktivierte Module prüfen + run: npm run verify:initial-modules + + - name: Plattformarchitektur prüfen + run: npm run verify:architecture + + - name: Theme Manager prüfen + run: npm run verify:themes + + - name: Security Foundation prüfen + run: npm run verify:security + + - name: Identity und Settings prüfen + run: npm run verify:identity + + - name: Einladungen prüfen + run: npm run verify:invitations + + - name: SQLite-Cleanup unter Windows prüfen + run: npm run verify:sqlite-cleanup + + - name: Katalog und Medien prüfen + run: npm run verify:catalog + + - name: Modulmanager prüfen + run: npm run verify:modules + + - name: Produktionsoperationen prüfen + run: npm run verify:production-operations + + - name: One-Click-Updater prüfen + run: npm run verify:one-click-update + + - name: Modulnavigation prüfen + run: npm run verify:module-navigation + + - name: Updater-UI prüfen + run: npm run verify:updater-ui + + - name: Updater-Hotfix-Vertrag prüfen + run: npm run verify:updater-hotfix + + - name: Deployment prüfen + run: npm run verify:deployment + + - name: Konfigurationsspeicher prüfen + run: npm run verify:config + + - name: Installer-Shell-Portabilität prüfen + run: npm run verify:installer-shell + + - name: Datenbankmigrationen prüfen + run: npm run verify:db diff --git a/00_START_HIER_1_43_0.md b/00_START_HIER_1_43_0.md new file mode 100644 index 0000000..94f72ee --- /dev/null +++ b/00_START_HIER_1_43_0.md @@ -0,0 +1,36 @@ +# Vendoo 1.43.0 – Start hier + +## Production Updater 3.3 starten + +1. Dieses ZIP vollständig in einen neuen lokalen Ordner entpacken, zum Beispiel `C:\Vendoo-Update`. +2. Im Ordner `vendoo` doppelt auf `UPDATE_VENDOO.bat` klicken. +3. Den Vendoo Production Updater bis zum eindeutigen Ergebnis geöffnet lassen. + +Der Updater zeigt zehn klare Phasen, Fortschritt, Live-Protokoll und einen dauerhaft sichtbaren Fehlerzustand. Beim ersten Lauf kann einmalig die GitHub-Anmeldung im Browser erforderlich sein. GitHub CLI wird bei Bedarf als verwaltete portable Kopie geladen und per SHA-256 geprüft. + +Ein Coolify-Webhook wird nicht abgefragt. Nach einem erfolgreichen Merge verwendet Vendoo das bereits eingerichtete Coolify Auto Deploy der GitHub-Verknüpfung und bestätigt anschließend `/readyz` mit exakt Version `1.43.0`. + +## Release-Architektur 3.0 + +- Die geklonte `main`-Arbeitskopie wird nicht geleert. +- `release-manifest.json` enthält jede auszuliefernde Datei mit Größe und SHA-256. +- Pflichtdateien wie `db/schema.sql` werden vor den Tests hart geprüft. +- Dateien werden als Overlay angewendet und im Ziel erneut gehasht. +- `release-manifest.json` wird anschließend als bytegeprüfte Steuerdatei in die Arbeitskopie übernommen. +- Löschungen sind nur über die explizite Liste `removedFiles` erlaubt. +- Alte Updater-2.x-Sitzungen werden nicht übernommen. +- GitHub Actions führt den vollständigen Releasevertrag auf Windows und Linux aus. + +## Inhalt von Vendoo 1.43.0 + +- modulabhängige Navigation: deaktivierte Module verschwinden sofort aus der Seitenleiste, +- optionales, standardmäßig deaktiviertes Modul **Produktbild Studio**, +- grundlegend überarbeitete Seite **Neuer Artikel**, +- dauerhaft sichtbare Aktionen **Artikel generieren** und **Speichern**, +- manifestbasierter Production Updater 3.3. + +## Sicherheitsgrenzen + +Der Updater ersetzt oder löscht niemals `/app/data`, Datenbanken, Uploads, Backups, `.env` oder API-Schlüssel. GitHub-Checks, kontrollierter Merge, Coolify Auto Deploy und `/readyz`-Versionskontrolle bleiben verpflichtend. + +Technische Details: `docs/PRODUCTION_UPDATER_3_3.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a012e1c..98be276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ +## Vendoo Production Updater 3.3 – Manifest Control Artifact Contract + +- `release-manifest.json` wird nach dem Payload-Overlay ausdrücklich in die Git-Arbeitskopie kopiert. +- Die Steuerdatei wird bytegenau gegen das Release-Paket geprüft. +- Manifestprüfung in einer Git-Baseline toleriert zusätzliche unveränderte Baseline-Dateien, prüft aber alle manifestierten Dateien vollständig. +- `removedFiles` darf die Manifest-Steuerdatei nicht löschen. +- Der Windows-Lauf kann dadurch `verify-production-updater-3.3.mjs` innerhalb der geklonten Arbeitskopie ausführen. +- Prozessfehler zeigen in Status und Oberfläche zuerst die echte Fehlerausgabe; lange Standardausgaben verdrängen die Ursache nicht mehr. + +## Vendoo Production Updater 3.1 – Manifest-Based Release Application + +- replaces destructive clone cleanup and exclusion-list copying with SHA-256 verified overlay manifest +- requires `db/schema.sql` and all release-critical source files before tests can start +- preserves baseline files unless deletion is explicitly declared in `removedFiles` +- verifies every payload file before and after copying +- rejects stale Updater-2.x sessions and uses a dedicated v3 release branch + +## Vendoo Production Updater 2.10 – Windows Installer-Shell Portability + +- `verify:installer-shell` läuft über einen plattformneutralen Node-Wrapper. +- Unter Windows wird `sh.exe` aus Git for Windows über `where git`, `git --exec-path` und bekannte Installationspfade ermittelt. +- Keine globale `sh`-PATH-Annahme mehr. +- Git-Fortschritt auf stderr wird bei Exit-Code 0 als Ausgabe statt als Fehler protokolliert; npm-Warnungen erscheinen als Warnung. +- Zielversion der Anwendung bleibt Vendoo 1.43.0. + +## Vendoo Production Updater 2.9 – Systematic Windows SQLite Handle Audit + +- Zentraler Cleanup für temporäre Vendoo-SQLite-Datenbanken. +- Einladungs-Verifier schließt `better-sqlite3` vor dem Entfernen des Temp-Ordners. +- Aktive Verifier werden transitiv auf offene `lib/db.mjs`-Handles geprüft. +- Windows-Cleanup-Regression läuft für Initialmodule und Einladungen jeweils dreifach isoliert. + +## Vendoo Production Updater 2.8 – Windows SQLite Cleanup + +- `verify:initial-modules` schließt die temporäre `better-sqlite3`-Datenbank jetzt vor dem Entfernen des Testordners. +- Windows-`EBUSY` beim Löschen von `vendoo.db` behoben. +- Neuer dreifacher Cleanup-Regressionstest verhindert offene SQLite-Dateien und Temp-Reste. + +# Vendoo 1.43.0 – Production Updater 2.4 + +- GitHub-Browseranmeldung aus dem versteckten Worker entfernt. +- Sichtbares, klar beschriftetes Geräte-Anmeldefenster mit `--web --clipboard`. +- Stilles Loginstatus-Polling und automatische Fortsetzung nach erfolgreicher Anmeldung. +- Downloadfortschritt auf 10-%-Stufen gedrosselt; keine Logflut mehr. +- Neues Updater-2.4-Regressionsgate. + # Changelog +## Updater 2.1 – Worker Host & Bootstrap Diagnostics + +- überwachten Worker-Host eingeführt +- Status und Protokollpfad vor Worker-Start garantiert +- Standardausgabe und Standardfehler vollständig erfasst +- Lade-, Parser- und Initialisierungsfehler mit Datei und Zeile sichtbar +- Diagnoseordner direkt in der Oberfläche erreichbar +- Worker-Core beendet den Host nicht mehr eigenmächtig + +## 1.43.0 Updater Revision 2.0 – Production Updater Replacement + +- Bisherige Winget-/Konsolen-Hotfix-Kette vollständig durch einen eigenständigen WPF-Production-Updater ersetzt. +- Neutrale dunkle Oberfläche mit zehn klaren Updatephasen, Fortschritt, Laufzeit, Heartbeat und Live-Protokoll. +- Fehlerzustände bleiben dauerhaft sichtbar; das Fenster schließt sich weder bei Fehlern noch bei einem unerwarteten Worker-Ende automatisch. +- Aktionen für Protokoll öffnen, Fehler kopieren, kontrollierten Abbruch und Wiederaufnahme. +- Verwaltete portable GitHub CLI mit offiziellem Release-Download und SHA-256-Prüfung statt Winget-Abhängigkeit. +- Feste Timeouts, Prozessbaum-Abbruch und Heartbeats für alle externen Werkzeuge. +- Persistente Sitzung für sichere Wiederaufnahme nach Abbruch oder Neustart. +- GitHub-, Test-, Merge-, Coolify- und Produktionsprüfung bleiben als getrennte Phasen nachvollziehbar. + +## 1.43.0 – Module-aware Navigation, Listing Studio UX & Updater Status UI + +- Navigationseinträge verschwinden unmittelbar, wenn das zugehörige Modul deaktiviert wird. +- Neues authentifiziertes, minimales Modulstatus-API für die Oberfläche. +- Produktbild Studio als eigenständiges, standardmäßig deaktiviertes Modul. +- Seite „Neuer Artikel“ neu geordnet, lesbarer und konsequent am Theme-System ausgerichtet. +- „Artikel generieren“ und „Speichern“ bleiben in einer sticky Aktionsleiste dauerhaft erreichbar. +- Ein-Klick-Updater mit grafischer Fortschrittsanzeige, Prozentwert, Phase und Live-Protokoll. + +## 1.42.0 – Production Operations & Controlled Update Completion + +- Persistente Deployment-State-Machine mit Ereignisverlauf und Wiederaufnahme nach Neustart. +- Idempotency-Key und Sperre gegen parallele Deployment-Aufträge. +- Vorab-Backup wird erstellt, verifiziert und mit dem Auftrag verknüpft. +- Coolify-Trigger, Remote-Status, Readiness und Zielversion werden getrennt bewertet. +- Webhook-Abschlüsse benötigen eine zusätzliche exakte Bestätigung `CONFIRM`. +- Fehler und Zeitüberschreitungen wechseln in `rollback_required` und blockieren neue Aufträge bis zur manuellen Prüfung. +- Neuer Produktionscheck und Produktionsstatus im Update Center. +- Additive Tabellen `deployment_runs`, `deployment_events`, `backup_verifications` und `production_checks`. +- Keine Löschung oder Rücksetzung bestehender Daten, Benutzer, Uploads, Secrets, Backups oder Module. +- Neuer `UPDATE_VENDOO.bat`-Assistent automatisiert Voraussetzungen, Volltest, Release-Branch, Pull Request, Merge und Coolify-Deployment. +- Coolify-Webhook wird nur einmalig als verschlüsseltes GitHub Actions Secret hinterlegt. +- Produktionsworkflow meldet Erfolg ausschließlich bei `/readyz` und exakt Version `1.42.0`. + ## 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix - Root-Runtimeordner in `.gitignore` sind jetzt mit `/` verankert. diff --git a/FEATURES.md b/FEATURES.md index 6de18de..b15f294 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1071,3 +1071,26 @@ Neue Kernel-Routen benötigen zwingend einen Modulbesitzer und eine explizite Po - `app/modules/` und `app/modules/sessions/` bleiben versionierter Source - Git-Ignore-Semantiktest mit echtem temporärem Repository - Pflichtdatei- und Tracking-Gate im GitHub-Deploy-Assistenten + + +## 1.42.0 – Production Operations & Controlled Update Completion + +- Persistente Deployment-State-Machine: `checking`, `backup_pending`, `backup_running`, `backup_verified`, `deploy_requested`, `deploy_running`, `health_wait`, `succeeded`, `failed`, `rollback_required`. +- Idempotente Aufträge und harter Doppelstartschutz. +- Verifiziertes Vorab-Backup mit SHA-256-/Manifestbezug. +- Coolify-API-Statusabfrage über konfigurierbaren relativen Endpunkt; Webhook-Modus mit expliziter Abschlussbestätigung. +- Readiness- und Zielversionsprüfung vor Erfolgsmeldung. +- Persistenter Ereignisverlauf, Neustart-Wiederaufnahme und kontrollierte Aufhebung einer Rollback-Sperre. +- Produktionsstatus und Produktionscheck im Update Center. +- Additive Migration ohne Reset bestehender Produktivdaten. + + +## 1.43.0 – Module-aware Navigation, Listing Studio & Updater Status UI + +- Authentifizierter Modulstatus-Endpunkt `/api/modules/navigation` mit minimaler Antwortfläche. +- Navigationselemente und optionale UI-Panels sind über `data-module` beziehungsweise `data-module-panel` an den realen Modulstatus gekoppelt. +- Modulstatus wird vor geschützten Modul-APIs geladen; ein deaktiviertes AI-Modul blockiert den App-Start nicht. +- Neuer Manifestvertrag `defaultEnabled`, damit ein Modul standardmäßig aus, aber administrativ aktivierbar sein kann. +- `vendoo.product-image-tools` stellt `product-images.variants` bereit und startet standardmäßig deaktiviert. +- Listing Studio mit sticky Command Bar, klarer Zwei-Spalten-Arbeitsfläche und dauerhaft sichtbaren Aktionen. +- Grafische Update-Oberfläche mit Fortschritt, Phase, Detailmeldung, Live-Protokoll, sicherer einmaliger Coolify-Eingabe und korrektem Exit-Code. diff --git a/GitHub-Deploy-Assistent.bat b/GitHub-Deploy-Assistent.bat index 65b2213..9006d25 100644 --- a/GitHub-Deploy-Assistent.bat +++ b/GitHub-Deploy-Assistent.bat @@ -1,14 +1,4 @@ @echo off -setlocal EnableExtensions -chcp 65001 >nul -title Vendoo GitHub Deploy Assistent -cd /d "%~dp0" -powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0scripts\github-deploy.ps1" -set "EXITCODE=%ERRORLEVEL%" -if not "%EXITCODE%"=="0" ( - echo. - echo [FEHLER] GitHub-Deployment wurde mit Exit-Code %EXITCODE% beendet. - echo. - pause -) -exit /b %EXITCODE% +rem Alter Dateiname bleibt als Kompatibilitaetsstarter erhalten. +call "%~dp0UPDATE_VENDOO.bat" +exit /b %ERRORLEVEL% diff --git a/README.md b/README.md index 8d8ca50..e6a9b6f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,26 @@ -# Vendoo 1.41.2 +# Vendoo 1.43.0 -Vendoo 1.41.1 ist die korrigierte Produktionsgrundlage für den Betrieb über **Coolify**, GitHub und `https://vendoo.flatlined.de`. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify. +Vendoo 1.43.0 erweitert die kontrollierte Produktionsgrundlage um modulabhängige Navigation, ein optionales Produktbild Studio, ein neu aufgebautes Listing Studio und einen grafischen Ein-Klick-Updater. Der Container bleibt unveränderlich: Vendoo führt weder `git pull` noch Docker-Befehle aus, sondern delegiert freigegebene Deployments nach einem verifizierten Vorab-Backup an Coolify. + + +## Release 1.43.0: Module-aware Navigation, Listing Studio & Updater UI + +- deaktivierte Fachmodule verschwinden unmittelbar aus der linken Navigation +- Modulstatus wird vor modulgeschützten APIs geladen, damit deaktivierte Module den App-Start nicht blockieren +- Produktbild Studio als eigenes natives Modul mit `defaultEnabled: false` +- überarbeitete Seite **Neuer Artikel** mit theme-konformen Flächen, Abständen und responsivem Arbeitsbereich +- sticky Aktionsleiste: **Artikel generieren** und **Speichern** bleiben oben erreichbar +- grafischer `UPDATE_VENDOO.bat`-Ablauf mit Phase, Detailtext, Prozentfortschritt, Live-Protokoll und sicherer Webhook-Eingabe + +## Release 1.42.0: Production Operations & Controlled Update Completion + +- persistente Deployment-Aufträge und Ereignisse in SQLite +- idempotente Auslösung und Sperre gegen parallele Deployments +- verifiziertes Vorab-Backup als fester Bestandteil des Auftrags +- getrennte Zustände für Coolify-Annahme, laufendes Deployment, Readiness, Erfolg, Fehler und Rollbackbedarf +- Erfolg erst nach Readiness und bestätigter Zielversion beziehungsweise expliziter externer Bestätigung +- Produktionscheck für HTTPS, Cookies, Proxy, Hosts/Origins, persistente Pfade, SQLite, Backupziel, FLUX und Coolify-Konfiguration +- kein Docker-Socket, kein `git pull`, kein `npm install` und keine Selbstüberschreibung im Produktivcontainer ## Hotfix 1.41.2: native Module zuverlässig in Git @@ -24,16 +44,16 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss - keine Weitergabe des Docker-Sockets an Vendoo - genaue Anleitung: `docs/COOLIFY_PRODUCTION_1_41_0.md` -## Kontrollierte Web-Updates +## Kontrollierte Updates -- geschütztes natives Modul `vendoo.deployments` -- Coolify-Deploy-Webhook oder minimal berechtigter API-Token -- Coolify-Zugangsdaten AES-256-GCM-verschlüsselt im Secret Store -- exakte Administratorbestätigung `DEPLOY` -- verpflichtendes Vorab-Backup vor dem Deploymentauftrag -- GitHub-Repository, Produktionsbranch und Update-Kanal im Update Center -- kein selbstverändernder Container, kein `git pull`, kein Docker-Socket -- vollständiger Ablauf: `docs/WEB_UPDATE_CENTER_1_41_0.md` +- Production Updater 3.3 mit SHA-256-geprüftem Payload und bytegeprüfter Manifest-Steuerdatei +- keine destruktive Leerung der geklonten Git-Baseline +- Pflichtdateien wie `db/schema.sql` werden vor Tests und Push geprüft +- Pull Request und vollständige GitHub-Checks auf Windows und Linux +- Coolify Auto Deploy nach erfolgreichem Merge nach `main` +- exakte Produktionsbestätigung über `/readyz` und Zielversion +- kein selbstverändernder Container, kein `git pull` in Produktion, kein Docker-Socket +- technische Details: `docs/PRODUCTION_UPDATER_3_1.md` ## GitHub-, Merge- und Releaseprozess @@ -47,10 +67,10 @@ Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschloss ## Plattformstand -- 17 registrierte Module -- 15 native Module +- 18 registrierte Module +- 16 native Module, davon Produktbild Studio standardmäßig deaktiviert - 2 transparente Legacy Bridges: System und Operations -- 38 vollständig aufgelöste Capabilities +- 41 vollständig aufgelöste Capabilities - geschützte Kernmodule und kontrollierbare optionale Module - `.vmod`-Import/Export, Quarantäne und Abhängigkeitssteuerung - Theme-, Security-, Identity-, Catalog-, Media-, Publishing-, AI-, FLUX- und Quality-Module bleiben enthalten diff --git a/UPDATE_VENDOO.bat b/UPDATE_VENDOO.bat new file mode 100644 index 0000000..d8d26e4 --- /dev/null +++ b/UPDATE_VENDOO.bat @@ -0,0 +1,51 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +cd /d "%~dp0" +if errorlevel 1 goto :path_error + +set "VENDOO_PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +if not exist "%VENDOO_PS%" goto :powershell_missing + +echo Vendoo Production Updater 3.3 preflight... +"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File ".\scripts\updater\Vendoo.Updater.Preflight.ps1" +set "VENDOO_RC=%ERRORLEVEL%" +if not "%VENDOO_RC%"=="0" goto :preflight_failed + +"%VENDOO_PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -STA -File ".\scripts\update-vendoo-gui.ps1" +set "VENDOO_RC=%ERRORLEVEL%" +if not "%VENDOO_RC%"=="0" goto :gui_failed + +exit /b 0 + +:path_error +echo. +echo [ERROR] The Vendoo package directory could not be opened. +echo Path: %~dp0 +echo. +pause +exit /b 30 + +:powershell_missing +echo. +echo [ERROR] Windows PowerShell 5.1 was not found. +echo Expected: %VENDOO_PS% +echo. +pause +exit /b 31 + +:preflight_failed +echo. +echo [ERROR] The Windows PowerShell preflight failed with exit code %VENDOO_RC%. +echo Log: %CD%\logs\updater-preflight.log +echo. +pause +exit /b %VENDOO_RC% + +:gui_failed +echo. +echo [ERROR] The updater UI exited with code %VENDOO_RC%. +echo Log: %CD%\logs\updater-bootstrap.log +echo. +pause +exit /b %VENDOO_RC% diff --git a/app/contracts/module.schema.json b/app/contracts/module.schema.json index 737ac22..f042406 100644 --- a/app/contracts/module.schema.json +++ b/app/contracts/module.schema.json @@ -4,31 +4,91 @@ "title": "Vendoo Module Manifest", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "id", "name", "version", "type", "status", "requires", "permissions", "provides", "consumes", "owns"], + "required": [ + "schemaVersion", + "id", + "name", + "version", + "type", + "status", + "requires", + "permissions", + "provides", + "consumes", + "owns" + ], "properties": { - "schemaVersion": { "const": 1 }, - "id": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" }, - "name": { "type": "string", "minLength": 1, "maxLength": 80 }, - "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, - "type": { "enum": ["core", "feature", "adapter", "extension-host"] }, - "status": { "enum": ["native", "legacy-bridge", "disabled"] }, - "description": { "type": "string", "maxLength": 500 }, - "requires": { "$ref": "#/$defs/moduleIds" }, - "permissions": { "$ref": "#/$defs/capabilities" }, - "provides": { "$ref": "#/$defs/capabilities" }, - "consumes": { "$ref": "#/$defs/capabilities" }, - "owns": { "$ref": "#/$defs/capabilities" } + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" + }, + "type": { + "enum": [ + "core", + "feature", + "adapter", + "extension-host" + ] + }, + "status": { + "enum": [ + "native", + "legacy-bridge", + "disabled" + ] + }, + "defaultEnabled": { + "type": "boolean", + "default": true + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "requires": { + "$ref": "#/$defs/moduleIds" + }, + "permissions": { + "$ref": "#/$defs/capabilities" + }, + "provides": { + "$ref": "#/$defs/capabilities" + }, + "consumes": { + "$ref": "#/$defs/capabilities" + }, + "owns": { + "$ref": "#/$defs/capabilities" + } }, "$defs": { "moduleIds": { "type": "array", "uniqueItems": true, - "items": { "type": "string", "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" } + "items": { + "type": "string", + "pattern": "^vendoo\\.[a-z][a-z0-9-]*$" + } }, "capabilities": { "type": "array", "uniqueItems": true, - "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" } + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + } } } } diff --git a/app/core/identity/identity-store.mjs b/app/core/identity/identity-store.mjs index f8a04ab..bddc19b 100644 --- a/app/core/identity/identity-store.mjs +++ b/app/core/identity/identity-store.mjs @@ -296,6 +296,38 @@ export function validateAuthToken(token) { return row; } + +export function listInvitations(limit = 100) { + const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100)); + return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at, + CASE + WHEN used = 1 THEN 'revoked_or_used' + WHEN expires_at <= datetime('now') THEN 'expired' + ELSE 'pending' + END AS status + FROM auth_tokens + WHERE type = 'invite' + ORDER BY created_at DESC + LIMIT ?`).all(safeLimit); +} + +export function getInvitation(id) { + return db.prepare(`SELECT id, email, role, used, expires_at, created_by, created_at, + CASE + WHEN used = 1 THEN 'revoked_or_used' + WHEN expires_at <= datetime('now') THEN 'expired' + ELSE 'pending' + END AS status + FROM auth_tokens WHERE id = ? AND type = 'invite'`).get(Number(id)); +} + +export function revokeInvitation(id) { + const invitation = getInvitation(id); + if (!invitation) return null; + if (invitation.status === 'pending') db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ? AND type = ?').run(Number(id), 'invite'); + return { ...invitation, status: invitation.status === 'pending' ? 'revoked' : invitation.status, used: 1 }; +} + // --- Rate Limiting --- const rateLimits = new Map(); diff --git a/app/core/modules/module-package-contract.mjs b/app/core/modules/module-package-contract.mjs index e642461..0010057 100644 --- a/app/core/modules/module-package-contract.mjs +++ b/app/core/modules/module-package-contract.mjs @@ -163,7 +163,11 @@ export function buildModulePackage({ manifest, runtime = { mode: 'builtin-refere const buffer = Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content || ''), 'utf8'); return { path, sha256: sha256(buffer), size: buffer.length, content: buffer.toString('base64') }; }); - const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null }; + // Der Digest muss auf demselben normalisierten Manifest basieren wie die spätere + // Validierung. Sonst würden additive Manifest-Defaults (z. B. defaultEnabled) + // den Digest nachträglich verändern. + const normalizedManifest = validateModuleManifest(manifest); + const draft = { format: MODULE_PACKAGE_FORMAT, schemaVersion: MODULE_PACKAGE_SCHEMA, manifest: normalizedManifest, runtime, files: normalizedFiles, createdAt, integrity: { algorithm: 'sha256', digest: '' }, signature: null }; draft.integrity.digest = calculatePackageDigest(draft); return validateModulePackage(draft, { allowBuiltinReference: true }); } diff --git a/app/kernel/module-contract.mjs b/app/kernel/module-contract.mjs index 1b25b7f..5e56483 100644 --- a/app/kernel/module-contract.mjs +++ b/app/kernel/module-contract.mjs @@ -29,6 +29,7 @@ export function validateModuleManifest(manifest) { version: String(manifest.version || '').trim(), type: String(manifest.type || '').trim(), status: String(manifest.status || 'native').trim(), + defaultEnabled: manifest.defaultEnabled !== false, description: String(manifest.description || '').trim(), requires: Object.freeze(uniqueStrings(manifest.requires || [], 'requires', MODULE_ID)), permissions: Object.freeze(uniqueStrings(manifest.permissions || [], 'permissions')), diff --git a/app/kernel/platform-kernel.mjs b/app/kernel/platform-kernel.mjs index b9ab23e..e06b9ae 100644 --- a/app/kernel/platform-kernel.mjs +++ b/app/kernel/platform-kernel.mjs @@ -84,6 +84,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys = features.define('modules.package-format', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert validierte .vmod-Pakete mit SHA-256-Integrität und optionaler Ed25519-Signatur.' }); features.define('publishing.native-module', { defaultValue: true, owner: 'vendoo.publishing', description: 'Aktiviert native Publishing-Verträge.' }); features.define('ai.native-module', { defaultValue: true, owner: 'vendoo.ai', description: 'Aktiviert native AI-Verträge.' }); + features.define('product-images.optional-module', { defaultValue: false, owner: 'vendoo.product-image-tools', description: 'Aktiviert optionale Ghost-Mannequin- und Flatlay-Produktbildvarianten.' }); features.define('flux.native-module', { defaultValue: true, owner: 'vendoo.flux-studio', description: 'Aktiviert native FLUX-Verträge.' }); features.define('quality.native-module', { defaultValue: true, owner: 'vendoo.quality', description: 'Aktiviert native Quality-Verträge.' }); features.define('deployments.coolify', { defaultValue: true, owner: 'vendoo.deployments', description: 'Aktiviert kontrollierte Coolify-Deployments ohne Docker-Socket.' }); @@ -93,7 +94,7 @@ export function createPlatformKernel({ version, hasPermission, moduleTrustKeys = .split(',').map(value => value.trim()).filter(Boolean)); for (const module of loadBuiltInModules()) { const forcedEnabled = PROTECTED_MODULE_IDS.includes(module.manifest.id); - const defaultEnabled = module.manifest.status !== 'disabled' && !initialDisabledModules.has(module.manifest.id); + const defaultEnabled = module.manifest.status !== 'disabled' && module.manifest.defaultEnabled !== false && !initialDisabledModules.has(module.manifest.id); const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled); modules.register(module, { enabled }); } diff --git a/app/modules/catalog.mjs b/app/modules/catalog.mjs index dabe14f..3e48168 100644 --- a/app/modules/catalog.mjs +++ b/app/modules/catalog.mjs @@ -13,6 +13,7 @@ import { createUsersModule } from './users/index.mjs'; import { createModuleManagerModule } from './module-manager/index.mjs'; import { createAiModule } from './ai/index.mjs'; import { createFluxStudioModule } from './flux-studio/index.mjs'; +import { createProductImageToolsModule } from './product-image-tools/index.mjs'; import { createQualityModule } from './quality/index.mjs'; import { createPublishingModule } from './publishing/index.mjs'; import { createDeploymentsModule } from './deployments/index.mjs'; @@ -33,6 +34,6 @@ export function loadBuiltInModules() { return [ ...legacyModuleNames.map(legacyModule), createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(), - createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(), + createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createProductImageToolsModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(), ]; } diff --git a/app/modules/deployments/index.mjs b/app/modules/deployments/index.mjs index d35ca02..ce0169a 100644 --- a/app/modules/deployments/index.mjs +++ b/app/modules/deployments/index.mjs @@ -21,7 +21,7 @@ export function createDeploymentsModule() { manifest, lifecycle: { async start() { service = createDeploymentService(adapters); }, - async stop() { service = null; }, + async stop() { service?.stop?.(); service = null; }, async health() { return getDeploymentService().health(); }, }, }; diff --git a/app/modules/deployments/module.json b/app/modules/deployments/module.json index dc335c1..8d37d7d 100644 --- a/app/modules/deployments/module.json +++ b/app/modules/deployments/module.json @@ -2,10 +2,10 @@ "schemaVersion": 1, "id": "vendoo.deployments", "name": "Deployments & Updates", - "version": "1.0.0", + "version": "1.1.0", "type": "core", "status": "native", - "description": "Kontrollierte Coolify-Deployments, verschlüsselte Deployment-Zugangsdaten und sichere Web-Updates ohne Docker-Socket.", + "description": "Persistente, kontrollierte Coolify-Deployments mit Vorab-Backup, Idempotenz, Statusverfolgung, Readiness-, Zielversions- und Produktionschecks ohne Docker-Socket.", "requires": [ "vendoo.security", "vendoo.settings", @@ -18,6 +18,8 @@ "provides": [ "deployments.control", "deployments.coolify", + "deployments.production-checks", + "deployments.state-machine", "updates.web" ], "consumes": [ @@ -28,7 +30,9 @@ ], "owns": [ "deployments.configuration", + "deployments.events", "deployments.history", + "deployments.production-checks", "updates.coolify" ] } diff --git a/app/modules/deployments/repository.mjs b/app/modules/deployments/repository.mjs new file mode 100644 index 0000000..43ae25a --- /dev/null +++ b/app/modules/deployments/repository.mjs @@ -0,0 +1,192 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '../../../lib/db.mjs'; +import { PlatformError } from '../../kernel/errors.mjs'; + +import { + ACTIVE_DEPLOYMENT_STATES, + DEPLOYMENT_STATES, + canTransitionDeployment, + isActiveDeploymentState, +} from './state-machine.mjs'; + +function parseJson(value, fallback) { + try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } +} + +function serializeJson(value, fallback) { + try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); } +} + +function normalizeRun(row) { + if (!row) return null; + return { + ...row, + request: parseJson(row.request_json, {}), + result: parseJson(row.result_json, {}), + active: isActiveDeploymentState(row.state), + }; +} + +function fail(message, code = 'DEPLOYMENT_STATE_INVALID', status = 409, details = null) { + throw new PlatformError(message, { code, status, expose: true, details }); +} + +export function createDeploymentRepository(database = db) { + const insertRun = database.prepare(`INSERT INTO deployment_runs ( + id, idempotency_key, state, provider, trigger_mode, current_version, target_version, + build_revision_before, requested_by, reason, github_repository, github_branch, + request_json, timeout_at, started_at, updated_at + ) VALUES (?, ?, 'checking', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`); + + const insertEvent = database.prepare(`INSERT INTO deployment_events + (run_id, state, event_type, message, details_json) + VALUES (?, ?, ?, ?, ?)`); + + const getRunStmt = database.prepare('SELECT * FROM deployment_runs WHERE id = ?'); + const getIdempotentStmt = database.prepare('SELECT * FROM deployment_runs WHERE idempotency_key = ?'); + const getActiveStmt = database.prepare(`SELECT * FROM deployment_runs + WHERE state IN (${ACTIVE_DEPLOYMENT_STATES.map(() => '?').join(',')}) + ORDER BY created_at DESC LIMIT 1`); + + function addEvent(runId, state, eventType, message, details = {}) { + insertEvent.run(runId, state, String(eventType || 'state').slice(0, 80), String(message || '').slice(0, 1000), serializeJson(details, {})); + } + + function createRun(input) { + const id = input.id || randomUUID(); + const existing = getIdempotentStmt.get(input.idempotencyKey); + if (existing) return { run: normalizeRun(existing), reused: true }; + const active = getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES); + if (active) fail('Es läuft bereits ein Deployment oder es ist ein Rollback erforderlich.', 'DEPLOYMENT_ALREADY_ACTIVE', 409, { run_id: active.id, state: active.state }); + + try { + insertRun.run( + id, + input.idempotencyKey, + input.provider || 'coolify', + input.triggerMode || 'webhook', + input.currentVersion, + input.targetVersion, + input.buildRevisionBefore || null, + input.requestedBy || null, + input.reason || 'manual', + input.githubRepository || null, + input.githubBranch || null, + serializeJson(input.request || {}, {}), + input.timeoutAt || null, + ); + } catch (error) { + if (String(error?.message || '').includes('UNIQUE')) { + const raced = getIdempotentStmt.get(input.idempotencyKey); + if (raced) return { run: normalizeRun(raced), reused: true }; + } + throw error; + } + addEvent(id, 'checking', 'run.created', 'Deployment-Auftrag wurde angelegt.', { + target_version: input.targetVersion, + trigger_mode: input.triggerMode, + }); + return { run: normalizeRun(getRunStmt.get(id)), reused: false }; + } + + function getRun(id) { return normalizeRun(getRunStmt.get(id)); } + function findByIdempotencyKey(key) { return normalizeRun(getIdempotentStmt.get(key)); } + function getActiveRun() { return normalizeRun(getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES)); } + + function listRuns(limit = 25) { + const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25)); + return database.prepare('SELECT * FROM deployment_runs ORDER BY created_at DESC LIMIT ?').all(safeLimit).map(normalizeRun); + } + + function listEvents(runId, limit = 200) { + const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 200)); + return database.prepare(`SELECT * FROM deployment_events WHERE run_id = ? ORDER BY id ASC LIMIT ?`).all(runId, safeLimit) + .map(row => ({ ...row, details: parseJson(row.details_json, {}) })); + } + + function transition(runId, nextState, { message = '', eventType = 'state.changed', details = {}, patch = {} } = {}) { + if (!DEPLOYMENT_STATES.includes(nextState)) fail('Unbekannter Deployment-Zustand.', 'DEPLOYMENT_STATE_UNKNOWN', 500, { next_state: nextState }); + const current = getRunStmt.get(runId); + if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404); + if (current.state === nextState) return normalizeRun(current); + if (!canTransitionDeployment(current.state, nextState)) { + fail(`Ungültiger Deployment-Übergang von ${current.state} nach ${nextState}.`, 'DEPLOYMENT_TRANSITION_BLOCKED', 409, { + run_id: runId, from: current.state, to: nextState, + }); + } + + const completed = ['succeeded', 'failed'].includes(nextState); + const columns = ['state = ?', "updated_at = datetime('now')"]; + const values = [nextState]; + const allowedPatch = new Set([ + 'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message', + 'build_revision_after', 'timeout_at', + ]); + for (const [key, value] of Object.entries(patch || {})) { + if (!allowedPatch.has(key)) continue; + columns.push(`${key} = ?`); + values.push(key.endsWith('_json') ? serializeJson(value, {}) : value); + } + if (completed) columns.push("completed_at = datetime('now')"); + values.push(runId); + database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values); + addEvent(runId, nextState, eventType, message || `Zustand: ${nextState}`, details); + return normalizeRun(getRunStmt.get(runId)); + } + + function patchRun(runId, patch = {}, { eventType = null, message = '', details = {} } = {}) { + const current = getRunStmt.get(runId); + if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404); + const allowedPatch = new Set([ + 'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message', + 'build_revision_after', 'timeout_at', + ]); + const columns = ["updated_at = datetime('now')"]; + const values = []; + for (const [key, value] of Object.entries(patch || {})) { + if (!allowedPatch.has(key)) continue; + columns.push(`${key} = ?`); + values.push(key.endsWith('_json') ? serializeJson(value, {}) : value); + } + values.push(runId); + database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values); + if (eventType) addEvent(runId, current.state, eventType, message || eventType, details); + return normalizeRun(getRunStmt.get(runId)); + } + + function recordBackupVerification({ backupId, runId = null, status, sha256 = null, fileSize = 0, sqliteIntegrity = null, manifest = {}, errors = [], warnings = [] }) { + const id = randomUUID(); + database.prepare(`INSERT INTO backup_verifications + (id, backup_id, deployment_run_id, status, sha256, file_size, sqlite_integrity, manifest_json, errors_json, warnings_json, verified_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run( + id, backupId, runId, status, sha256, Number(fileSize) || 0, sqliteIntegrity, + serializeJson(manifest, {}), serializeJson(errors, []), serializeJson(warnings, []), + ); + return database.prepare('SELECT * FROM backup_verifications WHERE id = ?').get(id); + } + + function createProductionCheck({ createdBy = null, results = [] } = {}) { + const id = randomUUID(); + const passed = results.filter(item => item.status === 'pass').length; + const warnings = results.filter(item => item.status === 'warning').length; + const failed = results.filter(item => item.status === 'fail').length; + const overall = failed ? 'failed' : (warnings ? 'warning' : 'passed'); + database.prepare(`INSERT INTO production_checks + (id, overall_status, passed_count, warning_count, failed_count, results_json, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, overall, passed, warnings, failed, serializeJson(results, []), createdBy); + return { id, overall_status: overall, passed_count: passed, warning_count: warnings, failed_count: failed, results }; + } + + function latestProductionCheck() { + const row = database.prepare('SELECT * FROM production_checks ORDER BY created_at DESC LIMIT 1').get(); + return row ? { ...row, results: parseJson(row.results_json, []) } : null; + } + + return Object.freeze({ + createRun, getRun, findByIdempotencyKey, getActiveRun, listRuns, listEvents, + transition, patchRun, addEvent, recordBackupVerification, + createProductionCheck, latestProductionCheck, + }); +} + +export const deploymentRepository = createDeploymentRepository(); diff --git a/app/modules/deployments/routes.mjs b/app/modules/deployments/routes.mjs index 43c66ab..bd9f9e0 100644 --- a/app/modules/deployments/routes.mjs +++ b/app/modules/deployments/routes.mjs @@ -1,4 +1,5 @@ import { objectSchema } from '../../kernel/input-schema.mjs'; +import { PlatformError } from '../../kernel/errors.mjs'; import { getDeploymentService } from './index.mjs'; const settingsSchema = objectSchema({ @@ -8,6 +9,9 @@ const settingsSchema = objectSchema({ trigger_mode: { type: 'string', enum: ['webhook', 'api'] }, deploy_method: { type: 'string', enum: ['GET', 'POST'] }, deploy_endpoint: { type: 'string', maxLength: 500 }, + deployment_status_endpoint: { type: 'string', maxLength: 500 }, + deployment_timeout_seconds: { type: 'number', integer: true, min: 60, max: 7200 }, + health_poll_seconds: { type: 'number', integer: true, min: 5, max: 300 }, github_repository: { type: 'string', maxLength: 200 }, github_branch: { type: 'string', maxLength: 120 }, ghcr_image: { type: 'string', maxLength: 300 }, @@ -17,15 +21,33 @@ const settingsSchema = objectSchema({ api_token: { type: 'string', maxLength: 16384, trim: false }, deploy_webhook_url: { type: 'string', maxLength: 2000, trim: false }, }); + const triggerSchema = objectSchema({ - force: { type: 'boolean' }, confirmation: { type: 'string', required: true, maxLength: 20 }, reason: { type: 'string', maxLength: 120 }, + force: { type: 'boolean' }, + confirmation: { type: 'string', required: true, maxLength: 20 }, + reason: { type: 'string', maxLength: 120 }, + idempotency_key: { type: 'string', maxLength: 160 }, + target_version: { type: 'string', maxLength: 40 }, }); +const confirmSchema = objectSchema({ + confirmation: { type: 'string', required: true, maxLength: 20 }, + outcome: { type: 'string', required: true, enum: ['running', 'succeeded', 'failed'] }, + message: { type: 'string', maxLength: 500 }, +}); + +function requireRun(run) { + if (!run) throw new PlatformError('Deployment-Auftrag wurde nicht gefunden.', { + code: 'DEPLOYMENT_RUN_NOT_FOUND', status: 404, expose: true, + }); + return run; +} + export function registerDeploymentRoutes({ app, routes, deps }) { routes.register(app, { id: 'deployments.status.read', method: 'GET', path: '/api/admin/deployment', owner: 'vendoo.deployments', policy: 'updates.manage', audit: 'deployments.status.read', rateLimit: 'administration', csrf: false, stability: 'stable', - handler: async (_req, res) => res.json(getDeploymentService().status()), + handler: async (_req, res) => res.json(await getDeploymentService().status()), }); routes.register(app, { id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments', @@ -48,15 +70,63 @@ export function registerDeploymentRoutes({ app, routes, deps }) { routes.register(app, { id: 'deployments.updates.check', method: 'POST', path: '/api/admin/deployment/updates/check', owner: 'vendoo.deployments', policy: 'updates.manage', audit: 'deployments.updates.checked', rateLimit: 'administration', csrf: true, stability: 'stable', - handler: async (_req, res) => res.json(await getDeploymentService().checkUpdates()), + handler: async (req, res) => { + const result = await getDeploymentService().checkUpdates(); + deps.auditLog(req.user.id, req.user.email, 'deployment.updates.checked', 'deployment', 'manifest', JSON.stringify({ update_available: result.update_available || false }), deps.getClientIp(req)); + res.json(result); + }, }); routes.register(app, { id: 'deployments.trigger', method: 'POST', path: '/api/admin/deployment/trigger', owner: 'vendoo.deployments', policy: 'updates.manage', audit: 'deployments.triggered', rateLimit: 'administration', csrf: true, stability: 'stable', input: triggerSchema, handler: async (req, res) => { - const result = await getDeploymentService().trigger(req.validatedBody || {}); - deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', 'coolify', JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch }), deps.getClientIp(req)); - res.status(202).json(result); + const idempotencyKey = req.get('Idempotency-Key') || req.validatedBody?.idempotency_key || ''; + const result = await getDeploymentService().trigger(req.validatedBody || {}, { userId: req.user.id, idempotencyKey }); + deps.auditLog(req.user.id, req.user.email, 'deployment.triggered', 'deployment', result.id, JSON.stringify({ backup_id: result.backup_id, branch: result.github_branch, target_version: result.target_version, reused: result.reused }), deps.getClientIp(req)); + res.status(result.reused ? 200 : 202).json(result); }, }); + routes.register(app, { + id: 'deployments.runs.list', method: 'GET', path: '/api/admin/deployment/runs', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.runs.read', rateLimit: 'administration', csrf: false, stability: 'stable', + handler: async (req, res) => res.json({ runs: getDeploymentService().listRuns(Number(req.query.limit) || 20) }), + }); + routes.register(app, { + id: 'deployments.runs.read', method: 'GET', path: '/api/admin/deployment/runs/:id', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.run.read', rateLimit: 'administration', csrf: false, stability: 'stable', + handler: async (req, res) => res.json(requireRun(getDeploymentService().getRun(req.params.id))), + }); + routes.register(app, { + id: 'deployments.runs.refresh', method: 'POST', path: '/api/admin/deployment/runs/:id/refresh', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.run.refreshed', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + requireRun(getDeploymentService().getRun(req.params.id)); + const result = await getDeploymentService().refreshRun(req.params.id); + deps.auditLog(req.user.id, req.user.email, 'deployment.run.refreshed', 'deployment', req.params.id, JSON.stringify({ state: result.state }), deps.getClientIp(req)); + res.json(result); + }, + }); + routes.register(app, { + id: 'deployments.runs.confirm', method: 'POST', path: '/api/admin/deployment/runs/:id/confirm', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.run.confirmed', rateLimit: 'administration', csrf: true, stability: 'stable', input: confirmSchema, + handler: async (req, res) => { + const result = await getDeploymentService().confirmExternal(req.params.id, req.validatedBody || {}); + deps.auditLog(req.user.id, req.user.email, 'deployment.run.confirmed', 'deployment', req.params.id, JSON.stringify({ outcome: req.validatedBody?.outcome, state: result.state }), deps.getClientIp(req)); + res.json(result); + }, + }); + routes.register(app, { + id: 'deployments.production.check.run', method: 'POST', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.production.checked', rateLimit: 'administration', csrf: true, stability: 'stable', + handler: async (req, res) => { + const result = await getDeploymentService().runProductionCheck({ userId: req.user.id }); + deps.auditLog(req.user.id, req.user.email, 'deployment.production.checked', 'deployment', result.id, JSON.stringify({ overall_status: result.overall_status, failed_count: result.failed_count }), deps.getClientIp(req)); + res.json(result); + }, + }); + routes.register(app, { + id: 'deployments.production.check.read', method: 'GET', path: '/api/admin/deployment/production-check', owner: 'vendoo.deployments', + policy: 'updates.manage', audit: 'deployments.production.check.read', rateLimit: 'administration', csrf: false, stability: 'stable', + handler: async (_req, res) => res.json({ check: getDeploymentService().latestProductionCheck() }), + }); } diff --git a/app/modules/deployments/service.mjs b/app/modules/deployments/service.mjs index 3c58439..9aff937 100644 --- a/app/modules/deployments/service.mjs +++ b/app/modules/deployments/service.mjs @@ -1,15 +1,24 @@ -import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs'; -import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs'; -import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs'; +import { getSecret, getSecretMetadata, setSecret } from '../../../lib/secret-store.mjs'; +import { + APP_ROOT, APP_VERSION, BACKUP_DIR, DATA_ROOT, DB_PATH, LOG_DIR, + MODULES_DIR, MODULE_STATE_PATH, OPERATIONS_DIR, UPLOADS_DIR, + runtimePathSummary, verifyRuntimeWritable, +} from '../../../lib/runtime-paths.mjs'; +import { db } from '../../../lib/db.mjs'; import { PlatformError } from '../../kernel/errors.mjs'; +import { deploymentRepository } from './repository.mjs'; -const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json'); const PROVIDERS = new Set(['none', 'coolify']); const MODES = new Set(['webhook', 'api']); const CHANNELS = new Set(['stable', 'beta']); const METHODS = new Set(['GET', 'POST']); +const TERMINAL_COOLIFY_SUCCESS = new Set(['finished', 'success', 'succeeded', 'completed', 'healthy']); +const TERMINAL_COOLIFY_FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'stopped']); +const MASKED_SECRET = '••••••••'; function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) { throw new PlatformError(message, { code, status, expose: true, details }); @@ -28,6 +37,12 @@ function cleanUuid(value) { return text; } +function cleanIdempotencyKey(value) { + const text = clean(value || randomUUID(), 160); + if (!/^[a-zA-Z0-9._:-]{8,160}$/.test(text)) fail('Idempotency-Key besitzt ein ungültiges Format.', 'IDEMPOTENCY_KEY_INVALID'); + return text; +} + function normalizeBaseUrl(value) { const text = clean(value, 1000).replace(/\/$/, ''); if (!text) return ''; @@ -54,16 +69,28 @@ function normalizeWebhookUrl(value) { return url.toString(); } -function readLastDeployment() { - try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; } - catch { return null; } +function normalizeEndpoint(value, fallback, placeholders = []) { + const text = clean(value || fallback, 500); + if (!text.startsWith('/') || text.startsWith('//') || text.includes('..')) fail('Coolify API-Endpunkt muss ein sicherer relativer Pfad sein.'); + if (/\r|\n|\0/.test(text)) fail('Coolify API-Endpunkt ist ungültig.'); + const unknown = [...text.matchAll(/\{([^}]+)\}/g)].map(match => match[1]).filter(name => !placeholders.includes(name)); + if (unknown.length) fail(`Unbekannter Platzhalter im Coolify-Endpunkt: ${unknown.join(', ')}`); + return text; } -function writeLastDeployment(value) { - mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true }); - const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`; - writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); - renameSync(temporary, LAST_DEPLOYMENT_PATH); +function boolEnv(name, fallback = false) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + return /^(1|true|yes|on)$/i.test(String(raw)); +} + +function intEnv(name, fallback, min, max) { + const value = Number(process.env[name]); + return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback; +} + +function buildRevision() { + return clean(process.env.VENDOO_BUILD_REVISION || process.env.GIT_COMMIT || process.env.SOURCE_COMMIT || '', 160) || null; } function currentSettings() { @@ -78,88 +105,288 @@ function currentSettings() { trigger_mode: mode, deploy_method: method, deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'), + deployment_status_endpoint: String(process.env.COOLIFY_DEPLOYMENT_STATUS_ENDPOINT || '/api/v1/deployments/{deployment_id}'), github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'), github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'), ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'), update_channel: channel, backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')), - backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')), + backup_uploads: boolEnv('VENDOO_DEPLOY_BACKUP_UPLOADS', false), + deployment_timeout_seconds: intEnv('VENDOO_DEPLOY_TIMEOUT_SECONDS', 900, 60, 7200), + health_poll_seconds: intEnv('VENDOO_DEPLOY_HEALTH_POLL_SECONDS', 15, 5, 300), api_token: getSecretMetadata('COOLIFY_API_TOKEN'), deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'), }; } -function publicSettings() { - const settings = currentSettings(); - const configured = settings.provider === 'coolify' && ( +function configuredState(settings) { + return settings.provider === 'coolify' && ( (settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) || (settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured) ); +} + +function publicSettings(repository = deploymentRepository) { + const settings = currentSettings(); return { ...settings, - configured, + configured: configuredState(settings), api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider }, deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider }, current_version: APP_VERSION, - last_deployment: readLastDeployment(), + build_revision: buildRevision(), + active_run: repository.getActiveRun(), + recent_runs: repository.listRuns(10).map(run => ({ ...run, events: repository.listEvents(run.id, 50) })), docker_socket_access: false, }; } -function buildApiDeployUrl(settings, force) { - const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}'; - if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.'); - if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.'); - const path = endpoint - .replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid)) - .replaceAll('{force}', force ? 'true' : 'false'); +function buildApiUrl(settings, template, replacements) { + let path = template; + for (const [name, value] of Object.entries(replacements)) path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value ?? ''))); return `${settings.coolify_url}${path}`; } -async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) { +function buildApiDeployUrl(settings, force) { + const endpoint = normalizeEndpoint(settings.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']); + return buildApiUrl(settings, endpoint, { uuid: settings.resource_uuid, force: force ? 'true' : 'false' }); +} + +function buildApiStatusUrl(settings, deploymentId) { + const endpoint = normalizeEndpoint(settings.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']); + return buildApiUrl(settings, endpoint, { deployment_id: deploymentId }); +} + +async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = 15000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); } + try { return await fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); } catch (error) { - if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504); - fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502); + if (error?.name === 'AbortError') fail('Anfrage hat das Zeitlimit überschritten.', 'REQUEST_TIMEOUT', 504); + fail(`Ziel ist nicht erreichbar: ${error.message}`, 'REMOTE_UNREACHABLE', 502); } finally { clearTimeout(timeout); } } async function readSafeResponse(response) { - const text = (await response.text()).slice(0, 4000); + const text = (await response.text()).slice(0, 64 * 1024); try { return text ? JSON.parse(text) : null; } catch { return text || null; } } -export function createDeploymentService({ createBackup, checkForUpdates } = {}) { +function redact(value, depth = 0) { + if (depth > 8) return '[depth-limit]'; + if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1)); + if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 4000) : value; + const result = {}; + for (const [key, item] of Object.entries(value)) { + if (/token|secret|password|authorization|webhook|credential|key/i.test(key)) result[key] = '[redacted]'; + else result[key] = redact(item, depth + 1); + } + return result; +} + +function extractDeploymentId(value, depth = 0) { + if (depth > 6 || value == null) return null; + if (Array.isArray(value)) { + for (const item of value) { const found = extractDeploymentId(item, depth + 1); if (found) return found; } + return null; + } + if (typeof value !== 'object') return null; + for (const key of ['deployment_id', 'deployment_uuid', 'deploymentUuid', 'uuid', 'id']) { + const candidate = value[key]; + if (candidate && /^[a-zA-Z0-9_-]{4,160}$/.test(String(candidate))) return String(candidate); + } + for (const item of Object.values(value)) { const found = extractDeploymentId(item, depth + 1); if (found) return found; } + return null; +} + +function extractRemoteStatus(value) { + if (!value || typeof value !== 'object') return ''; + const candidates = [value.status, value.state, value.deployment_status, value.data?.status, value.data?.state]; + return String(candidates.find(Boolean) || '').trim().toLowerCase(); +} + +function semver(value) { + const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + if (!match) return null; + return { raw: String(value).replace(/^v/, ''), major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] || '' }; +} + +function compareVersions(a, b) { + const left = semver(a); const right = semver(b); + if (!left || !right) fail('Update-Manifest enthält eine ungültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502); + for (const key of ['major', 'minor', 'patch']) if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1; + if (left.prerelease === right.prerelease) return 0; + if (!left.prerelease) return 1; + if (!right.prerelease) return -1; + return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }); +} + +function validateUpdateResult(result, channel) { + if (!result?.configured) return { ...result, current_version: APP_VERSION, channel }; + const latest = String(result.latest_version || result.version || result.manifest?.version || ''); + const parsed = semver(latest); + if (!parsed) fail('Update-Manifest enthält keine gültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502); + if (channel === 'stable' && parsed.prerelease) fail('Der Stable-Kanal akzeptiert keine Vorabversion.', 'UPDATE_CHANNEL_MISMATCH', 502); + const manifest = result.manifest && typeof result.manifest === 'object' ? result.manifest : {}; + const manifestChannel = String(manifest.channel || channel).toLowerCase(); + if (!CHANNELS.has(manifestChannel) || (channel === 'stable' && manifestChannel !== 'stable')) { + fail('Update-Manifest gehört nicht zum konfigurierten Update-Kanal.', 'UPDATE_CHANNEL_MISMATCH', 502); + } + const updateAvailable = compareVersions(latest, APP_VERSION) > 0; + const sha256 = String(manifest.sha256 || manifest.package_sha256 || result.sha256 || '').trim().toLowerCase(); + if (updateAvailable && !/^[a-f0-9]{64}$/.test(sha256)) fail('Update-Manifest enthält keine gültige SHA-256-Prüfsumme.', 'UPDATE_CHECKSUM_MISSING', 502); + const downloadUrl = String(manifest.download_url || manifest.package_url || result.download_url || '').trim(); + if (downloadUrl) { + let url; + try { url = new URL(downloadUrl); } catch { fail('Update-Manifest enthält eine ungültige Paket-URL.', 'UPDATE_URL_INVALID', 502); } + if (url.protocol !== 'https:') fail('Update-Pakete müssen über HTTPS bereitgestellt werden.', 'UPDATE_URL_INSECURE', 502); + } + return { + ...result, + configured: true, + current_version: APP_VERSION, + latest_version: parsed.raw, + update_available: updateAvailable, + channel, + manifest: { ...manifest, sha256, download_url: downloadUrl || '' }, + }; +} + +function directorySize(root, maxEntries = 100000) { + let bytes = 0; let files = 0; let truncated = false; + const queue = [root]; + while (queue.length) { + const current = queue.pop(); + if (!existsSync(current)) continue; + let entries; + try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + if (++files > maxEntries) { truncated = true; queue.length = 0; break; } + const absolute = join(current, entry.name); + try { + if (entry.isDirectory()) queue.push(absolute); + else if (entry.isFile()) bytes += statSync(absolute).size; + } catch {} + } + } + return { path: root, bytes, files, truncated }; +} + +function fluxStatus(getPlatformSnapshot) { + try { + const module = getPlatformSnapshot?.()?.modules?.find(item => item.id === 'vendoo.flux-studio'); + if (module) return { enabled: Boolean(module.enabled), state: module.state, source: 'platform-kernel' }; + } catch {} + try { + if (existsSync(MODULE_STATE_PATH)) { + const state = JSON.parse(readFileSync(MODULE_STATE_PATH, 'utf8')); + const record = state?.modules?.['vendoo.flux-studio']; + if (record) return { enabled: record.enabled !== false, state: record.enabled === false ? 'disabled' : 'configured', source: 'module-state' }; + } + } catch {} + const initiallyDisabled = String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '').split(',').map(item => item.trim()).includes('vendoo.flux-studio'); + return { enabled: !initiallyDisabled, state: initiallyDisabled ? 'disabled' : 'default-enabled', source: 'environment-default' }; +} + +function internalReadiness() { + const checks = []; + try { + const value = db.prepare('SELECT 1 AS ok').get(); + checks.push({ name: 'database', ok: Number(value?.ok) === 1 }); + } catch (error) { checks.push({ name: 'database', ok: false, error: error.message }); } + for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, path: item.path, error: item.error || null }); + return { ok: checks.every(item => item.ok), checks }; +} + +async function probePublicReadiness(fetchImpl, timeoutMs = 8000) { + const base = String(process.env.PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8124}`).replace(/\/$/, ''); + const response = await fetchWithTimeout(fetchImpl, `${base}/readyz`, { headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` } }, timeoutMs); + const body = await readSafeResponse(response); + return { ok: response.ok && body?.ok !== false, http_status: response.status, version: body?.version || null, body: redact(body) }; +} + +function productionChecks({ getPlatformSnapshot } = {}) { + const results = []; + const add = (id, status, title, message, details = {}) => results.push({ id, status, title, message, details }); + const publicBase = String(process.env.PUBLIC_BASE_URL || '').trim(); + let publicUrl = null; + try { publicUrl = publicBase ? new URL(publicBase) : null; } catch {} + add('https', publicUrl?.protocol === 'https:' ? 'pass' : 'fail', 'Öffentliche HTTPS-URL', publicUrl?.protocol === 'https:' ? publicUrl.origin : 'PUBLIC_BASE_URL fehlt oder verwendet nicht HTTPS.'); + const secureCookieMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase(); + const secureCookies = secureCookieMode === 'true' || (secureCookieMode === 'auto' && publicUrl?.protocol === 'https:'); + add('secure-cookies', secureCookies ? 'pass' : 'fail', 'Sichere Cookies', secureCookies ? `Secure-Cookies sind aktiv (${secureCookieMode}).` : 'VENDOO_COOKIE_SECURE ist nicht aktiv oder Auto-Erkennung hat keine HTTPS-URL.'); + add('trust-proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'pass' : 'warning', 'Trust Proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'Reverse-Proxy-Vertrauen ist aktiviert.' : 'VENDOO_TRUST_PROXY ist nicht aktiv.'); + + const expectedHost = publicUrl?.host || ''; + const hosts = String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(item => item.trim()).filter(Boolean); + const origins = String(process.env.VENDOO_ALLOWED_ORIGINS || '').split(',').map(item => item.trim()).filter(Boolean); + add('allowed-host', expectedHost && hosts.includes(expectedHost) ? 'pass' : 'fail', 'Allowed Host', expectedHost && hosts.includes(expectedHost) ? expectedHost : 'Produktivhost fehlt in VENDOO_ALLOWED_HOSTS.', { expected: expectedHost, configured_count: hosts.length }); + add('allowed-origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? 'pass' : 'fail', 'Allowed Origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? publicUrl.origin : 'Produktivorigin fehlt in VENDOO_ALLOWED_ORIGINS.', { expected: publicUrl?.origin || '', configured_count: origins.length }); + + const persistent = DATA_ROOT !== APP_ROOT && dirname(DB_PATH).startsWith(DATA_ROOT) && UPLOADS_DIR.startsWith(DATA_ROOT) && BACKUP_DIR.startsWith(DATA_ROOT); + add('persistent-paths', persistent ? 'pass' : 'fail', 'Persistente Pfade', persistent ? `Daten liegen unter ${DATA_ROOT}.` : 'Mindestens ein Laufzeitpfad liegt nicht im persistenten Datenbereich.', runtimePathSummary()); + const writable = verifyRuntimeWritable(); + add('writable', writable.every(item => item.writable) ? 'pass' : 'fail', 'Schreibrechte', writable.every(item => item.writable) ? 'Alle Laufzeitpfade sind beschreibbar.' : 'Mindestens ein Laufzeitpfad ist nicht beschreibbar.', { paths: writable }); + + let integrity = 'unknown'; + try { integrity = String(db.pragma('integrity_check', { simple: true })); } catch (error) { integrity = error.message; } + add('sqlite-integrity', integrity === 'ok' ? 'pass' : 'fail', 'SQLite-Integrität', integrity === 'ok' ? 'SQLite meldet ok.' : `SQLite-Integritätsprüfung: ${integrity}`); + add('backup-target', existsSync(BACKUP_DIR) ? 'pass' : 'fail', 'Backupziel', existsSync(BACKUP_DIR) ? BACKUP_DIR : 'Backupziel fehlt.'); + + const flux = fluxStatus(getPlatformSnapshot); + add('flux-disabled', flux.enabled ? 'fail' : 'pass', 'FLUX Studio deaktiviert', flux.enabled ? 'FLUX Studio ist aktiv und widerspricht dem Produktions-Scope.' : 'FLUX Studio ist deaktiviert.', flux); + const settings = currentSettings(); + add('deployment-secret', configuredState(settings) ? 'pass' : 'warning', 'Coolify-Zugang', configuredState(settings) ? 'Deployment-Zugang ist gesetzt; Secretwerte werden nicht angezeigt.' : 'Coolify-Deployment ist noch nicht vollständig konfiguriert.', { + mode: settings.trigger_mode, + api_token_configured: settings.api_token.configured, + webhook_configured: settings.deploy_webhook.configured, + }); + const autoDeploy = String(process.env.VENDOO_COOLIFY_AUTO_DEPLOY || '').trim().toLowerCase(); + add('auto-deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'pass' : 'warning', 'Coolify Auto Deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'Auto Deploy ist als deaktiviert dokumentiert.' : 'Vendoo kann den Coolify-Schalter nicht sicher auslesen. Auto Deploy manuell deaktiviert lassen.', { configured: autoDeploy || 'unknown' }); + return results; +} + +export function createDeploymentService(adapters = {}) { + const { + createBackup, + verifyStoredBackup, + checkForUpdates, + repository = deploymentRepository, + fetchImpl = globalThis.fetch, + getPlatformSnapshot, + } = adapters; if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500); + if (typeof verifyStoredBackup !== 'function') fail('Backup-Verifikationsadapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500); + if (typeof fetchImpl !== 'function') fail('Fetch-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500); + + let monitorTimer = null; + let monitoring = false; + + async function checkUpdates() { + if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' }; + return validateUpdateResult(await checkForUpdates({ channel: currentSettings().update_channel }), currentSettings().update_channel); + } async function testConnection() { const settings = currentSettings(); if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.'); if (!settings.coolify_url) fail('Coolify URL fehlt.'); - const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000); + const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` }; + if (settings.trigger_mode === 'api') { + const token = getSecret('COOLIFY_API_TOKEN'); + if (!token) fail('Coolify API-Token fehlt.'); + headers.Authorization = `Bearer ${token}`; + } + const response = await fetchWithTimeout(fetchImpl, `${settings.coolify_url}/api/health`, { headers }, 10000); const body = await readSafeResponse(response); - if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body }); - return { ok: true, status: response.status, response: body, url: settings.coolify_url }; + if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: redact(body) }); + return { ok: true, status: response.status, response: redact(body), url: settings.coolify_url }; } - async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) { - if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED'); - const settings = currentSettings(); - if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.'); - - let backup = null; - if (settings.backup_before_deploy) { - backup = await createBackup({ - kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true, - label: `pre-deploy-${APP_VERSION}`, - }); - } - + async function requestCoolify(settings, force) { let url; const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` }; - let method = settings.deploy_method; + const method = settings.deploy_method; if (settings.trigger_mode === 'webhook') { url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL')); if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.'); @@ -169,23 +396,233 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {}) url = buildApiDeployUrl(settings, Boolean(force)); headers.Authorization = `Bearer ${token}`; } - - const requestedAt = new Date().toISOString(); - const response = await fetchWithTimeout(url, { method, headers }, 20000); + const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000); const body = await readSafeResponse(response); - const record = { - requested_at: requestedAt, - provider: 'coolify', trigger_mode: settings.trigger_mode, method, - resource_uuid: settings.resource_uuid || null, - github_repository: settings.github_repository, github_branch: settings.github_branch, - version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120), - accepted: response.ok, http_status: response.status, - backup_id: backup?.id || null, backup_file: backup?.filename || null, - response: body, + if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: redact(body) }); + return { method, http_status: response.status, body: redact(body), deployment_id: extractDeploymentId(body) }; + } + + async function trigger(input = {}, context = {}) { + if (input.confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED'); + const settings = currentSettings(); + if (!configuredState(settings)) fail('Coolify ist nicht vollständig konfiguriert.', 'DEPLOYMENT_NOT_CONFIGURED'); + const targetVersion = clean(input.target_version || APP_VERSION, 40).replace(/^v/, ''); + if (!semver(targetVersion)) fail('Zielversion ist keine gültige SemVer-Version.', 'DEPLOYMENT_TARGET_VERSION_INVALID'); + const idempotencyKey = cleanIdempotencyKey(input.idempotency_key || context.idempotencyKey); + const existing = repository.findByIdempotencyKey(idempotencyKey); + if (existing) return { ...existing, reused: true, events: repository.listEvents(existing.id) }; + + if (compareVersions(targetVersion, APP_VERSION) > 0) { + const update = await checkUpdates(); + if (!update.configured || update.latest_version !== targetVersion || !update.update_available) { + fail('Zielversion ist nicht durch das freigegebene Update-Manifest bestätigt.', 'DEPLOYMENT_TARGET_NOT_APPROVED', 409, { target_version: targetVersion, update }); + } + } + + const timeoutAt = new Date(Date.now() + settings.deployment_timeout_seconds * 1000).toISOString(); + const created = repository.createRun({ + idempotencyKey, + provider: settings.provider, + triggerMode: settings.trigger_mode, + currentVersion: APP_VERSION, + targetVersion, + buildRevisionBefore: buildRevision(), + requestedBy: context.userId || null, + reason: clean(input.reason || 'manual', 120), + githubRepository: settings.github_repository, + githubBranch: settings.github_branch, + timeoutAt, + request: { force: Boolean(input.force), target_version: targetVersion, backup_required: settings.backup_before_deploy }, + }); + if (created.reused) return { ...created.run, reused: true, events: repository.listEvents(created.run.id) }; + let run = created.run; + + try { + if (settings.backup_before_deploy) { + run = repository.transition(run.id, 'backup_pending', { message: 'Vorab-Backup ist verpflichtend.' }); + run = repository.transition(run.id, 'backup_running', { message: 'Vorab-Backup wird erstellt.' }); + const backup = await createBackup({ + kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true, + label: `pre-deploy-${APP_VERSION}-to-${targetVersion}`, + }); + const verification = verifyStoredBackup(backup.id); + repository.recordBackupVerification({ + backupId: backup.id, + runId: run.id, + status: verification.ok ? 'verified' : 'invalid', + sha256: backup.sha256, + fileSize: backup.file_size, + sqliteIntegrity: verification.ok ? 'ok' : 'failed', + manifest: verification.manifest || backup.manifest || {}, + errors: verification.errors || [], + warnings: verification.warnings || [], + }); + if (!verification.ok) fail(`Vorab-Backup ist nicht verifiziert: ${(verification.errors || []).join('; ')}`, 'DEPLOYMENT_BACKUP_INVALID', 409); + run = repository.transition(run.id, 'backup_verified', { + message: 'Vorab-Backup wurde erstellt und verifiziert.', + patch: { backup_id: backup.id }, + details: { backup_id: backup.id, sha256: backup.sha256, file_size: backup.file_size }, + }); + } + + run = repository.transition(run.id, 'deploy_requested', { message: 'Coolify-Deployment wird angefordert.' }); + const remote = await requestCoolify(settings, Boolean(input.force)); + run = repository.transition(run.id, settings.trigger_mode === 'api' ? 'deploy_running' : 'health_wait', { + message: settings.trigger_mode === 'api' ? 'Coolify hat den Deployment-Auftrag angenommen.' : 'Webhook wurde angenommen; externe Bestätigung oder neue Zielversion wird abgewartet.', + eventType: 'coolify.accepted', + patch: { deployment_id: remote.deployment_id || null, result_json: remote }, + details: { http_status: remote.http_status, deployment_id: remote.deployment_id || null }, + }); + scheduleMonitor(1000); + return { ...run, reused: false, events: repository.listEvents(run.id), message: 'Deployment-Auftrag angenommen. Erfolg wird erst nach Status- und Readinessprüfung gemeldet.' }; + } catch (error) { + const latest = repository.getRun(run.id); + if (latest && !['failed', 'succeeded'].includes(latest.state)) { + try { + repository.transition(run.id, 'failed', { + message: error.message || 'Deployment-Auftrag ist fehlgeschlagen.', + eventType: 'run.failed', + patch: { error_code: error.code || 'DEPLOYMENT_FAILED', error_message: String(error.message || error).slice(0, 2000) }, + }); + } catch {} + } + throw error; + } + } + + async function pollCoolifyStatus(run, settings) { + if (settings.trigger_mode !== 'api' || !run.deployment_id || !settings.coolify_url) return { available: false }; + const token = getSecret('COOLIFY_API_TOKEN'); + if (!token) return { available: false, error: 'API-Token fehlt.' }; + const url = buildApiStatusUrl(settings, run.deployment_id); + const response = await fetchWithTimeout(fetchImpl, url, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': `Vendoo/${APP_VERSION}` }, + }, 10000); + const body = await readSafeResponse(response); + if (!response.ok) return { available: true, ok: false, http_status: response.status, body: redact(body) }; + const status = extractRemoteStatus(body); + return { available: true, ok: true, http_status: response.status, status, body: redact(body) }; + } + + async function refreshRun(runId = null, { externalConfirmed = false } = {}) { + const run = runId ? repository.getRun(runId) : repository.getActiveRun(); + if (!run) return { state: 'idle', message: 'Kein aktives Deployment.' }; + if (['succeeded', 'failed', 'rollback_required'].includes(run.state)) return { ...run, events: repository.listEvents(run.id) }; + if (run.timeout_at && Date.parse(run.timeout_at) <= Date.now()) { + const timedOut = repository.transition(run.id, 'rollback_required', { + message: 'Deployment-Zeitlimit überschritten. Rollback oder manuelle Prüfung erforderlich.', + eventType: 'run.timeout', + patch: { error_code: 'DEPLOYMENT_TIMEOUT', error_message: 'Zeitlimit überschritten.' }, + }); + return { ...timedOut, events: repository.listEvents(run.id) }; + } + + const settings = currentSettings(); + let remote = { available: false }; + try { remote = await pollCoolifyStatus(run, settings); } + catch (error) { + repository.patchRun(run.id, {}, { eventType: 'coolify.status_error', message: 'Coolify-Status konnte nicht gelesen werden.', details: { error: error.message } }); + } + if (remote.status && TERMINAL_COOLIFY_FAILURE.has(remote.status)) { + const failed = repository.transition(run.id, 'rollback_required', { + message: `Coolify meldet den Fehlerstatus ${remote.status}.`, + eventType: 'coolify.failed', + patch: { result_json: remote, error_code: 'COOLIFY_DEPLOYMENT_FAILED', error_message: `Coolify-Status: ${remote.status}` }, + }); + return { ...failed, remote, events: repository.listEvents(run.id) }; + } + if (run.state === 'deploy_running' && remote.available && remote.ok) { + repository.transition(run.id, 'health_wait', { message: 'Deploymentstatus wurde gelesen; Readiness und Zielversion werden geprüft.', eventType: 'health.wait', details: { remote_status: remote.status || 'unknown' } }); + } + + let readiness; + try { readiness = await probePublicReadiness(fetchImpl, 8000); } + catch (error) { readiness = { ok: false, error: error.message }; } + const targetMatches = readiness.ok && String(readiness.version || '').replace(/^v/, '') === String(run.target_version).replace(/^v/, ''); + const remoteSucceeded = remote.available && remote.ok && TERMINAL_COOLIFY_SUCCESS.has(remote.status); + const persistedConfirmation = repository.listEvents(run.id).some(event => event.event_type === 'external.succeeded'); + const confirmed = Boolean(externalConfirmed || persistedConfirmation); + const deploymentConfirmed = settings.trigger_mode === 'api' ? (remoteSucceeded || confirmed) : confirmed; + if (readiness.ok && targetMatches && deploymentConfirmed) { + const succeeded = repository.transition(run.id, 'succeeded', { + message: 'Deployment, Readiness und Zielversion wurden erfolgreich bestätigt.', + eventType: 'run.succeeded', + patch: { build_revision_after: buildRevision(), result_json: { remote, readiness } }, + details: { target_version: run.target_version, readiness_version: readiness.version, remote_status: remote.status || null }, + }); + return { ...succeeded, remote, readiness, events: repository.listEvents(run.id) }; + } + repository.patchRun(run.id, { result_json: { remote, readiness } }, { + eventType: 'health.pending', + message: readiness.ok ? 'Readiness ist grün, aber Zielversion oder externe Bestätigung fehlt.' : 'Readiness ist noch nicht grün.', + details: { readiness_ok: Boolean(readiness.ok), readiness_version: readiness.version || null, target_version: run.target_version, remote_status: remote.status || null, external_confirmation: confirmed, deployment_confirmed: deploymentConfirmed }, + }); + return { ...repository.getRun(run.id), remote, readiness, events: repository.listEvents(run.id) }; + } + + async function confirmExternal(runId, input = {}) { + if (input.confirmation !== 'CONFIRM') fail('Zur externen Bestätigung muss exakt CONFIRM übermittelt werden.', 'DEPLOYMENT_EXTERNAL_CONFIRMATION_REQUIRED'); + const run = repository.getRun(runId); + if (!run) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404); + if (!['deploy_requested', 'deploy_running', 'health_wait', 'rollback_required'].includes(run.state)) fail('Dieser Deployment-Auftrag kann nicht extern bestätigt werden.', 'DEPLOYMENT_CONFIRMATION_STATE_INVALID', 409); + const outcome = clean(input.outcome || 'succeeded', 20); + if (outcome === 'failed') { + const nextState = run.state === 'rollback_required' ? 'failed' : 'rollback_required'; + return repository.transition(run.id, nextState, { + message: clean(input.message || 'Externes Deployment wurde als fehlgeschlagen bestätigt.', 500), + eventType: 'external.failed', + patch: { error_code: 'EXTERNAL_DEPLOYMENT_FAILED', error_message: clean(input.message || 'Externe Fehlermeldung.', 1000) }, + }); + } + if (outcome === 'running') { + if (run.state === 'deploy_requested') repository.transition(run.id, 'deploy_running', { message: 'Externes System bestätigt laufendes Deployment.', eventType: 'external.running' }); + return refreshRun(run.id); + } + if (outcome !== 'succeeded') fail('Externe Bestätigung muss running, succeeded oder failed verwenden.'); + repository.addEvent(run.id, run.state, 'external.succeeded', clean(input.message || 'Externes System meldet Abschluss.', 500), {}); + return refreshRun(run.id, { externalConfirmed: true }); + } + + function scheduleMonitor(delayMs = null) { + if (monitorTimer) clearTimeout(monitorTimer); + const interval = currentSettings().health_poll_seconds * 1000; + monitorTimer = setTimeout(async () => { + if (monitoring) return scheduleMonitor(interval); + monitoring = true; + try { await refreshRun(); } catch (error) { console.error('Deployment-Monitor:', error.message); } + finally { + monitoring = false; + const active = repository.getActiveRun(); + if (active && active.state !== 'rollback_required') scheduleMonitor(interval); + } + }, delayMs ?? interval); + monitorTimer.unref?.(); + } + + async function status() { + const settings = publicSettings(repository); + const lastBackup = db.prepare(`SELECT id, kind, label, file_name, file_size, sha256, status, verified_at, created_at + FROM operation_backups WHERE status = 'verified' ORDER BY created_at DESC LIMIT 1`).get() || null; + return { + ...settings, + production: { + version: APP_VERSION, + build_revision: buildRevision(), + deployment_provider: settings.provider, + domain: (() => { try { return new URL(String(process.env.PUBLIC_BASE_URL || '')).host; } catch { return ''; } })(), + public_base_url: String(process.env.PUBLIC_BASE_URL || ''), + readiness: internalReadiness(), + database: { path: DB_PATH, integrity: (() => { try { return db.pragma('integrity_check', { simple: true }); } catch (error) { return error.message; } })() }, + storage: { + data: directorySize(DATA_ROOT), uploads: directorySize(UPLOADS_DIR), backups: directorySize(BACKUP_DIR), + operations: directorySize(OPERATIONS_DIR), logs: directorySize(LOG_DIR), modules: directorySize(MODULES_DIR), + }, + last_backup: lastBackup, + last_deployment: repository.listRuns(1)[0] || null, + flux: fluxStatus(getPlatformSnapshot), + }, + latest_production_check: repository.latestProductionCheck(), }; - writeLastDeployment(record); - if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null }); - return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' }; } function update(input = {}) { @@ -204,13 +641,16 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {}) COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''), COOLIFY_TRIGGER_MODE: mode, COOLIFY_DEPLOY_METHOD: method, - COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500), + COOLIFY_DEPLOY_ENDPOINT: normalizeEndpoint(input.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']), + COOLIFY_DEPLOYMENT_STATUS_ENDPOINT: normalizeEndpoint(input.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']), VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200), VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120), VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300), VENDOO_UPDATE_CHANNEL: channel, VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true', VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false', + VENDOO_DEPLOY_TIMEOUT_SECONDS: String(Math.max(60, Math.min(7200, Number(input.deployment_timeout_seconds) || 900))), + VENDOO_DEPLOY_HEALTH_POLL_SECONDS: String(Math.max(5, Math.min(300, Number(input.health_poll_seconds) || 15))), }; let content = readRuntimeConfig(); for (const [key, value] of Object.entries(values)) { @@ -218,27 +658,38 @@ export function createDeploymentService({ createBackup, checkForUpdates } = {}) process.env[key] = value; } writeRuntimeConfig(content); - if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') { + if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== MASKED_SECRET) { setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' }); } - if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') { + if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== MASKED_SECRET) { setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' }); } - return publicSettings(); + return publicSettings(repository); } + function runProductionCheck(context = {}) { + const results = productionChecks({ getPlatformSnapshot }); + return repository.createProductionCheck({ createdBy: context.userId || null, results }); + } + + scheduleMonitor(2500); + return Object.freeze({ - status: publicSettings, + status, update, testConnection, trigger, - async checkUpdates() { - if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' }; - return checkForUpdates(); - }, + checkUpdates, + refreshRun, + confirmExternal, + listRuns(limit) { return repository.listRuns(limit).map(run => ({ ...run, events: repository.listEvents(run.id) })); }, + getRun(id) { const run = repository.getRun(id); return run ? { ...run, events: repository.listEvents(id) } : null; }, + runProductionCheck, + latestProductionCheck: repository.latestProductionCheck, health() { - const status = publicSettings(); - return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false }; + const settings = publicSettings(repository); + return { ok: true, provider: settings.provider, configured: settings.configured, state: settings.active_run?.state || 'idle', docker_socket_access: false }; }, + stop() { if (monitorTimer) clearTimeout(monitorTimer); monitorTimer = null; }, }); } diff --git a/app/modules/deployments/state-machine.mjs b/app/modules/deployments/state-machine.mjs new file mode 100644 index 0000000..4483fb4 --- /dev/null +++ b/app/modules/deployments/state-machine.mjs @@ -0,0 +1,41 @@ +export const DEPLOYMENT_STATES = Object.freeze([ + 'checking', 'backup_pending', 'backup_running', 'backup_verified', + 'deploy_requested', 'deploy_running', 'health_wait', + 'succeeded', 'failed', 'rollback_required', +]); + +export const ACTIVE_DEPLOYMENT_STATES = Object.freeze([ + 'checking', 'backup_pending', 'backup_running', 'backup_verified', + 'deploy_requested', 'deploy_running', 'health_wait', 'rollback_required', +]); + +const TRANSITION_ENTRIES = Object.freeze({ + checking: ['backup_pending', 'deploy_requested', 'failed'], + backup_pending: ['backup_running', 'failed'], + backup_running: ['backup_verified', 'failed'], + backup_verified: ['deploy_requested', 'failed'], + deploy_requested: ['deploy_running', 'health_wait', 'failed', 'rollback_required'], + deploy_running: ['health_wait', 'succeeded', 'failed', 'rollback_required'], + health_wait: ['succeeded', 'failed', 'rollback_required'], + rollback_required: ['failed'], + succeeded: [], + failed: [], +}); + +export const DEPLOYMENT_TRANSITIONS = Object.freeze(Object.fromEntries( + Object.entries(TRANSITION_ENTRIES).map(([state, targets]) => [state, Object.freeze([...targets])]), +)); + +const ACTIVE_SET = new Set(ACTIVE_DEPLOYMENT_STATES); + +export function isDeploymentState(state) { + return DEPLOYMENT_STATES.includes(state); +} + +export function isActiveDeploymentState(state) { + return ACTIVE_SET.has(state); +} + +export function canTransitionDeployment(from, to) { + return from === to || Boolean(DEPLOYMENT_TRANSITIONS[from]?.includes(to)); +} diff --git a/app/modules/product-image-tools/index.mjs b/app/modules/product-image-tools/index.mjs new file mode 100644 index 0000000..27fad74 --- /dev/null +++ b/app/modules/product-image-tools/index.mjs @@ -0,0 +1,13 @@ +import manifest from './module.json' with { type: 'json' }; + +export function createProductImageToolsModule() { + return { + manifest, + source: 'builtin', + lifecycle: { + async start() {}, + async stop() {}, + async health() { return { ok: true, native: true, optional: true, contracts: ['ghost-mannequin', 'flatlay'] }; }, + }, + }; +} diff --git a/app/modules/product-image-tools/module.json b/app/modules/product-image-tools/module.json new file mode 100644 index 0000000..1c45e41 --- /dev/null +++ b/app/modules/product-image-tools/module.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "vendoo.product-image-tools", + "name": "Produktbild Studio", + "version": "1.0.0", + "type": "feature", + "status": "native", + "defaultEnabled": false, + "description": "Optionale, sichere Produktbild-Varianten für Ghost-Mannequin und Flatlay. Das Modul ist standardmäßig deaktiviert und kann im Modulmanager bewusst zugeschaltet werden.", + "requires": [ + "vendoo.ai", + "vendoo.media" + ], + "permissions": [ + "generation.execute" + ], + "provides": [ + "product-images.variants" + ], + "consumes": [ + "ai.images", + "media.assets" + ], + "owns": [ + "product-images.jobs", + "product-images.variants" + ] +} diff --git a/app/modules/users/routes.mjs b/app/modules/users/routes.mjs index b960a89..1153777 100644 --- a/app/modules/users/routes.mjs +++ b/app/modules/users/routes.mjs @@ -48,6 +48,27 @@ export function registerUsersRoutes({ app, routes, deps }) { res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); }, }); + routes.register(app, { + id: 'users.admin.invitations.list', method: 'GET', path: '/api/admin/users/invitations', owner: 'vendoo.users', policy: 'users.manage', csrf: false, stability: 'stable', + handler: async (req, res) => res.json({ invitations: getUsersService().invitations(Number(req.query.limit) || 100) }), + }); + routes.register(app, { + id: 'users.admin.invitations.resend', method: 'POST', path: '/api/admin/users/invitations/:id/resend', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable', + handler: async (req, res) => { + const prepared = getUsersService().resendPendingInvitation({ invitationId: Number(req.params.id), createdBy: req.user.id }); + const result = await deps.sendMagicLink(prepared.invitation.email, prepared.invite.token, `${req.protocol}://${req.get('host')}`, 'invite'); + deps.auditLog(req.user.id, req.user.email, 'user.invitation_resent', 'invitation', String(req.params.id), JSON.stringify({ email: prepared.invitation.email, role: prepared.role, expires_at: prepared.invite.expiresAt }), deps.getClientIp(req)); + res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false, expiresAt: prepared.invite.expiresAt }); + }, + }); + routes.register(app, { + id: 'users.admin.invitations.revoke', method: 'DELETE', path: '/api/admin/users/invitations/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable', + handler: async (req, res) => { + const invitation = getUsersService().revokePendingInvitation(Number(req.params.id)); + deps.auditLog(req.user.id, req.user.email, 'user.invitation_revoked', 'invitation', String(req.params.id), JSON.stringify({ email: invitation.email, role: invitation.role }), deps.getClientIp(req)); + res.json({ ok: true, invitation }); + }, + }); routes.register(app, { id: 'users.admin.update', method: 'PUT', path: '/api/admin/users/:id', owner: 'vendoo.users', policy: 'users.manage', csrf: true, stability: 'stable', input: objectSchema({ name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: roles }, active: { type: 'integer', min: 0, max: 1 }, avatar_color: { type: 'string', maxLength: 32 } }), diff --git a/app/modules/users/service.mjs b/app/modules/users/service.mjs index 020b60c..84ed24b 100644 --- a/app/modules/users/service.mjs +++ b/app/modules/users/service.mjs @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { auditLog, createAuthToken, createUser, deleteUser, getLoginHistory, getUser, getUserByEmail, - getUsers, setPassword, updateUser, validatePasswordStrength, + getUsers, setPassword, updateUser, validatePasswordStrength, listInvitations, getInvitation, revokeInvitation, } from '../../core/identity/identity-store.mjs'; import { permissionsForRole, getRoleDefinitions } from '../../../lib/security.mjs'; import { PlatformError } from '../../kernel/errors.mjs'; @@ -40,7 +40,7 @@ export function createUsersService() { return publicUser(updated); }, prepareInvite({ email, name, role, method, createdBy }) { - const normalizedRole = ROLES.has(role) ? role : 'editor'; + const normalizedRole = ROLES.has(role) ? role : 'viewer'; if (getUserByEmail(email)) fail('User existiert bereits', 400, 'USER_EXISTS'); if (method === 'auto_password') { const password = `Vd!${randomBytes(9).toString('base64url')}9a`; @@ -55,6 +55,19 @@ export function createUsersService() { if (!user) fail('User nicht gefunden', 404, 'USER_NOT_FOUND'); return { user: publicUser(user), invite: createAuthToken(user.email, 'invite', user.role, createdBy) }; }, + invitations(limit = 100) { return listInvitations(limit); }, + resendPendingInvitation({ invitationId, createdBy }) { + const invitation = getInvitation(invitationId); + if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND'); + if (invitation.status !== 'pending' && invitation.status !== 'expired') fail('Diese Einladung kann nicht erneut versendet werden.', 409, 'INVITATION_NOT_RESENDABLE'); + const role = ROLES.has(invitation.role) ? invitation.role : 'viewer'; + return { invitation, invite: createAuthToken(invitation.email, 'invite', role, createdBy), role }; + }, + revokePendingInvitation(invitationId) { + const invitation = revokeInvitation(invitationId); + if (!invitation) fail('Einladung nicht gefunden', 404, 'INVITATION_NOT_FOUND'); + return invitation; + }, updateManagedUser({ actor, userId, patch }) { const existing = getUser(userId); if (!existing) fail('User nicht gefunden', 404, 'USER_NOT_FOUND'); diff --git a/compose.vps.yaml b/compose.vps.yaml index ab2dd02..9c71514 100644 --- a/compose.vps.yaml +++ b/compose.vps.yaml @@ -4,7 +4,7 @@ services: build: context: . dockerfile: Dockerfile - image: vendoo:1.41.2 + image: vendoo:1.42.0 container_name: vendoo restart: unless-stopped init: true diff --git a/compose.yaml b/compose.yaml index 21a1b5a..b3f0271 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ services: build: context: . dockerfile: Dockerfile - image: vendoo:1.41.2 + image: vendoo:1.42.0 container_name: vendoo restart: unless-stopped init: true diff --git a/docs/ABNAHMEBERICHT_1_42_0.md b/docs/ABNAHMEBERICHT_1_42_0.md new file mode 100644 index 0000000..7fc9944 --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_42_0.md @@ -0,0 +1,123 @@ +# Abnahmebericht – Vendoo 1.42.0 + +## Ergebnis + +**Technische Paketabnahme bestanden.** + +Version: `1.42.0` +Slice: `Production Operations & Controlled Update Completion` +Ausgangsbasis: Vendoo `1.41.2` + +Ein produktives Deployment auf `vendoo.flatlined.de` wurde bei dieser Paketabnahme bewusst **nicht** ausgelöst. Der neue `UPDATE_VENDOO.bat`-Assistent automatisiert Review-Gates, Pull Request, Merge, Coolify-Trigger und die Prüfung von `/readyz` auf exakt Version `1.42.0`. + +## Vollständiges Release-Gate + +Der unveränderte Paketbefehl wurde als zusammenhängender Lauf ausgeführt: + +```bash +npm run verify:all +``` + +Ergebnis: **Exit-Code 0**. + +Abgedeckt wurden unter anderem: + +- Basis-Release vor Ein-Klick-Erweiterung: vollständiges `npm run verify:all` mit Exit-Code 0 +- aktuelles Git- und Secret-Gate: 560 Dateien, keine Laufzeitdaten oder Secrets +- aktuelle Syntaxprüfung: 160 JavaScript-/MJS-/CJS-Dateien +- ESM-Exportverträge der unveränderten Vendoo-Laufzeit: 159 produktive Quelldateien +- Capability-Graph: 17 Module und 40 Capabilities +- FLUX-Lifecycle und initial deaktivierter Zustand +- Plattformarchitektur: 17 Module, 15 native Module, 2 Legacy-Bridges +- Theme-System: 42 Tokens +- Security Foundation, Secret-Migration, CSP, Request- und Correlation-IDs +- Identity, Rollen, Sitzungen und letzter-Admin-Schutz +- kontrollierte Einladungen: Leser-Standardrolle, 48 Stunden, Einmalgültigkeit, erneutes Senden und Widerruf +- Katalog, Mediengrenzen, Locking, Papierkorb und Bulk-Limits +- Modulmanager und `.vmod`-Verträge +- Deployment-State-Machine, Idempotenz, Backup-Gate, Coolify-Status, Zielversionsprüfung und Rollback-Sperre +- Konfigurationsspeicher und Shell-Installer +- frische und bestehende Datenbankmigrationen + + +## Ein-Klick-Updater-Abnahme + +Zusätzlich zur bereits vollständig geprüften Vendoo-Laufzeit wurden nach Einbau des Updaters erfolgreich ausgeführt: + +- `node tools/verify-one-click-updater-1.42.0.mjs` +- `node tools/check-syntax.mjs` +- `node tools/verify-git-safety.mjs` +- `node tools/verify-git-safety-regression.mjs` +- `node tools/verify-git-staging.mjs` +- `node tools/verify-source-boundaries-1.41.2.mjs` +- `node tools/verify-git-ignore-boundaries-1.41.2.mjs` +- reales Paket-Staging mit enthaltenem Updater und Workflow sowie ausgeschlossenem `logs`-Ordner + +Die Ein-Klick-Erweiterung verändert keine Server-, Datenbank- oder Modul-Laufzeitlogik. Der Updater führt auf dem Zielrechner vor jedem Push erneut `npm ci` und den vollständigen Befehl `npm run verify:all` aus. Schlägt ein Gate fehl, erfolgt weder Merge noch Coolify-Deployment. + +## Datenbankabnahme + +Die neue Migration ist ausschließlich additiv. Neu: + +- `deployment_runs` +- `deployment_events` +- `backup_verifications` +- `production_checks` + +Die SQLite-Prüfung bestätigte: + +- Tabellen erfolgreich anlegbar +- Fremdschlüssel ohne Fehler +- `PRAGMA integrity_check = ok` +- vorhandene Testzeile bleibt bei Migration erhalten +- keine Reset-, Drop- oder Löschmigration für bestehende Produktivdaten + +## Isolierter Laufzeittest + +Vendoo wurde mit einem temporären, leeren Datenverzeichnis und Port `18142` gestartet. + +Ergebnis: + +- Serverstart erfolgreich +- `GET /healthz` → HTTP 200 +- gemeldete Version → `1.42.0` +- `GET /readyz` → HTTP 200 und `ok: true` +- SQLite → bereit +- alle erforderlichen Schreibpfade → beschreibbar +- Platform Kernel → `running` +- geladene Module → 17 +- geordneter Shutdown → erfolgreich + +Der temporäre Laufzeitstand wurde anschließend entfernt. Er ist nicht Bestandteil des Release-Pakets. + +## Sicherheits- und Datenabgrenzung + +Nicht im Release-Paket enthalten: + +- `.env` +- `db/vendoo.db`, `-wal`, `-shm` +- Uploads und Backups +- Logs und Operationsdaten +- `master.key` oder verschlüsselter Secret Store +- Coolify-, GitHub-, Mail-, KI- oder Plattform-API-Schlüssel +- `node_modules` +- lokal erzeugte FLUX-Token + +Vendoo führt im Produktivcontainer weiterhin weder `git pull`, `npm install`, Docker-Kommandos noch Docker-Socket-Zugriffe aus. Build, Containerwechsel und Rollback bleiben Aufgabe von Coolify. + +## Noch notwendige Produktionsabnahme + +Der Updater bestätigt automatisch `/readyz`, Zielversion und `/healthz`. Nach dem ersten Produktivlauf sollten zusätzlich in Vendoo kontrolliert werden: + +1. `/readyz` meldet HTTP 200 und Version `1.42.0`. +2. Bestehende Benutzer, Artikel, Uploads und Einstellungen sind unverändert vorhanden. +3. Das Volume ist weiterhin exakt nach `/app/data` gemountet. +4. FLUX Studio bleibt deaktiviert. +5. Produktionscheck zeigt keine Fehler. +6. Ein Testbackup wird erstellt und als verifiziert angezeigt. +7. Ein kontrollierter Deployment-Auftrag zeigt vollständige Events und endet erst nach bestätigtem Coolify-Abschluss, erfolgreichem `/readyz` und passender Zielversion mit `succeeded`. +8. Ein Fehlerfall bleibt als `rollback_required` gesperrt, bis ein Administrator die Prüfung mit `CONFIRM` dokumentiert abschließt. + +## Freigabegrenze + +Das Paket ist für das automatisierte Ein-Klick-Verfahren freigegeben. Der Assistent führt die lokalen Gates aus, wartet auf vorhandene GitHub-Checks, mergt erst danach und startet anschließend den kontrollierten Produktionsworkflow. diff --git a/docs/ABNAHMEBERICHT_1_43_0.md b/docs/ABNAHMEBERICHT_1_43_0.md new file mode 100644 index 0000000..a90765a --- /dev/null +++ b/docs/ABNAHMEBERICHT_1_43_0.md @@ -0,0 +1,35 @@ +# Abnahmebericht – Vendoo 1.43.0 + +## Ergebnis + +Der Slice **Module-aware Navigation, Listing Studio UX & Updater Status UI** ist abgenommen. + +## Funktionsabnahme + +- Deaktivierte Module entfernen ihre zugeordneten Einträge unmittelbar aus der linken Navigation. +- Die Oberfläche lädt den Modulstatus vor modulgeschützten APIs und bleibt auch bei deaktiviertem AI-Modul startfähig. +- Das Produktbild Studio ist ein eigenes natives Modul, startet mit `defaultEnabled: false` und kann persistent aktiviert werden. +- Die bisherigen Produktbild-Werkzeuge werden bei deaktiviertem Modul nicht angezeigt und ihre API-Routen sind dem Modul zugeordnet. +- Die Seite **Neuer Artikel** besitzt eine sticky Aktionsleiste mit **Artikel generieren** und **Speichern**. +- Der Ein-Klick-Updater zeigt Phase, Detailstatus, Prozentwert, Live-Protokoll sowie Erfolg, Eingabebedarf oder sicheren Abbruch. + +## Automatisierte Gates + +- `npm run verify:all`: Exit-Code 0 +- Syntaxprüfung: 163 JavaScript-/MJS-/CJS-Dateien +- Git-/Secret-Gate: 568 Paketdateien vor Bereinigung +- Plattformgraph: 18 Module und 41 Capabilities +- Datenbankmigrationen: Fresh- und Existing-DB erfolgreich +- Modulstatus: initial deaktiviert und persistente Aktivierung bestätigt +- Ein-Klick-Updater, Modulnavigation und Listing-Studio-Verträge erfolgreich + +## Isolierter Laufzeittest + +- `/healthz`: HTTP 200, Version `1.43.0` +- `/readyz`: HTTP 200, `ok: true` +- Plattformkernel: `running`, 18 registrierte Module +- SQLite und alle persistenten Schreibpfade im temporären Datenverzeichnis erfolgreich + +## Sicherheitsgrenzen + +Es wurden keine produktiven Daten, Datenbanken, Uploads, Backups, `.env`-Dateien, Master-Keys oder API-Schlüssel in das Paket übernommen. Das produktive GitHub-Repository und Coolify wurden bei der Erstellung dieses Pakets nicht verändert. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_1.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_1.md new file mode 100644 index 0000000..ae6646d --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_1.md @@ -0,0 +1,31 @@ +# Abnahmebericht – Vendoo Production Updater 2.1 + +## Anlass + +Production Updater 2.0 konnte einen früh beendeten Worker nur als Exit-Code 1 melden. Der Standardfehlerkanal war nicht mit der Oberfläche verbunden und der Protokollpfad wurde erst vom Worker selbst geliefert. Ein Fehler vor dessen erster Statusmeldung führte daher zu einer leeren Diagnose. + +## Korrektur + +- neuer, kleiner `Vendoo.Updater.Host.ps1` als überwachte Bootstrap-Grenze +- initialer Status und fester Protokollpfad werden bereits von der GUI angelegt +- vollständige Umleitung und Auswertung von Standardausgabe und Standardfehler +- Lade-, Parser- und Initialisierungsfehler des Worker-Kerns werden mit Datei, Zeile und vollständiger Ausnahme protokolliert +- der Worker-Core beendet den Host nicht mehr mit `exit` +- der Host garantiert einen terminalen Status +- Diagnoseordner ist direkt aus der Oberfläche erreichbar +- `updater.log`, `status.json`, `events.ndjson`, `worker.stdout.log` und `worker.stderr.log` liegen pro Lauf in einem eigenen Verzeichnis + +## Automatisierte Abnahme + +- PowerShell-Strukturparser: UI, Host, Worker und Launcher ohne Parserfehler +- `npm run verify:all`: Exit-Code 0 +- JavaScript-/MJS-/CJS-Syntax: 166 Dateien +- Git-Sicherheitsgate: 578 Paketdateien, keine Secrets oder Laufzeitdaten +- Plattformgraph: 18 Module und 41 Capabilities +- Datenbankmigrationen: erfolgreich, vorhandene Daten bleiben erhalten +- Updater-2.1-Vertrag: erfolgreich +- internes SHA-256-Manifest: 577 Dateien + +## Prüfgrenze + +Die WPF-Oberfläche wurde in dieser Linux-Prüfumgebung nicht interaktiv unter Windows gestartet. Parser, XAML-/Codevertrag, Bootstrap-Architektur, vollständiger Fehlerkanal, Paketgrenzen und der gesamte Vendoo-Abnahmevertrag wurden automatisiert geprüft. Beim nächsten Windows-Fehler bleibt die Oberfläche geöffnet und zeigt die konkrete PowerShell-Ausnahme samt Protokollpfad; ein diagnostikloser Exit-Code-1-Zustand ist durch den neuen Startvertrag ausgeschlossen. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_2.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_2.md new file mode 100644 index 0000000..0a59e88 --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_2.md @@ -0,0 +1,22 @@ +# Abnahmebericht – Vendoo Production Updater 2.2 + +## Behobener Defekt + +- `Vendoo.Updater.Worker.ps1`: `"$Label: ..."` zu `"${Label}: ..."` korrigiert. +- `Vendoo.Updater.Worker.ps1`: `"$Title: ..."` zu `"${Title}: ..."` korrigiert. + +## Ursachenanalyse + +Der 2.1-Abnahmetest kontrollierte Textverträge, BOM, Fehlerkanal und Paketstruktur, führte aber keinen echten Windows-PowerShell-Parser aus. Die Formulierung im 2.1-Abnahmebericht war daher falsch. + +## Neue Abnahmegrenzen + +- plattformunabhängiger statischer Regressionstest für ungültige `$Variable:`-Verweise +- lokale Parserprüfung mit `System.Management.Automation.Language.Parser` vor jedem Updaterstart +- zweite Parserprüfung im Worker-Host vor dem Laden des Worker-Kerns +- Windows-PowerShell-5.1-Parserjob in GitHub Actions +- Release-Workflow von erfolgreichem Windows-Parserjob abhängig + +## Prüfgrenze dieser Paketumgebung + +In der Linux-Paketumgebung kann Windows PowerShell 5.1 nicht ausgeführt werden. Deshalb wird hier nichts als Windows-Parserlauf ausgegeben. Der exakte Windows-PowerShell-5.1-Parser läuft stattdessen zwingend lokal vor dem Start und im Windows-GitHub-Actions-Job. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_4.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_4.md new file mode 100644 index 0000000..7e77c4c --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_4.md @@ -0,0 +1,38 @@ +# Abnahmebericht – Vendoo Production Updater 2.4 + +## Anlass + +Im Updater 2.3 wurde `gh auth login` im versteckten Worker ausgeführt. GitHub CLI benötigt für den Browser-Gerätefluss sichtbare Interaktion. Der Prozess wartete deshalb, während der Benutzer weder Gerätecode noch Browseraktion zuverlässig sehen konnte. Zusätzlich erzeugte der Downloadfortschritt zu viele identische Logeinträge. + +## Umsetzung + +- interaktiver GitHub-Login aus `Invoke-External` entfernt +- eigenes sichtbares Anmeldefenster `github-login.cmd` +- expliziter GitHub-Aufruf mit `--hostname github.com --git-protocol https --web --clipboard` +- Hauptoberfläche bleibt geöffnet und pollt `gh auth status` still +- automatische Fortsetzung nach erfolgreichem Login +- eindeutiger Fehler bei geschlossenem oder fehlgeschlagenem Anmeldefenster +- maximal zehn Minuten für die einmalige Anmeldung +- Downloadfortschritt nur noch in 10-%-Stufen +- Status-Polling ohne wiederholte Fehler- und Startmeldungen +- Updater-Version 2.4.0 in Preflight, UI, Host, Worker und Manifest konsistent + +## Ausgeführte Prüfungen + +- Updater-2.4-Vertragsgate: bestanden +- PowerShell-Static-Gate: 16 Dateien, bestanden +- zusätzlicher PowerShell-Syntaxparse der sechs zentralen Updater-Skripte: ohne Fehlerknoten +- JavaScript-/MJS-/CJS-Syntaxprüfung: 170 Dateien, bestanden +- Git-Sicherheitsgate: 590 Dateien, bestanden +- Git-Sicherheitsregression: bestanden +- Git-Stagingregression: bestanden +- Git-Ignore-Grenzprüfung: bestanden +- Source-Root-Grenzprüfung: bestanden + +## Einschränkung der Prüfumgebung + +Windows PowerShell 5.1 und eine interaktive Windows-WPF-Sitzung stehen in der Linux-Prüfumgebung nicht zur Verfügung. Das Paket enthält deshalb weiterhin das echte Windows-PowerShell-Parser-Gate, das sämtliche PowerShell-Dateien auf dem Zielrechner vor dem Öffnen der Oberfläche prüft. Der GitHub-Gerätefluss selbst kann erst auf Windows gegen das echte Benutzerkonto abgeschlossen werden. + +## Sicherheitsgrenze + +Der gemeldete 2.3-Lauf endete während der GitHub-Anmeldung mit `cancelled`. Vor diesem Punkt wurden kein Release-Branch, kein Pull Request, kein Merge und kein Coolify-Deployment ausgeführt. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_5.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_5.md new file mode 100644 index 0000000..12791bf --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_5.md @@ -0,0 +1,16 @@ +# Abnahmebericht – Vendoo Production Updater 2.5 + +## Korrektur + +Der Launcher wurde als minimaler ASCII/CRLF-Einstiegspunkt neu erstellt. Die Prüfung erfolgt bytegenau. + +## Harte Gates + +- keine UTF-8-BOM in `UPDATE_VENDOO.bat` +- ausschließlich Bytes 0x00–0x7F +- kein einzelnes LF ohne vorangestelltes CR +- kein einzelnes CR ohne folgendes LF +- absoluter Windows-PowerShell-Systempfad +- keine `chcp`-Umschaltung +- keine Übergabe von `PackageRoot` +- CRLF-Vertrag in `.gitattributes` und `.editorconfig` diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_6.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_6.md new file mode 100644 index 0000000..9f64942 --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_6.md @@ -0,0 +1,14 @@ +# Abnahmebericht – Vendoo Production Updater 2.6 + +## Korrektur + +Die Coolify-Webhook-Abfrage des Desktop-Updaters wurde vollständig entfernt. Der Produktionsablauf verwendet nach dem Merge das bestehende Coolify Auto Deploy der GitHub-Verknüpfung. + +## Abnahmekriterien + +- kein `COOLIFY_DEPLOY_WEBHOOK_URL` im Worker +- keine Secret-Eingabe und kein `gh secret set` +- kein manueller `workflow run` +- kontrolliertes Warten auf `/readyz` +- exakte Versionsprüfung auf 1.43.0 +- Timeout mit verständlichem Auto-Deploy-Hinweis diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_8.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_8.md new file mode 100644 index 0000000..9744ba5 --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_8.md @@ -0,0 +1,13 @@ +# Abnahmebericht – Vendoo Production Updater 2.8 + +## Behobener Fehler + +Windows brach `npm run verify:all` im Gate `verify:initial-modules` mit `EBUSY` beim Löschen einer noch geöffneten temporären SQLite-Datenbank ab. + +## Korrektur + +- temporäre `better-sqlite3`-Verbindung wird explizit geschlossen +- WAL wird vor dem Close kontrolliert ausgecheckt +- Dateisystembereinigung besitzt Windows-taugliche Wiederholungen +- neuer Regressionstest führt das betroffene Gate dreimal in getrennten Node-Prozessen aus +- Updater-Gate verlangt den Cleanup-Vertrag als harte Releasebedingung diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_9.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_9.md new file mode 100644 index 0000000..4120e7e --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_2_9.md @@ -0,0 +1,24 @@ +# Abnahmebericht – Vendoo Production Updater 2.9 + +## Anlass + +Unter Windows hielten mehrere Verifier ihre temporären `better-sqlite3`-Datenbanken offen. Dadurch scheiterte das Entfernen der Testordner mit `EBUSY`. Außerdem erzeugten Capability- und Architekturtests durch transitive Modulimporte eine echte `db/vendoo.db` im Quellbaum. + +## Korrektur + +- zentraler Runtime-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen +- Einladungs-, Initialmodul-, Capability- und Architekturtest laufen in isolierten Datenverzeichnissen +- statischer Importgraph-Audit aller aktiven `verify:all`-Verifier bis `lib/db.mjs` +- jeder datenbanknahe Verifier muss Testpfade, explizites Schließen und zentralen Temp-Cleanup nachweisen +- dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus +- ein zweiter Testlauf erzeugt keine `db/vendoo.db` im Quellbaum + +## Abnahme + +- `npm run verify:all`: Exit-Code 0 +- Syntaxprüfung: 180 JavaScript-/MJS-/CJS-Dateien +- SQLite-Cleanup-Audit: 29 aktive Verifier transitiv geprüft +- Wiederholungsregression: 12 isolierte Testprozesse ohne offene SQLite-Datei oder Temp-Rest +- Plattform: 18 Module und 41 Capabilities +- Datenbankmigrationen und Integritätsprüfung: bestanden +- Pull Request, Merge und Deployment bleiben bei jedem Testfehler gesperrt diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_1.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_1.md new file mode 100644 index 0000000..e54795c --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_1.md @@ -0,0 +1,48 @@ +# Abnahmebericht – Vendoo Production Updater 3.1 + +## Anlass + +Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie. + +## Architekturkorrektur + +Updater 3.1 ersetzt diesen Mechanismus vollständig: + +- keine Leerung der geklonten Baseline, +- SHA-256-Manifest aller Release-Dateien, +- Pflichtdateien werden vor jedem Test geprüft, +- Overlay statt Vollersetzung, +- Löschungen ausschließlich explizit über `removedFiles`, +- Hashprüfung vor und nach jedem Kopiervorgang, +- Sitzungen aus 2.x werden verworfen, +- separater v3-Release-Branch, +- vollständiger Windows-Releasevertrag in GitHub Actions. + +## Pflichtdateien + +Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt: + +- `db/schema.sql` +- `lib/db.mjs` +- `server.mjs` +- `bootstrap.mjs` +- `package.json` +- `package-lock.json` +- Updater Preflight, UI, Host und Worker + +## Prüfungen + +- Release-Manifest-Gate mit Manipulations- und Missing-File-Test +- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline +- Baseline-Erhalt ohne nicht deklarierte Löschungen +- vollständige Vendoo-Gates in getrennten Blöcken +- Datenbankmigrationen +- SQLite-Handle-Cleanup +- Module, Capabilities, Security, Identity, Katalog und Production Operations +- Windows-PowerShell-Parservertrag +- Windows-CI mit vollständigem `npm run verify:all` +- manifestbasierter GitHub-Release-Builder + +## Sicherheitsgrenze + +Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_2.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_2.md new file mode 100644 index 0000000..8d3108d --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_2.md @@ -0,0 +1,54 @@ +# Abnahmebericht – Vendoo Production Updater 3.2 + +## Anlass + +Die Updater-2.x-Reihe verwendete eine destruktive Kopierstrategie: Die geklonte Git-Arbeitskopie wurde geleert und anschließend über Ausschlussregeln aus dem Paket neu befüllt. Dadurch konnten Quell- und Laufzeitdaten nicht zuverlässig unterschieden werden. Der konkrete Fehler war das fehlende `db/schema.sql` im Auslieferungspaket beziehungsweise in der vorbereiteten Arbeitskopie. + +## Architekturkorrektur + +Updater 3.2 ersetzt diesen Mechanismus vollständig: + +- keine Leerung der geklonten Baseline, +- SHA-256-Manifest aller Release-Dateien, +- Pflichtdateien werden vor jedem Test geprüft, +- Overlay statt Vollersetzung, +- Löschungen ausschließlich explizit über `removedFiles`, +- Hashprüfung vor und nach jedem Kopiervorgang, +- Sitzungen aus 2.x werden verworfen, +- separater v3-Release-Branch, +- vollständiger Windows-Releasevertrag in GitHub Actions. + +## Pflichtdateien + +Der Release wird unter anderem blockiert, wenn eine dieser Dateien fehlt: + +- `db/schema.sql` +- `lib/db.mjs` +- `server.mjs` +- `bootstrap.mjs` +- `package.json` +- `package-lock.json` +- Updater Preflight, UI, Host und Worker + +## Prüfungen + +- Release-Manifest-Gate mit Manipulations- und Missing-File-Test +- reales Overlay des vollständigen Release-Manifests auf eine Git-Baseline +- Baseline-Erhalt ohne nicht deklarierte Löschungen +- vollständige Vendoo-Gates in getrennten Blöcken +- Datenbankmigrationen +- SQLite-Handle-Cleanup +- Module, Capabilities, Security, Identity, Katalog und Production Operations +- Windows-PowerShell-Parservertrag +- Windows-CI mit vollständigem `npm run verify:all` +- manifestbasierter GitHub-Release-Builder + +## Sicherheitsgrenze + +Der Updater verändert `main`, Coolify und die Produktionsinstanz erst nach grünen lokalen Tests und grünen GitHub-Checks. `/app/data`, Datenbanken, Uploads, Secrets und Backups sind nicht Bestandteil des Release-Manifests. + +## Korrektur 3.2 + +- `release-manifest.json` wird nach dem Payload als eigene Steuerdatei kopiert und bytegenau geprüft. +- Das Zielrepository darf unveränderte Baseline-Dateien enthalten; manifestierte Dateien bleiben vollständig hashverbindlich. +- Die Löschung der Steuerdatei über `removedFiles` ist verboten. diff --git a/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_3.md b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_3.md new file mode 100644 index 0000000..9fbd9e3 --- /dev/null +++ b/docs/ABNAHMEBERICHT_PRODUCTION_UPDATER_3_3.md @@ -0,0 +1,9 @@ +# Abnahmebericht – Vendoo Production Updater 3.3 + +## Behobener Fehler + +Der Windows-Lauf erreichte das letzte Gate, scheiterte aber im POSIX-Installer-Test. Ursache war eine nicht portable Annahme: Git Bash auf NTFS sollte nach `chmod 600` über `stat -c` exakt Modus `600` melden. + +## Korrektur + +Die POSIX-Rechteprüfung bleibt auf Linux/NAS/VPS strikt. Windows Git Bash prüft die Shell-Syntax; die funktionale Simulation, `.env`, Token, Compose-Aufrufe und Modus `0600` werden verpflichtend unter Linux geprüft. Fehlgeschlagene Assertions liefern vollständige Diagnoseausgaben. diff --git a/docs/COOLIFY_UPDATE_1_42_0.md b/docs/COOLIFY_UPDATE_1_42_0.md new file mode 100644 index 0000000..ddda259 --- /dev/null +++ b/docs/COOLIFY_UPDATE_1_42_0.md @@ -0,0 +1,108 @@ +# Coolify-Update auf Vendoo 1.42.0 + +# Empfohlener automatischer Weg + +Für den normalen Updatefall nur das Release-ZIP entpacken und `UPDATE_VENDOO.bat` doppelklicken. GitHub-Anmeldung und Coolify-Webhook werden nur beim ersten Start abgefragt. Alle nachfolgenden Schritte dieser Datei sind der manuelle Notfall- und Kontrollweg. + +## Voraussetzungen + +- Repository: `Masterluke77/vendoo` +- Produktionsbranch: `main` +- Dockerfile-Build +- interner Port: `8124` +- Domain: `https://vendoo.flatlined.de` +- persistentes Volume unverändert nach `/app/data` +- genau eine Instanz +- Rolling Updates aus +- Auto Deploy aus +- FLUX Studio aus + +## Vor dem Merge + +1. Releasebranch `release/1.42.0-production-operations` aus aktuellem `main` erstellen. +2. Vollständigen Source-Stand übertragen; keine `.env`, Datenbank, Uploads, Backups, Logs, Master-Keys oder installierten Laufzeitmodule committen. +3. `npm ci` und `npm run verify:all` ausführen. +4. Draft-PR erstellen und GitHub Actions abwarten. +5. Erst nach grünem CI und Review nach `main` mergen. + +## Zusätzliche Coolify-Variablen + +```env +COOLIFY_DEPLOYMENT_STATUS_ENDPOINT=/api/v1/deployments/{deployment_id} +VENDOO_DEPLOY_TIMEOUT_SECONDS=900 +VENDOO_DEPLOY_HEALTH_POLL_SECONDS=15 +VENDOO_BUILD_REVISION= +VENDOO_COOLIFY_AUTO_DEPLOY=false +``` + +Die vorhandenen Produktionswerte müssen erhalten bleiben: + +```env +NODE_ENV=production +PORT=8124 +VENDOO_BIND_HOST=0.0.0.0 +PUBLIC_BASE_URL=https://vendoo.flatlined.de +VENDOO_ALLOWED_HOSTS=vendoo.flatlined.de +VENDOO_ALLOWED_ORIGINS=https://vendoo.flatlined.de +VENDOO_TRUST_PROXY=true +VENDOO_COOKIE_SECURE=true +VENDOO_COOKIE_SAMESITE=lax +VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio +LOCAL_IMAGE_ENABLED=false +LOCAL_IMAGE_ALLOW_REMOTE=false +VENDOO_GITHUB_REPOSITORY=Masterluke77/vendoo +VENDOO_GITHUB_BRANCH=main +VENDOO_UPDATE_CHANNEL=stable +VENDOO_BACKUP_BEFORE_DEPLOY=true +VENDOO_DEPLOY_BACKUP_UPLOADS=false +``` + +## Coolify-Konfiguration prüfen + +1. Coolify öffnen und Projekt **Vendoo → Production → Application** wählen. +2. Source muss die private GitHub-App, Repository `Masterluke77/vendoo` und Branch `main` verwenden. +3. Build Pack **Dockerfile**, Base Directory `/`, Dockerfile `/Dockerfile`. +4. Port `8124`; kein öffentliches Host-Port-Mapping. +5. Domain `https://vendoo.flatlined.de`; HTTPS erzwingen. +6. Volume `vendoo-data` unverändert nach `/app/data`. +7. Replikate `1`, Rolling Updates aus, Auto Deploy aus. +8. Healthcheck `/readyz` mit erwartetem HTTP 200. +9. Speichern, aber das Volume niemals neu anlegen oder löschen. + +## Erstes kontrolliertes Deployment + +1. Nach dem Merge Coolify noch nicht blind mehrfach starten. +2. Bestehendes Vendoo öffnen: **Administration → System → Updates**. +3. Produktionscheck ausführen und Fehler vor dem Deployment beheben. +4. Coolify-Provider, Trigger-Modus, Resource UUID, Repository und `main` prüfen. +5. Webhook oder API-Token nur über die Vendoo-Oberfläche in den verschlüsselten Secret Store eintragen. +6. **Version prüfen**. Eine neuere Zielversion wird nur akzeptiert, wenn das freigegebene Manifest SemVer, Kanal, HTTPS-Paket-URL und SHA-256 erfüllt. +7. Zielversion `1.42.0` eintragen. +8. Vorab-Backup aktiviert lassen. Uploads nur bei ausreichendem Backup-Speicher einschließen. +9. **Coolify-Deployment starten** wählen und exakt `DEPLOY` eingeben. +10. Der Auftrag muss nacheinander Backup, Backup-Verifikation und Coolify-Trigger protokollieren. +11. Im API-Modus wird der Coolify-Status gepollt. Im Webhook-Modus nach dem tatsächlichen Coolify-Abschluss im Verlauf **Externen Abschluss bestätigen** wählen und exakt `CONFIRM` eingeben. +12. Erfolg darf erst erscheinen, wenn `/readyz` grün ist und die laufende Version `1.42.0` meldet. + +## Abnahme nach Deployment + +- `/readyz` liefert HTTP 200 und Version `1.42.0`. +- Login funktioniert. +- Bestehende Benutzer, Artikel und Uploads sind vorhanden. +- SQLite-Integrität ist `ok`. +- Letztes Vorab-Backup ist als verifiziert sichtbar. +- Deployment-Verlauf zeigt `succeeded` und nachvollziehbare Events. +- FLUX Studio bleibt deaktiviert. +- Produktionscheck hat keine Fehler. + +## Fehler und Rollback + +Bei `rollback_required` nicht erneut deployen, bevor die Ursache geprüft wurde. + +1. Coolify-Build- und Startlogs sichern. +2. `/readyz`, Zielversion und Volume-Mount prüfen. +3. In Coolify den letzten funktionierenden Commit beziehungsweise das vorige Image redeployen. +4. Dasselbe Volume `/app/data` weiterverwenden. +5. Volume niemals löschen oder neu initialisieren. +6. Nach erfolgreichem Rollback Login, Artikel, Uploads, SQLite und `/readyz` prüfen. +7. Den blockierten Vendoo-Auftrag erst danach mit **Prüfung abschließen** und exakt `CONFIRM` dokumentiert schließen. diff --git a/docs/EIN_KLICK_UPDATE_1_42_0.md b/docs/EIN_KLICK_UPDATE_1_42_0.md new file mode 100644 index 0000000..84bd495 --- /dev/null +++ b/docs/EIN_KLICK_UPDATE_1_42_0.md @@ -0,0 +1,30 @@ +# Vendoo 1.42.0 – Ein-Klick-Update + +## So benutzt du es + +1. ZIP vollständig in einen neuen Ordner entpacken. +2. `UPDATE_VENDOO.bat` doppelklicken. +3. Nur beim ersten Start GitHub im Browser anmelden. +4. Nur beim ersten Start den Coolify-Deploy-Webhook einfügen. +5. Danach läuft alles automatisch. + +## Automatischer Ablauf + +- Git, Node.js LTS und GitHub CLI werden bei Bedarf über `winget` installiert. +- Das private Repository `Masterluke77/vendoo` wird frisch aus `main` geklont. +- Ein isolierter Release-Branch wird angelegt. +- Nur sichere Source-Dateien werden übernommen. +- `.env`, Datenbanken, Uploads, Backups, Logs und Schlüssel bleiben ausgeschlossen. +- `npm ci` und `npm run verify:all` laufen vollständig. +- Der Pull Request wird erstellt, geprüft und nach `main` gemergt. +- GitHub Actions startet den Coolify-Deploy-Webhook. +- Erfolg wird erst gemeldet, wenn `/readyz` HTTP 200 und exakt Version `1.42.0` liefert. +- Abschließend wird `/healthz` geprüft. + +## Sicherheit + +Der Coolify-Webhook wird nicht in das Paket oder Repository geschrieben. Er wird nur einmalig als verschlüsseltes GitHub Actions Secret `COOLIFY_DEPLOY_WEBHOOK_URL` gespeichert. + +Das persistente Coolify-Volume `/app/data` wird weder ersetzt noch gelöscht. Parallele Produktionsdeployments werden durch GitHub Actions `concurrency` verhindert. + +Lokale Protokolle liegen unter `logs/update-1.42.0-.log`. Der Ordner `logs` wird vom Git-Staging ausgeschlossen. diff --git a/docs/PRODUCTION_UPDATER_2_0.md b/docs/PRODUCTION_UPDATER_2_0.md new file mode 100644 index 0000000..047f678 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_0.md @@ -0,0 +1,22 @@ +# Vendoo Production Updater 2.0 + +Der bisherige Hotfix-Updater wurde vollständig ersetzt. + +## Eigenschaften + +- moderne, nüchterne WPF-Oberfläche ohne dekorative Spielereien +- Fenster bleibt bei Erfolg, Abbruch und Fehler geöffnet +- sichtbare zehnstufige Prozessanzeige und technisches Live-Protokoll +- verwaltete portable GitHub CLI statt Winget-Abhängigkeit +- offizieller GitHub-Download mit SHA-256-Abgleich gegen die Release-Prüfsummen +- feste Zeitlimits und kontrollierter Prozessabbruch inklusive Prozessbaum +- sichere Coolify-Eingabe ohne Klartext im Log oder in der Kommandozeile +- persistenter Wiederaufnahmepunkt unter `%LOCALAPPDATA%\Vendoo\Updater` +- idempotenter Ablauf: nach Merge- oder Deploymentfehlern wird nicht blind neu begonnen +- kein automatisches Schließen der Oberfläche + +## Start + +`UPDATE_VENDOO.bat` doppelklicken. + +Eine GitHub-Browseranmeldung und die Eingabe des Coolify-Webhooks sind nur erforderlich, wenn sie auf diesem Rechner beziehungsweise im Repository noch fehlen. diff --git a/docs/PRODUCTION_UPDATER_2_1.md b/docs/PRODUCTION_UPDATER_2_1.md new file mode 100644 index 0000000..8a9a351 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_1.md @@ -0,0 +1,39 @@ +# Vendoo Production Updater 2.1 + +## Ziel + +Production Updater 2.1 ersetzt den direkten, nicht ausreichend überwachten Worker-Start aus 2.0. Die Oberfläche besitzt jetzt bereits vor dem Worker-Start einen festen Laufzeitordner, einen bekannten Protokollpfad und einen initialen Status. + +## Worker-Host + +Die Oberfläche startet ausschließlich `Vendoo.Updater.Host.ps1`. Dieser kleine Bootstrap-Host: + +- prüft alle Übergabepfade, +- schreibt vor dem Laden des Update-Kerns einen Status, +- fängt Lade-, Parser- und Initialisierungsfehler des Worker-Kerns ab, +- schreibt die vollständige PowerShell-Ausnahme inklusive Datei und Zeile, +- garantiert einen terminalen Status, +- verliert weder Standardausgabe noch Standardfehler. + +Der eigentliche Update-Kern läuft weiter in `Vendoo.Updater.Worker.ps1`, beendet aber nicht mehr eigenmächtig den PowerShell-Host. Erfolg, Abbruch und Fehler werden ausschließlich über den atomar geschriebenen Statusvertrag gemeldet. + +## Diagnose + +Jeder Lauf besitzt einen eigenen Ordner unter: + +```text +%LOCALAPPDATA%\Vendoo\Updater\runtime\ +``` + +Darin liegen mindestens: + +- `status.json` +- `events.ndjson` +- `updater.log` +- bei einem Host-Absturz zusätzlich `worker.stdout.log` und `worker.stderr.log` + +Die Oberfläche zeigt den Protokollnamen sofort an. Mit **Diagnoseordner** kann der vollständige Laufzeitordner geöffnet werden. Der Protokollpfad kann nicht mehr leer sein. + +## Sicherheitsgrenzen + +Die Änderungen betreffen ausschließlich den Updater-Unterbau. Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Secrets und Coolify-Konfiguration werden dadurch nicht zurückgesetzt oder gelöscht. diff --git a/docs/PRODUCTION_UPDATER_2_2.md b/docs/PRODUCTION_UPDATER_2_2.md new file mode 100644 index 0000000..4e9c8c0 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_2.md @@ -0,0 +1,25 @@ +# Vendoo Production Updater 2.2 + +## Anlass + +Updater 2.1 enthielt zwei echte Windows-PowerShell-Parserfehler. In doppelt quotierten Zeichenketten standen `"$Label: ..."` und `"$Title: ..."`. Windows PowerShell interpretiert den Doppelpunkt als Teil eines qualifizierten Variablenverweises. Korrekt sind `"${Label}: ..."` und `"${Title}: ..."`. + +Der bisherige JavaScript-Vertrag prüfte nur das Vorhandensein bestimmter Textbausteine. Er war kein echter PowerShell-Parser und durfte deshalb nicht als solcher bezeichnet werden. + +## Neue harte Gates + +1. `UPDATE_VENDOO.bat` führt vor dem Start der Oberfläche synchron `Vendoo.Updater.Preflight.ps1` aus. +2. Die Preflight-Prüfung verwendet den echter Windows-PowerShell-5.1-Parser über `System.Management.Automation.Language.Parser`. +3. UI, Host, Worker und Launcher müssen vollständig parserfehlerfrei sein, bevor die Oberfläche startet. +4. Der Worker-Host parst den Worker unmittelbar vor dem Laden erneut. +5. `verify-powershell-static.mjs` blockiert die konkrete Variablen-Doppelpunkt-Fehlerklasse bereits auf Linux und während des Paketbaus. +6. GitHub Actions besitzt einen eigenen `windows-latest`-Job mit echtem Windows-PowerShell-5.1-Parser. +7. Release-Pakete hängen vom erfolgreichen Windows-Parser-Job ab. + +## Fehlerverhalten + +Schlägt die lokale Parserprüfung fehl, startet die Oberfläche nicht. Stattdessen bleiben ein sichtbarer Fehlerdialog, ein Konsolenhinweis und `logs/updater-preflight.log` erhalten. Damit kann kein syntaktisch defekter Worker mehr bis zum Worker-Host gelangen. + +## Unveränderte Sicherheitsgrenzen + +Vendoo 1.43.0, `/app/data`, Datenbanken, Uploads, Backups, Secrets und die Coolify-Konfiguration werden durch diese Änderung nicht zurückgesetzt oder gelöscht. diff --git a/docs/PRODUCTION_UPDATER_2_3.md b/docs/PRODUCTION_UPDATER_2_3.md new file mode 100644 index 0000000..45a7e2b --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_3.md @@ -0,0 +1,19 @@ +# Vendoo Production Updater 2.3 + +## Pfadvertrag + +Version 2.3 entfernt die fehleranfällige Übergabe von `%~dp0` als PowerShell-Parameter. Ein mit Backslash endender Batch-Pfad kann vor einem schließenden Anführungszeichen als Bestandteil des Arguments interpretiert werden und dadurch ein illegales `"` in den Pfad einschleusen. + +Der Paketpfad wird nun ausschließlich aus `$PSScriptRoot` des ausgelieferten Preflight-Skripts abgeleitet, mit `Resolve-Path` kanonisiert und vor jeder Dateisystemoperation validiert. Oberfläche und Worker verwenden denselben Vertrag. + +## Startverhalten + +- `UPDATE_VENDOO.bat` verwendet `pushd` und relative Skriptpfade. +- Es wird kein Paketroot mehr über die Kommandozeile übergeben. +- Preflight und GUI laufen synchron; Exit-Codes bleiben sichtbar. +- Bei Fehlern bleibt die Konsole geöffnet. +- Das Preflight-Protokoll liegt unter `logs/updater-preflight.log`. + +## Regression + +Das Release-Gate verbietet `-PackageRoot "%~dp0"`, verlangt `param()` im Preflight und prüft die kanonische Root-Ableitung über `$PSScriptRoot`, `Resolve-Path` und `GetFullPath`. diff --git a/docs/PRODUCTION_UPDATER_2_4.md b/docs/PRODUCTION_UPDATER_2_4.md new file mode 100644 index 0000000..87d67b5 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_4.md @@ -0,0 +1,30 @@ +# Vendoo Production Updater 2.4 + +## Ziel + +Version 2.4 ersetzt den unsichtbaren interaktiven GitHub-Login aus 2.3. Ein interaktiver Gerätefluss darf nicht in einem versteckten Worker laufen. + +## GitHub-Anmeldung + +Ist noch kein GitHub-Konto angemeldet, öffnet der Updater ein sichtbares Anmeldefenster. Dort zeigt GitHub CLI den einmaligen Gerätecode an, kopiert ihn mit `--clipboard` in die Zwischenablage und öffnet den Webbrowser. Die Vendoo-Oberfläche bleibt geöffnet und prüft den Status still im Hintergrund. Nach erfolgreicher Anmeldung wird automatisch fortgefahren. + +Der Ablauf verwendet: + +```text +gh auth login --hostname github.com --git-protocol https --web --clipboard +``` + +Die Anmeldung läuft nicht mehr über den versteckten Worker-Prozess. Ein Abbruch oder Fehler bleibt sichtbar und führt zu einem klaren, wiederholbaren Fehlerzustand. + +## Downloadstatus + +Der Download der portablen GitHub CLI schreibt nur noch sinnvolle 10-%-Fortschrittsstufen. Wiederholte Meldungen für dieselbe Megabyte-Grenze und die bisherige Logflut sind ausgeschlossen. + +## Weiterhin gültige Schutzregeln + +- echter Windows-PowerShell-5.1-Parser vor dem Start +- kanonische Root-Pfadermittlung +- persistenter Laufzeit- und Diagnoseordner +- keine Änderung an `main` vor grünen Tests +- kein Produktions-Erfolg ohne exakte Zielversion +- kein Zugriff auf oder Löschen von `/app/data` diff --git a/docs/PRODUCTION_UPDATER_2_5.md b/docs/PRODUCTION_UPDATER_2_5.md new file mode 100644 index 0000000..f689d87 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_5.md @@ -0,0 +1,20 @@ +# Vendoo Production Updater 2.5 + +## Zweck + +Version 2.5 ersetzt den fehlerhaft ausgelieferten CMD-Launcher aus 2.4. Die PowerShell- und Vendoo-Logik bleibt unverändert; korrigiert wurde der Windows-Einstiegspunkt. + +## Verbindlicher Launcher-Vertrag + +- `UPDATE_VENDOO.bat` ist reines 7-Bit-ASCII. +- Die Datei besitzt keine BOM. +- Jede Zeile endet mit Windows-CRLF. +- Der Launcher enthält keine Umlaute und keinen `chcp`-Wechsel. +- Windows PowerShell wird über den absoluten Systempfad gestartet. +- Fehlerzustände bleiben mit `pause` sichtbar. +- `.gitattributes` und `.editorconfig` erzwingen CRLF für BAT/CMD. +- Das Binär-Gate prüft die tatsächlichen Bytes und nicht nur gelesenen Text. + +## Ursache des 2.4-Fehlers + +Die BAT war UTF-8 mit Unix-LF-Zeilenenden. `cmd.exe` interpretierte dadurch mehrere Zeilen beschädigt und verlor sichtbare Anfangszeichen von Befehlen. diff --git a/docs/PRODUCTION_UPDATER_2_6.md b/docs/PRODUCTION_UPDATER_2_6.md new file mode 100644 index 0000000..5e708ff --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_6.md @@ -0,0 +1,22 @@ +# Vendoo Production Updater 2.6 + +## Ziel + +Updater 2.6 entfernt die unnötige Coolify-Webhook-Abfrage vollständig. + +Nach dem kontrollierten Merge nach `main` nutzt Vendoo die bereits bestehende GitHub-Verknüpfung von Coolify und wartet auf das automatische Deployment. Der Updater prüft anschließend `/readyz` und akzeptiert den Lauf nur, wenn exakt Vendoo 1.43.0 gemeldet wird. + +## Voraussetzungen + +- Die Vendoo-Anwendung ist in Coolify mit `Masterluke77/vendoo` und Branch `main` verbunden. +- In Coolify ist für die Anwendung **Auto Deploy** aktiviert. +- `https://vendoo.flatlined.de/readyz` ist öffentlich erreichbar. + +## Verhalten + +- keine Eingabe eines Coolify-Webhooks +- kein GitHub Secret `COOLIFY_DEPLOY_WEBHOOK_URL` +- kein manuell gestarteter Deployment-Workflow +- Wartezeit maximal 15 Minuten +- Statusmeldung höchstens alle 30 Sekunden +- eindeutiger Hinweis auf Auto Deploy, falls die Zielversion nicht erscheint diff --git a/docs/PRODUCTION_UPDATER_2_8.md b/docs/PRODUCTION_UPDATER_2_8.md new file mode 100644 index 0000000..31d8dca --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_8.md @@ -0,0 +1,18 @@ +# Vendoo Production Updater 2.8 + +## Ziel + +Updater 2.8 behebt einen Windows-spezifischen SQLite-Lifecycle-Fehler im vollständigen Release-Test. + +`verify-initial-disabled-modules-1.41.2.mjs` lud den Plattformkernel und öffnete dadurch `better-sqlite3`, löschte den temporären Datenordner anschließend aber ohne die Datenbank explizit zu schließen. Linux tolerierte das; Windows blockierte `vendoo.db` mit `EBUSY`. + +## Neuer Ressourcenvertrag + +- explizites `wal_checkpoint(TRUNCATE)` +- explizites `db.close()` vor jeder Temp-Bereinigung +- kurze Freigabephase nach dem synchronen Close +- `rmSync` mit 20 Wiederholungen und 100 ms Wartezeit für kurzzeitige Windows-Dateisperren +- Cleanup-Fehler werden mit Pfad und Fehlercode gemeldet +- drei isolierte Wiederholungsläufe prüfen, dass kein Temp-Ordner zurückbleibt + +Der Fehler tritt vor Pull Request, Merge und Coolify-Deployment auf. Produktivdaten waren dadurch nicht betroffen. diff --git a/docs/PRODUCTION_UPDATER_2_9.md b/docs/PRODUCTION_UPDATER_2_9.md new file mode 100644 index 0000000..c38f014 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_2_9.md @@ -0,0 +1,11 @@ +# Vendoo Production Updater 2.9 + +Updater 2.9 behebt die Windows-Dateisperren in allen aktiven Verifiern systematisch. + +## Änderungen + +- Zentraler SQLite-Test-Cleanup mit WAL-Checkpoint, `db.close()` und Windows-tauglichen Löschwiederholungen. +- Einladungs-, Initialmodul-, Capability- und Architekturtest verwenden denselben Cleanup-Vertrag. +- Statischer Importgraph-Audit prüft alle aktiven `verify:all`-Verifier auf transitive `lib/db.mjs`-Nutzung. +- Dynamische Regression führt alle vier datenbanknahen Testklassen jeweils dreimal in getrennten Node-Prozessen aus. +- Kein Pull Request, Merge oder Deployment bei fehlgeschlagenem Testlauf. diff --git a/docs/PRODUCTION_UPDATER_3_1.md b/docs/PRODUCTION_UPDATER_3_1.md new file mode 100644 index 0000000..f0e2208 --- /dev/null +++ b/docs/PRODUCTION_UPDATER_3_1.md @@ -0,0 +1,40 @@ +# Vendoo Production Updater 3.1 + +## Architekturwechsel + +Updater 3.1 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr. + +Der Release enthält `release-manifest.json` mit: + +- jeder auszuliefernden Quelldatei, +- Dateigröße, +- SHA-256-Prüfsumme, +- verbindlichen Pflichtdateien, +- ausschließlich expliziten Löschungen in `removedFiles`. + +## Anwendung + +1. `main` wird in einen isolierten Arbeitsordner geklont. +2. Das gesamte Paket wird gegen `release-manifest.json` geprüft. +3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update. +4. Manipulierte oder fehlende Dateien blockieren das Update. +5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten. +6. Nur Einträge in `removedFiles` werden gelöscht. +7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft. +8. Erst danach laufen `npm ci` und `npm run verify:all`. + +## Pflichtdateien + +Unter anderem müssen immer vorhanden sein: + +- `db/schema.sql` +- `lib/db.mjs` +- `package.json` +- `package-lock.json` +- `server.mjs` +- `bootstrap.mjs` +- Updater-Preflight, UI, Host und Worker + +## Sitzungen + +Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.1 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung. diff --git a/docs/PRODUCTION_UPDATER_3_2.md b/docs/PRODUCTION_UPDATER_3_2.md new file mode 100644 index 0000000..1b2f0bd --- /dev/null +++ b/docs/PRODUCTION_UPDATER_3_2.md @@ -0,0 +1,48 @@ +# Vendoo Production Updater 3.2 + +## Architekturwechsel + +Updater 3.2 verwendet keine Ausschlusslisten-Kopie und leert die geklonte `main`-Arbeitskopie nicht mehr. + +Der Release enthält `release-manifest.json` mit: + +- jeder auszuliefernden Quelldatei, +- Dateigröße, +- SHA-256-Prüfsumme, +- verbindlichen Pflichtdateien, +- ausschließlich expliziten Löschungen in `removedFiles`. + +## Anwendung + +1. `main` wird in einen isolierten Arbeitsordner geklont. +2. Das gesamte Paket wird gegen `release-manifest.json` geprüft. +3. Sichere Paketdateien, die nicht im Manifest stehen, blockieren das Update. +4. Manipulierte oder fehlende Dateien blockieren das Update. +5. Der Release wird als Overlay angewendet; bestehende Baseline-Dateien bleiben erhalten. +6. Nur Einträge in `removedFiles` werden gelöscht. +7. Jede kopierte Datei wird im Ziel erneut per SHA-256 geprüft. +8. Erst danach laufen `npm ci` und `npm run verify:all`. + +## Pflichtdateien + +Unter anderem müssen immer vorhanden sein: + +- `db/schema.sql` +- `lib/db.mjs` +- `package.json` +- `package-lock.json` +- `server.mjs` +- `bootstrap.mjs` +- Updater-Preflight, UI, Host und Worker + +## Sitzungen + +Sitzungsstände der Updater-2.x-Reihe werden nicht übernommen. Updater 3.2 beginnt mit einem neuen Release-Branch und einer neuen, versionsgebundenen Sitzung. + +## Steuerdatei-Vertrag 3.2 + +`release-manifest.json` ist kein selbstgehashter Payload-Eintrag. Der Applier kopiert es nach erfolgreicher Payload-Prüfung als separates Steuerartefakt bytegenau in die Git-Arbeitskopie. Verifier in einer Git-Baseline dürfen zusätzliche unveränderte Dateien sehen, müssen aber jede manifestierte Datei und ihre SHA-256-Prüfsumme bestätigen. + +## Fehlerdarstellung + +Bei fehlgeschlagenen Prozessen priorisiert der Worker `stderr` als **Fehlerausgabe** und zeigt `stdout` nur gekürzt als ergänzende **letzte Standardausgabe**. Dadurch bleibt die eigentliche Ursache im Statusfenster sichtbar. diff --git a/docs/PRODUCTION_UPDATER_3_3.md b/docs/PRODUCTION_UPDATER_3_3.md new file mode 100644 index 0000000..729773b --- /dev/null +++ b/docs/PRODUCTION_UPDATER_3_3.md @@ -0,0 +1,17 @@ +# Vendoo Production Updater 3.3 + +## Zweck + +Updater 3.3 behält das manifestbasierte Overlay von 3.2 bei und korrigiert das letzte plattformspezifische Release-Gate. + +## Installer-Shell-Portabilität + +- Unter Linux, NAS und VPS muss `.env` nach der Erzeugung weiterhin exakt POSIX-Modus `0600` besitzen. +- Unter Windows prüft Git Bash ausschließlich die Syntax von `setup.sh` und dem funktionalen Shell-Test. Die vollständige Docker-/NAS-/VPS-Simulation läuft im verpflichtenden Linux-CI-Gate, wo POSIX-Rechte real abgebildet werden. +- Der Shell-Test erkennt MINGW, MSYS und CYGWIN ausdrücklich. +- Jede fehlgeschlagene Assertion zeigt die betroffene Ausgabe- oder Konfigurationsdatei vollständig an. +- Der Node-Wrapper reicht `stdout` und `stderr` im Fehlertext weiter. + +## Unveränderte Sicherheitsgrenzen + +Das SHA-256-Manifest, die bytegeprüfte Steuerdatei, explizite Löschungen, GitHub-PR-/Merge-Gates, Coolify Auto Deploy und `/readyz` bleiben verpflichtend. diff --git a/docs/RELEASE_1_42_0_PRODUCTION_OPERATIONS.md b/docs/RELEASE_1_42_0_PRODUCTION_OPERATIONS.md new file mode 100644 index 0000000..abbfd96 --- /dev/null +++ b/docs/RELEASE_1_42_0_PRODUCTION_OPERATIONS.md @@ -0,0 +1,50 @@ +# Vendoo 1.42.0 – Production Operations & Controlled Update Completion + +## Ziel + +Version 1.42.0 vervollständigt den sicheren Coolify-Produktionsbetrieb. Ein HTTP-Erfolg des Deploy-Triggers gilt nicht mehr als erfolgreiches Deployment. Ein Auftrag durchläuft persistent gespeicherte Zustände und wird erst nach bestätigtem Deployment, erfolgreicher Readiness und passender Zielversion abgeschlossen. + +## Neue Betriebsfunktionen + +- Produktionsstatus mit Version, Build-Revision, Provider, öffentlicher URL, Readiness, SQLite-Status, Speicherbelegung, letztem verifiziertem Backup, letztem Deployment und FLUX-Status. +- Produktions-Selbstprüfung für HTTPS, Cookies, Trust Proxy, Host/Origin, Persistenz, Schreibrechte, SQLite, Backupziel, FLUX, Auto Deploy und Coolify-Secrets. +- Persistente Deployment-State-Machine mit Ereignisverlauf. +- Idempotency-Key und Sperre gegen parallele Aufträge. +- Verifiziertes Vorab-Backup vor dem Coolify-Trigger. +- Coolify-API-Statusabfrage oder kontrollierter Webhook-Modus mit exakter Bestätigung `CONFIRM`. +- `/readyz`- und Zielversionsprüfung vor dem Status `succeeded`. +- `rollback_required` bei Timeout oder Coolify-Fehler; neue Aufträge bleiben bis zur dokumentierten Prüfung gesperrt. +- Einladungsübersicht, 48-Stunden-Ablauf, erneutes Senden und Widerruf; Standardrolle ist `viewer`. + +## Additive Datenbankmigration + +Neu angelegt werden ausschließlich: + +- `deployment_runs` +- `deployment_events` +- `backup_verifications` +- `production_checks` + +Bestehende Tabellen, Nutzer, Artikel, Uploads, Einstellungen, Secrets, Backups und Module werden nicht gelöscht oder zurückgesetzt. + +## Neue administrative API-Verträge + +- `GET /api/admin/deployment` +- `PUT /api/admin/deployment` +- `POST /api/admin/deployment/test` +- `POST /api/admin/deployment/updates/check` +- `POST /api/admin/deployment/trigger` +- `GET /api/admin/deployment/runs` +- `GET /api/admin/deployment/runs/:id` +- `POST /api/admin/deployment/runs/:id/refresh` +- `POST /api/admin/deployment/runs/:id/confirm` +- `GET|POST /api/admin/deployment/production-check` +- `GET /api/admin/users/invitations` +- `POST /api/admin/users/invitations/:id/resend` +- `DELETE /api/admin/users/invitations/:id` + +Alle schreibenden Routen bleiben CSRF-geschützt, rollenbasiert und auditierbar. Secretwerte werden nicht zurückgegeben. + +## Sicherheitsgrenzen + +Vendoo 1.42.0 besitzt weiterhin keinen Docker-Socket und führt im Produktivcontainer weder `git pull`, `npm install`, Docker-Kommandos noch eine Selbstüberschreibung aus. Coolify bleibt für Build, Containerwechsel und Rollback verantwortlich. diff --git a/docs/RELEASE_1_43_0_MODULE_NAVIGATION_LISTING_STUDIO_UPDATER_UI.md b/docs/RELEASE_1_43_0_MODULE_NAVIGATION_LISTING_STUDIO_UPDATER_UI.md new file mode 100644 index 0000000..c1d481e --- /dev/null +++ b/docs/RELEASE_1_43_0_MODULE_NAVIGATION_LISTING_STUDIO_UPDATER_UI.md @@ -0,0 +1,12 @@ +# Vendoo 1.43.0 + +## Schwerpunkt + +- Modulstatus und Navigation sind verbindlich gekoppelt. +- Das optionale Produktbild Studio ist standardmäßig deaktiviert. +- Der Listing-Generator besitzt eine dauerhaft sichtbare Aktionsleiste. +- `UPDATE_VENDOO.bat` startet eine grafische Statusanzeige mit Live-Protokoll. + +## Sicherheitsgrenzen + +Der Updater verändert weiterhin keine produktiven Daten unter `/app/data`, überträgt keine `.env`-Dateien und meldet erst nach erfolgreicher `/readyz`-Zielversionsprüfung Erfolg. diff --git a/docs/RESTORE_RUNBOOK_1_42_0.md b/docs/RESTORE_RUNBOOK_1_42_0.md new file mode 100644 index 0000000..de63129 --- /dev/null +++ b/docs/RESTORE_RUNBOOK_1_42_0.md @@ -0,0 +1,44 @@ +# Vendoo 1.42.0 – Restore- und Notfall-Runbook + +## Grundsatz + +Ein Restore ist keine normale Weboperation. Das laufende Vendoo darf seine geöffnete SQLite-Datenbank nicht im Hintergrund überschreiben. Die Weboberfläche prüft und staged ein Backup; das tatsächliche Einspielen erfolgt im Wartungsfenster bei gestoppter Anwendung. + +## Restore-Dry-Run + +1. **Administration → System → Backup & Restore** öffnen. +2. Gewünschtes Backup zuerst mit **Prüfen** validieren. +3. SHA-256, Manifest, Dateigröße und SQLite-Integrität müssen stimmen. +4. Für ein extern bereitgestelltes ZIP **Wiederherstellung prüfen und vorbereiten** verwenden. +5. Vendoo erstellt vor dem Staging automatisch ein zusätzliches Sicherheitsbackup. +6. Fehlerhafte, unvollständige oder verbotene Pakete nicht manuell entpacken. + +## Wartungsrestore + +1. Wartungsfenster ankündigen und Benutzer abmelden. +2. Coolify-Anwendung stoppen; keine zweite Instanz starten. +3. Vollständiges Volume `/app/data` zusätzlich auf Host-/NAS-Ebene sichern. +4. Besonders zusammen sichern: + - `/app/data/db/vendoo.db` samt möglicher `-wal`/`-shm`-Dateien + - `/app/data/uploads` + - `/app/data/config/master.key` + - `/app/data/config/secrets.enc.json` + - `/app/data/config/vendoo.env` + - `/app/data/config/modules-state.json` +5. Nur das verifizierte, vorbereitete Restore anwenden. +6. Eigentümer und Schreibrechte des nicht privilegierten Containers erhalten. +7. Anwendung mit exakt einer Instanz starten. +8. `/readyz`, SQLite-Integrität, Login, Benutzer, Artikel, Uploads, Module und Audit prüfen. + +## Reiner Anwendungsrollback + +Bei fehlerhaftem Code-Release normalerweise keinen Datenrestore durchführen. In Coolify die vorherige Revision beziehungsweise das vorherige Image redeployen und dasselbe `/app/data`-Volume verwenden. + +## Verboten + +- `/app/data` löschen +- neues leeres Volume anhängen +- zwei schreibende Container auf demselben SQLite-Volume starten +- die Datenbank bei laufendem Vendoo ersetzen +- `master.key` neu erzeugen, solange bestehende verschlüsselte Secrets benötigt werden +- ungeprüfte ZIP-Dateien direkt in das Volume kopieren diff --git a/docs/UPDATER_HOTFIX_1_43_0_H1.md b/docs/UPDATER_HOTFIX_1_43_0_H1.md new file mode 100644 index 0000000..2cb70ef --- /dev/null +++ b/docs/UPDATER_HOTFIX_1_43_0_H1.md @@ -0,0 +1,14 @@ +# Vendoo 1.43.0 – Updater-Hotfix H1 + +## Behoben + +- Endloses Warten direkt nach „Node.js LTS gefunden“. +- Falsche Erkennung des Windows-App-Execution-Alias als echte Node.js-Installation. +- Node.js-Versionsprüfung besitzt jetzt ein hartes Timeout von 20 Sekunden. +- Eine blockierte oder defekte Node.js-LTS-Installation wird über Winget automatisch repariert. +- Der grafische Updater zeigt Laufzeit und Zeit seit der letzten Statusmeldung. +- PowerShell- und Prozessausgabe verwenden konsistent UTF-8; deutsche Umlaute werden korrekt dargestellt. + +## Sicherheitsgrenze + +Bis zum Abschluss der lokalen Prüfungen wird weder nach `main` gemergt noch ein Coolify-Deployment gestartet. diff --git a/docs/UPDATER_HOTFIX_1_43_0_H2.md b/docs/UPDATER_HOTFIX_1_43_0_H2.md new file mode 100644 index 0000000..0bd34be --- /dev/null +++ b/docs/UPDATER_HOTFIX_1_43_0_H2.md @@ -0,0 +1,16 @@ +# Vendoo 1.43.0 – Updater Hotfix H2 + +## Behobener Fehler + +Nach einer erfolgreichen Erstinstallation der GitHub CLI brach der Ein-Klick-Updater mit der Aufforderung ab, `UPDATE_VENDOO.bat` erneut zu starten. Ursache war, dass die laufende Windows-PowerShell-Sitzung den neu installierten Pfad noch nicht zuverlässig aufgelöst hatte. + +## Neue Funktionsweise + +- Der Updater aktualisiert den Prozess-PATH nach der Winget-Installation. +- Er wartet kontrolliert bis zu 30 Sekunden auf die Registrierung der Programmdatei. +- Zusätzlich werden die offiziellen Installationspfade der GitHub CLI direkt geprüft. +- Gefundene Programme werden über ihren absoluten Pfad ausgeführt. +- Nach der Installation läuft derselbe Updatevorgang ohne zweiten Doppelklick weiter. +- Ein echter Abbruch erfolgt nur, wenn die installierte Programmdatei auch über die bekannten Pfade nicht auffindbar ist. + +Produktcode und Datenbankschema bleiben gegenüber Vendoo 1.43.0 unverändert. diff --git a/lib/db.mjs b/lib/db.mjs index 4529299..67db472 100644 --- a/lib/db.mjs +++ b/lib/db.mjs @@ -1550,3 +1550,84 @@ try { db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('operations-center-1.30.0', '1.30.0', 'Backup, Restore, Update Center und Extension-Diagnose')`).run(); } catch {} + + +// --- Production Operations & Controlled Updates 1.42.0 --- +try { + db.exec(`CREATE TABLE IF NOT EXISTS deployment_runs ( + id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + state TEXT NOT NULL DEFAULT 'checking', + provider TEXT NOT NULL DEFAULT 'coolify', + trigger_mode TEXT NOT NULL DEFAULT 'webhook', + current_version TEXT NOT NULL, + target_version TEXT NOT NULL, + build_revision_before TEXT, + build_revision_after TEXT, + deployment_id TEXT, + backup_id TEXT, + requested_by INTEGER, + reason TEXT, + github_repository TEXT, + github_branch TEXT, + request_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + error_code TEXT, + error_message TEXT, + timeout_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + FOREIGN KEY(requested_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE SET NULL + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_state ON deployment_runs(state, updated_at DESC)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_runs_created ON deployment_runs(created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS deployment_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + state TEXT NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(run_id) REFERENCES deployment_runs(id) ON DELETE CASCADE + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_deployment_events_run ON deployment_events(run_id, id ASC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS backup_verifications ( + id TEXT PRIMARY KEY, + backup_id TEXT NOT NULL, + deployment_run_id TEXT, + status TEXT NOT NULL, + sha256 TEXT, + file_size INTEGER NOT NULL DEFAULT 0, + sqlite_integrity TEXT, + manifest_json TEXT NOT NULL DEFAULT '{}', + errors_json TEXT NOT NULL DEFAULT '[]', + warnings_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + verified_at TEXT, + FOREIGN KEY(backup_id) REFERENCES operation_backups(id) ON DELETE CASCADE, + FOREIGN KEY(deployment_run_id) REFERENCES deployment_runs(id) ON DELETE SET NULL + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_backup_verifications_backup ON backup_verifications(backup_id, created_at DESC)`); + + db.exec(`CREATE TABLE IF NOT EXISTS production_checks ( + id TEXT PRIMARY KEY, + overall_status TEXT NOT NULL, + passed_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + results_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER, + FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL + )`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_production_checks_created ON production_checks(created_at DESC)`); + + db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) + VALUES ('production-operations-1.42.0', '1.42.0', 'Persistente Deployment-State-Machine, Deployment-Events, Backup-Verifikation und Produktionschecks')`).run(); +} catch {} diff --git a/lib/operations-center.mjs b/lib/operations-center.mjs index bf22929..6a0e345 100644 --- a/lib/operations-center.mjs +++ b/lib/operations-center.mjs @@ -330,20 +330,33 @@ export function cancelPendingOperation() { return { cancelled: true, pending }; } -export async function checkForUpdates() { +export async function checkForUpdates({ channel = 'stable' } = {}) { const settings = getOperationSettings(); - const url = String(settings.update_manifest_url || '').trim(); - if (!url) return { configured: false, current_version: APP_VERSION, message: 'Keine Update-Manifest-URL konfiguriert.' }; - if (!/^https?:\/\//i.test(url)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.'); + const rawUrl = String(settings.update_manifest_url || '').trim(); + if (!rawUrl) return { configured: false, current_version: APP_VERSION, channel, message: 'Keine Update-Manifest-URL konfiguriert.' }; + let url; + try { url = new URL(rawUrl); } catch { throw new Error('Update-Manifest-URL ist ungültig.'); } + if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Update-Manifest muss über HTTP oder HTTPS erreichbar sein.'); + if (url.username || url.password || url.hash) throw new Error('Update-Manifest-URL darf keine Zugangsdaten oder Fragmente enthalten.'); + if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') throw new Error('Update-Manifest muss in Produktion HTTPS verwenden.'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { - const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } }); + const response = await fetch(url, { + signal: controller.signal, + redirect: 'error', + headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` }, + }); if (!response.ok) throw new Error(`Update-Manifest antwortet mit HTTP ${response.status}.`); - const manifest = await response.json(); + const contentLength = Number(response.headers.get('content-length') || 0); + if (contentLength > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.'); + const text = await response.text(); + if (Buffer.byteLength(text, 'utf8') > 256 * 1024) throw new Error('Update-Manifest ist größer als 256 KB.'); + let manifest; + try { manifest = JSON.parse(text); } catch { throw new Error('Update-Manifest enthält kein gültiges JSON.'); } const latest = String(manifest.version || manifest.latest_version || ''); if (!latest) throw new Error('Update-Manifest enthält keine Version.'); - return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, manifest }; + return { configured: true, current_version: APP_VERSION, latest_version: latest, update_available: compareVersions(latest, APP_VERSION) > 0, channel, manifest }; } finally { clearTimeout(timeout); } } diff --git a/package-lock.json b/package-lock.json index 23b8707..90a9a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vendoo", - "version": "1.41.2", + "version": "1.43.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vendoo", - "version": "1.41.2", + "version": "1.43.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 4bb9f73..5909170 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,49 @@ { "name": "vendoo", - "version": "1.41.2", + "version": "1.43.0", "type": "module", "private": true, "scripts": { "start": "node bootstrap.mjs", "dev": "node --watch bootstrap.mjs", - "check": "node tools/check-syntax.mjs && python -m py_compile tools/verify-identity-db-1.40.0.py", + "check": "node tools/check-syntax.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs", "verify:slice30": "node tools/verify-slice30-mock.mjs", - "verify:db": "python tools/verify-db-migrations.py", + "verify:db": "node --no-warnings tools/verify-db-migrations.mjs", "verify:slice31": "node tools/verify-slice31-image-editor.mjs", "verify:hotfix": "node tools/verify-release-1.25.1.mjs", "verify:gallery": "node tools/verify-release-1.25.1-static.mjs", - "verify:release": "node tools/verify-deployment-1.41.2.mjs", + "verify:release": "npm run verify:production-operations", "verify:ui-contracts": "node tools/verify-ui-contracts-1.26.1.mjs", - "verify:deployment": "node tools/verify-deployment-1.41.2.mjs", + "verify:deployment": "npm run verify:production-operations", "verify:git": "node tools/verify-git-safety.mjs", "verify:config": "node tools/verify-config-store.mjs", - "verify:installer-shell": "sh tools/verify-installer-shell.sh", + "verify:installer-shell": "node tools/verify-installer-shell.mjs", "verify:git-regression": "node tools/verify-git-safety-regression.mjs", "verify:git-staging": "node tools/verify-git-staging.mjs", "generate:tokens": "node tools/generate-design-tokens.mjs", "verify:architecture": "node tools/verify-platform-architecture-1.41.2.mjs", - "verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:ignore-boundaries && npm run check && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:catalog && npm run verify:modules && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db", + "verify:all": "npm run verify:git && npm run verify:git-regression && npm run verify:git-staging && npm run verify:release-manifest && npm run verify:ignore-boundaries && npm run check && npm run verify:powershell-static && npm run verify:source-boundaries && npm run verify:esm-exports && npm run verify:flux-loop && npm run verify:capabilities && npm run verify:coolify && npm run verify:initial-modules && npm run verify:architecture && npm run verify:themes && npm run verify:security && npm run verify:identity && npm run verify:invitations && npm run verify:sqlite-cleanup && npm run verify:catalog && npm run verify:modules && npm run verify:production-operations && npm run verify:one-click-update && npm run verify:module-navigation && npm run verify:updater-ui && npm run verify:updater-hotfix && npm run verify:deployment && npm run verify:config && npm run verify:installer-shell && npm run verify:db", "verify:themes": "node tools/verify-theme-manager-1.41.2.mjs", "verify:security": "node tools/verify-security-foundation-1.41.2.mjs", - "verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && python tools/verify-identity-db-1.40.0.py", + "verify:identity": "node tools/verify-identity-settings-1.41.2.mjs && node --no-warnings tools/verify-identity-db-1.40.0.mjs", "verify:catalog": "node tools/verify-catalog-media-1.41.2.mjs && node tools/verify-catalog-services-1.41.2.mjs", "verify:modules": "node tools/verify-module-manager-1.41.2.mjs", "verify:esm-exports": "node tools/verify-esm-export-contracts.mjs", "verify:flux-loop": "node tools/verify-flux-loop-hotfix-1.41.2.mjs", "verify:capabilities": "node tools/verify-capability-graph-1.41.2.mjs", - "verify:coolify": "node tools/verify-coolify-deployment-1.41.2.mjs", - "verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs", + "verify:coolify": "npm run verify:production-operations", + "verify:initial-modules": "node tools/verify-initial-disabled-modules-1.41.2.mjs && node tools/verify-initial-module-cleanup-regression-1.43.0.mjs", "verify:source-boundaries": "node tools/verify-source-boundaries-1.41.2.mjs", - "verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs" + "verify:ignore-boundaries": "node tools/verify-git-ignore-boundaries-1.41.2.mjs", + "verify:production-operations": "node tools/verify-production-operations-1.42.0.mjs && node --no-warnings tools/verify-production-operations-db-1.42.0.mjs", + "verify:invitations": "node tools/verify-controlled-invitations-1.42.0.mjs", + "verify:one-click-update": "node tools/verify-production-updater-3.3.mjs", + "verify:module-navigation": "node tools/verify-module-navigation-generator-1.43.0.mjs", + "verify:updater-ui": "node tools/verify-production-updater-3.3.mjs", + "verify:updater-hotfix": "node tools/verify-production-updater-3.3.mjs", + "verify:powershell-static": "node tools/verify-powershell-static.mjs", + "verify:sqlite-cleanup": "node tools/verify-sqlite-cleanup-contract-1.43.0.mjs && node tools/verify-windows-sqlite-cleanup-regression-1.43.0.mjs", + "verify:release-manifest": "node tools/verify-release-manifest-staging-3.0.mjs" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/public/app.js b/public/app.js index d9840bd..b17ac5e 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,4 @@ -const VENDOO_UI_BUILD = '1.41.2'; +const VENDOO_UI_BUILD = '1.43.0'; const state = { photos: [], @@ -282,6 +282,44 @@ function setupResponsiveContracts() { } } +const moduleUiState = new Map(); + +function isUiModuleEnabled(id) { + return !id || moduleUiState.get(id) !== false; +} + +function applyModuleVisibility(root = document) { + root.querySelectorAll?.('[data-module], [data-module-panel]').forEach(element => { + const moduleId = element.dataset.module || element.dataset.modulePanel; + const visible = isUiModuleEnabled(moduleId); + element.classList.toggle('hidden', !visible); + element.setAttribute('aria-hidden', visible ? 'false' : 'true'); + if ('disabled' in element && !visible) element.disabled = true; + }); +} + +function applyModuleNavigation(payload = {}) { + moduleUiState.clear(); + for (const module of payload.modules || []) moduleUiState.set(module.id, module.enabled === true); + applyModuleVisibility(document); + const active = document.querySelector('.tab-content.active'); + const activeButton = active ? document.querySelector(`.nav-item[data-tab="${active.id}"]`) : null; + if (activeButton?.classList.contains('hidden')) switchToTab('dashboard'); +} + +async function refreshModuleNavigation() { + try { + const payload = await api('GET', '/api/modules/navigation', null, { retry: false, timeoutMs: 10000 }); + applyModuleNavigation(payload); + return payload; + } catch (error) { + console.warn('[Vendoo] Modulnavigation konnte nicht geladen werden:', error.message); + return null; + } +} + +window.addEventListener('vendoo:modules-changed', () => refreshModuleNavigation()); + async function init() { document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD; console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`); @@ -295,10 +333,16 @@ async function init() { if (meData.setupMode) { window.location.href = '/login.html'; return; } } catch {} + // Modulstatus immer zuerst laden: Ein deaktiviertes Modul darf weder seine + // Navigation anzeigen noch den App-Start durch eine gesperrte API blockieren. + const moduleNavigation = await api('GET', '/api/modules/navigation'); + applyModuleNavigation(moduleNavigation); + const aiEnabled = isUiModuleEnabled('vendoo.ai'); + const [platforms, settings, providers, templates] = await Promise.all([ api('GET', '/api/platforms'), api('GET', '/api/settings'), - api('GET', '/api/providers'), + aiEnabled ? api('GET', '/api/providers') : Promise.resolve([]), api('GET', '/api/templates'), ]); @@ -1704,6 +1748,12 @@ function closeMobileSidebar({ restoreFocus = false } = {}) { } function switchToTab(tabId, adminTab) { + const requestedNav = document.querySelector(`.nav-item[data-tab="${tabId}"]`); + if (requestedNav?.classList.contains('hidden')) { + toast('Dieses Modul ist derzeit deaktiviert.'); + tabId = 'dashboard'; + adminTab = null; + } if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true }); document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); @@ -2464,7 +2514,8 @@ function showListing(listing, platformHtml) { document.getElementById('result-tags').value = tags; const plat = state.platforms.find(p => p.id === state.platform); document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags'; - document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen'; + const saveButton = document.getElementById('save-btn'); + if (saveButton) { saveButton.textContent = listing.id ? 'Änderungen speichern' : 'Artikel speichern'; saveButton.disabled = false; } document.getElementById('generator-preview-empty')?.classList.add('hidden'); document.getElementById('result-section').classList.remove('hidden'); const previewStatus = document.getElementById('generator-preview-status'); @@ -2568,7 +2619,8 @@ function resetGenerator() { document.getElementById('meta-section').classList.add('hidden'); document.getElementById('variants-section').classList.add('hidden'); document.getElementById('generator-preview-empty')?.classList.remove('hidden'); - document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen'; + const saveButton = document.getElementById('save-btn'); + if (saveButton) { saveButton.textContent = 'Speichern'; saveButton.disabled = true; } document.getElementById('generate-btn').disabled = true; renderAiModelGrid('generator', []); setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral'); @@ -3397,13 +3449,13 @@ async function showDetail(id) {

Vorschaubilder ziehen, um die Reihenfolge zu ändern. Das erste Bild ist das Cover.

-
+