Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e70bb9b3e5 | ||
|
|
a694d3f50e | ||
|
|
1c9209d2c9 | ||
|
|
8cf2600783 | ||
|
|
c44b29c878 | ||
|
|
ea0c72936e | ||
|
|
7b04856217 | ||
|
|
32bd4e9cf1 | ||
|
|
a3241fd595 | ||
|
|
f495c534a1 | ||
|
|
5c2411d509 | ||
|
|
bd91c6b45a | ||
|
|
6b821cb09d | ||
|
|
1533f56a13 | ||
|
|
b4d0c51327 | ||
|
|
b0a80c0521 | ||
|
|
34936e840f | ||
|
|
cff8c53894 | ||
|
|
0b9f305e89 | ||
|
|
9688258707 | ||
|
|
bbecc829e7 | ||
|
|
2ecc6379d3 | ||
|
|
f00dbb66dd | ||
|
|
76123e3760 | ||
|
|
d4943fe108 | ||
|
|
2aa304e3d9 | ||
|
|
fd478f3c34 | ||
|
|
e7fe38be7b | ||
|
|
737d792860 | ||
|
|
d1f16a4c5c | ||
|
|
ffecf045db | ||
|
|
867cc6c2c0 | ||
|
|
2362c15477 | ||
|
|
f52e6f85eb | ||
|
|
8a6e13c86e | ||
|
|
33aaaaea1e | ||
|
|
1fb62916bc | ||
|
|
5a925498a5 | ||
|
|
65f94d041a | ||
|
|
ae0cfb7c59 | ||
|
|
ae5821a505 | ||
|
|
bcebb0e6e4 | ||
|
|
958a5877ed | ||
|
|
75a997843d | ||
|
|
e6f44fbd84 | ||
|
|
e40a218779 | ||
|
|
da4b4dd567 | ||
|
|
b6ee6f9aab | ||
|
|
4157afa2f3 | ||
|
|
18b334a20c | ||
|
|
1308d6cefd | ||
|
|
75d811ef19 | ||
|
|
80b024bc93 | ||
|
|
05a90aea21 | ||
|
|
d0a2a560a1 | ||
|
|
0b98ecd4a4 | ||
|
|
c8bc69968f | ||
|
|
537af2509c | ||
|
|
196919c5e5 | ||
|
|
bce6536a1c | ||
|
|
f6f612f429 | ||
|
|
d36c0c704b | ||
|
|
7393aa2cbc | ||
|
|
3fb4db14f6 | ||
|
|
df6fc77781 | ||
|
|
d8bdefc7fd | ||
|
|
e661bc902f | ||
|
|
f24b1ae217 | ||
|
|
fb9d4aa923 | ||
|
|
87ed676c2f | ||
|
|
dd9c62e989 | ||
|
|
14f1a0fa20 | ||
|
|
3818cee11f | ||
|
|
a2af2b3f3e | ||
|
|
cb05237247 | ||
|
|
975451bb93 | ||
|
|
2f75ed44cc | ||
|
|
baef1a51af | ||
|
|
ce2304cc52 | ||
|
|
d9d6f9e91a | ||
|
|
01c635bca4 | ||
|
|
ac06adfca2 | ||
|
|
6d997a95cc | ||
|
|
d6b65ee659 | ||
|
|
07d47ce952 | ||
|
|
a04127d832 | ||
|
|
120039a163 | ||
|
|
aba86b1b2a | ||
|
|
27a8eba16e | ||
|
|
1d16ec0673 | ||
|
|
344b18c07c | ||
|
|
8d3ca950dd | ||
|
|
e7560dcf69 | ||
|
|
03fe917305 | ||
|
|
3604f83c93 | ||
|
|
25254e37d6 | ||
|
|
40071f718e | ||
|
|
6f48827f4d |
@@ -0,0 +1,27 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
*.log
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backups
|
||||
operations
|
||||
logs
|
||||
uploads
|
||||
runtime
|
||||
data
|
||||
local-ai/comfyui
|
||||
local-ai/downloads
|
||||
local-ai/.env.local-flux
|
||||
MockupDesign
|
||||
*.zip
|
||||
FILE_CHECKSUMS_SHA256.txt
|
||||
release-output
|
||||
.vendoo-github-state.json
|
||||
operations/first-admin-code.txt
|
||||
@@ -0,0 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{bat,cmd}]
|
||||
charset = latin1
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
@@ -1,33 +1,109 @@
|
||||
NODE_ENV=development
|
||||
HOST=127.0.0.1
|
||||
PORT=8124
|
||||
PUBLIC_BASE_URL=http://localhost:8124
|
||||
TRUST_PROXY=0
|
||||
COOKIE_SECURE=false
|
||||
COOKIE_SAMESITE=lax
|
||||
ALLOWED_ORIGINS=http://localhost:8124
|
||||
# Vendoo 1.37.0 – Beispielkonfiguration für Windows, Docker und NAS
|
||||
# Diese Datei enthält ausschließlich Platzhalter. Für den Betrieb als .env kopieren.
|
||||
# Niemals eine produktive .env in Release-ZIPs, Git oder Support-Logs aufnehmen.
|
||||
|
||||
DATA_DIR=./data
|
||||
DB_PATH=./db/vendoo.db
|
||||
UPLOAD_DIR=./uploads
|
||||
BACKUP_DIR=./backups
|
||||
|
||||
SESSION_SECRET=
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
LOCAL_IMAGE_ENABLED=false
|
||||
LOCAL_IMAGE_URL=http://127.0.0.1:8188
|
||||
LOCAL_IMAGE_TOKEN=
|
||||
LOCAL_IMAGE_WORKFLOW_PATH=local-ai/workflows/vendoo_flux_schnell_api.json
|
||||
LOCAL_IMAGE_INSTALL_PATH=local-ai/comfyui
|
||||
LOCAL_IMAGE_REQUIRE_TOKEN=true
|
||||
LOCAL_IMAGE_ALLOW_REMOTE=false
|
||||
|
||||
ETSY_API_KEY=
|
||||
ETSY_CLIENT_SECRET=
|
||||
|
||||
EBAY_CLIENT_ID=
|
||||
EBAY_CLIENT_SECRET=
|
||||
EBAY_RUNAME=
|
||||
EBAY_RU_NAME=
|
||||
EBAY_SANDBOX=true
|
||||
|
||||
SMTP_HOST=smtp.example.de
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=Vendoo <vendoo@example.de>
|
||||
|
||||
PORT=8124
|
||||
|
||||
# Docker veröffentlicht standardmäßig nur an 127.0.0.1. Für NAS-Reverse-Proxy ggf. bewusst anpassen.
|
||||
VENDOO_PUBLISH_ADDRESS=127.0.0.1
|
||||
VENDOO_HOST_PORT=8124
|
||||
|
||||
# Vom Smartphone erreichbare Basis-URL für QR-Fotouploads.
|
||||
# Beispiel im LAN: http://192.168.178.50:8124
|
||||
# Öffentlich ausschließlich per HTTPS verwenden.
|
||||
PUBLIC_BASE_URL=
|
||||
# Nur für den geführten VPS-Modus; setup.sh trägt die Domain automatisch ein.
|
||||
VENDOO_DOMAIN=
|
||||
|
||||
# Optional: öffentliche JSON-Manifest-URL für die Updateprüfung
|
||||
VENDOO_UPDATE_MANIFEST_URL=
|
||||
# Server-, Mehrbenutzer- und Deployment-Sicherheit (1.37.0)
|
||||
# Standardmäßig nur auf diesem PC erreichbar. Für LAN bewusst auf 0.0.0.0 setzen.
|
||||
VENDOO_BIND_HOST=127.0.0.1
|
||||
# Kommagetrennte zusätzliche Hostnamen/IPs, z. B. vendoo.intern,192.168.178.50
|
||||
VENDOO_ALLOWED_HOSTS=
|
||||
# Kommagetrennte zusätzliche Browser-Ursprünge, jeweils mit Protokoll und Port
|
||||
VENDOO_ALLOWED_ORIGINS=
|
||||
# Nur hinter einem korrekt konfigurierten Reverse Proxy aktivieren
|
||||
VENDOO_TRUST_PROXY=false
|
||||
VENDOO_SESSION_DAYS=3
|
||||
VENDOO_SESSION_IDLE_MINUTES=120
|
||||
VENDOO_READ_RATE_LIMIT=360
|
||||
VENDOO_MUTATION_RATE_LIMIT=120
|
||||
|
||||
|
||||
# Produktionsbereitstellung 1.37.0
|
||||
# Mindestens 32 zufällige Zeichen. Wird beim ersten Admin-Setup abgefragt.
|
||||
VENDOO_SETUP_TOKEN=
|
||||
VENDOO_REQUIRE_SETUP_TOKEN=true
|
||||
VENDOO_COOKIE_SECURE=auto
|
||||
VENDOO_COOKIE_SAMESITE=lax
|
||||
# Konkrete Extension-Origins, z. B. chrome-extension://abcdefghijklmnop...
|
||||
VENDOO_EXTENSION_ORIGINS=
|
||||
VENDOO_ALLOW_ANY_EXTENSION_ORIGIN=false
|
||||
|
||||
# Optionale getrennte Laufzeitpfade. Windows kann die Standardwerte leer lassen.
|
||||
VENDOO_DATA_DIR=
|
||||
VENDOO_DB_PATH=
|
||||
VENDOO_UPLOADS_DIR=
|
||||
VENDOO_BACKUP_DIR=
|
||||
VENDOO_OPERATIONS_DIR=
|
||||
VENDOO_LOG_DIR=
|
||||
# Docker setzt automatisch /app/data/config/vendoo.env; lokal bleibt dieser Wert leer.
|
||||
VENDOO_CONFIG_PATH=
|
||||
|
||||
# Security, Secrets & Observability (1.37.0)
|
||||
# Normalerweise automatisch verwaltet; keine manuelle Bearbeitung erforderlich.
|
||||
# VENDOO_SECRETS_PATH=/app/data/config/secrets.enc.json
|
||||
# VENDOO_MASTER_KEY_FILE=/app/data/config/master.key
|
||||
# Optional: externer 32-Byte-Master-Key als Base64 oder 64 Hex-Zeichen.
|
||||
# VENDOO_MASTER_KEY=
|
||||
VENDOO_JSON_LOGS=true
|
||||
SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# Coolify / GitHub production deployment
|
||||
VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio
|
||||
VENDOO_DEPLOY_PROVIDER=none
|
||||
COOLIFY_API_URL=
|
||||
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
|
||||
VENDOO_UPDATE_CHANNEL=stable
|
||||
VENDOO_BACKUP_BEFORE_DEPLOY=true
|
||||
VENDOO_DEPLOY_BACKUP_UPLOADS=false
|
||||
# COOLIFY_API_TOKEN and COOLIFY_DEPLOY_WEBHOOK_URL belong in the encrypted secret store.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
@@ -0,0 +1 @@
|
||||
* @Masterluke77
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: '05:00'
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
time: '05:30'
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
time: '06:00'
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 5
|
||||
@@ -0,0 +1,20 @@
|
||||
## Änderung
|
||||
|
||||
<!-- Was wurde geändert und warum? -->
|
||||
|
||||
## Sicherheit und Daten
|
||||
|
||||
- [ ] Keine `.env`, Tokens, Datenbanken, Uploads, Backups, Logs oder Modelle enthalten
|
||||
- [ ] Bestehende Benutzer- und Artikeldaten bleiben erhalten
|
||||
- [ ] Originalbilder werden nicht überschrieben
|
||||
- [ ] FASHN VTON / Virtual Try-on wurde nicht wieder eingeführt
|
||||
|
||||
## Prüfung
|
||||
|
||||
- [ ] `npm ci`
|
||||
- [ ] `npm run verify:git`
|
||||
- [ ] `npm run check`
|
||||
- [ ] `npm run verify:architecture`
|
||||
- [ ] `npm run verify:deployment`
|
||||
- [ ] `npm run verify:db`
|
||||
- [ ] Windows-/Docker-/Browser-Abnahme dokumentiert oder ausdrücklich als offen markiert
|
||||
@@ -0,0 +1,238 @@
|
||||
name: Vendoo CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vendoo-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Syntax, Sicherheit und Release-Gates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
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: 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 und Paketvertrag prüfen
|
||||
run: npm run verify:release-manifest
|
||||
|
||||
- name: Git-Ignore-Grenzen für Quell- und Runtimeordner prüfen
|
||||
run: npm run verify:ignore-boundaries
|
||||
|
||||
- name: Syntaxprüfung
|
||||
run: npm run check
|
||||
|
||||
- name: PowerShell-Regressionen statisch prüfen
|
||||
run: npm run verify:powershell-static
|
||||
|
||||
- name: Updater-3.4-Lifecycle und Startkette prüfen
|
||||
run: npm run verify:updater-start-chain && npm run verify:updater-lifecycle
|
||||
|
||||
- name: Produktiven Source-Root und Archivgrenzen 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: Vollständigen Modul-Capability-Graph prüfen
|
||||
run: npm run verify:capabilities
|
||||
|
||||
- name: Coolify-Deployment und Web-Updates prüfen
|
||||
run: npm run verify:coolify
|
||||
|
||||
- name: Initial deaktivierte Module prüfen
|
||||
run: npm run verify:initial-modules
|
||||
|
||||
- name: Plattformarchitektur und Design Tokens prüfen
|
||||
run: npm run verify:architecture
|
||||
|
||||
- name: Theme Manager, Accessibility und Komponenten-Tokens prüfen
|
||||
run: npm run verify:themes
|
||||
|
||||
- name: Security, Secrets und Observability prüfen
|
||||
run: npm run verify:security
|
||||
|
||||
- name: Auth, Benutzer, Sitzungen und Einstellungen prüfen
|
||||
run: npm run verify:identity
|
||||
|
||||
- name: Artikel, Lager und Medien prüfen
|
||||
run: npm run verify:catalog
|
||||
|
||||
- name: Modulmanager, Pakete und Aktivierungsgrenzen prüfen
|
||||
run: npm run verify:modules
|
||||
|
||||
- name: Deployment-Gate
|
||||
run: npm run verify:deployment
|
||||
|
||||
- name: Persistenten Konfigurationsspeicher prüfen
|
||||
run: npm run verify:config
|
||||
|
||||
- name: Geführte Shell-Installer simulieren
|
||||
run: npm run verify:installer-shell
|
||||
|
||||
- name: Datenbankmigrationen prüfen
|
||||
run: npm run verify:db
|
||||
|
||||
- name: Docker-Compose-Dateien validieren
|
||||
run: |
|
||||
cp .env.example .env
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
p=Path('.env')
|
||||
s=p.read_text()
|
||||
s=s.replace('VENDOO_SETUP_TOKEN=', 'VENDOO_SETUP_TOKEN=ci-only-not-a-production-secret-1234567890')
|
||||
s=s.replace('VENDOO_DOMAIN=', 'VENDOO_DOMAIN=vendoo.example.invalid')
|
||||
p.write_text(s)
|
||||
PY
|
||||
docker compose -f compose.yaml config -q
|
||||
docker compose -f compose.yaml -f compose.bind-mounts.example.yaml config -q
|
||||
docker compose -f compose.vps.yaml config -q
|
||||
rm -f .env
|
||||
|
||||
- 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 -ReportPath "$env:RUNNER_TEMP\powershell-parser-diagnostics.txt"
|
||||
|
||||
- name: Parserdiagnose bei Fehler sichern
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: powershell-parser-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: ${{ runner.temp }}/powershell-parser-diagnostics.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Vollständigen Vendoo-Releasevertrag unter Windows ausführen
|
||||
shell: pwsh
|
||||
run: npm run verify:all
|
||||
|
||||
package-after-acceptance:
|
||||
name: Updater-3.4-Paket nach Abnahme
|
||||
needs: [quality, windows-release-contract]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Exakten geprüften Head auschecken
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
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: main für den Provenienzvergleich aktualisieren
|
||||
run: git fetch origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Reproduzierbares Root-Manifest bestätigen
|
||||
shell: bash
|
||||
run: |
|
||||
node tools/generate-release-manifest.mjs .
|
||||
git diff --exit-code -- release-manifest.json
|
||||
|
||||
- name: Paket mit unveränderlicher Provenienz erzeugen
|
||||
env:
|
||||
VENDOO_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: node tools/build-release.mjs
|
||||
|
||||
- name: Paketmanifest und SHA-256 prüfen
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_name="$(tr -d '\r\n' < release-output/RELEASE_NAME.txt)"
|
||||
package_dir="release-output/${release_name}"
|
||||
node --input-type=module - "$package_dir" <<'NODE'
|
||||
import { readAndValidateManifest } from './tools/release-manifest-lib.mjs';
|
||||
const packageDir = process.argv[2];
|
||||
const manifest = readAndValidateManifest(packageDir);
|
||||
if (manifest.schema !== 'vendoo-release-package-v2') throw new Error(`Unerwartetes Paketschema: ${manifest.schema}`);
|
||||
if (manifest.updaterVersion !== '3.4.0') throw new Error(`Unerwartete Updater-Version: ${manifest.updaterVersion}`);
|
||||
if (manifest.targetVersion !== '1.43.0') throw new Error(`Unerwartete Zielversion: ${manifest.targetVersion}`);
|
||||
console.log(`Paket validiert: ${manifest.targetVersion}, Updater ${manifest.updaterVersion}, Payload ${manifest.payloadHash}`);
|
||||
NODE
|
||||
archive="release-output/${release_name}.zip"
|
||||
python tools/build-deterministic-zip.py "$package_dir" "$archive"
|
||||
first_hash="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
python tools/build-deterministic-zip.py "$package_dir" "$archive"
|
||||
second_hash="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
if [[ "$first_hash" != "$second_hash" ]]; then
|
||||
echo "Der ZIP-Build ist nicht byte-reproduzierbar." >&2
|
||||
exit 1
|
||||
fi
|
||||
python -m zipfile -t "$archive"
|
||||
(
|
||||
cd release-output
|
||||
sha256sum "${release_name}.zip" > "${release_name}.zip.sha256"
|
||||
sha256sum --check "${release_name}.zip.sha256"
|
||||
)
|
||||
|
||||
- name: Abgenommenes Paket als CI-Artefakt bereitstellen
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendoo-1.43.0-updater-3.4-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
release-output/*.zip
|
||||
release-output/*.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,158 @@
|
||||
name: Vendoo Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: vendoo-release-${{ github.event.release.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Node.js 22 aktivieren
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Release-Tag gegen Paketversion prüfen
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
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: ESM-Exportverträge explizit prüfen
|
||||
run: npm run verify:esm-exports
|
||||
|
||||
- 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
|
||||
id: package
|
||||
shell: bash
|
||||
run: |
|
||||
PACKAGE_NAME="$(cat release-output/RELEASE_NAME.txt)"
|
||||
cd release-output
|
||||
zip -r -9 "${PACKAGE_NAME}.zip" "${PACKAGE_NAME}"
|
||||
sha256sum "${PACKAGE_NAME}.zip" > "${PACKAGE_NAME}.zip.sha256.txt"
|
||||
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Assets an GitHub Release anhängen
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
PACKAGE_NAME: ${{ steps.package.outputs.package_name }}
|
||||
run: |
|
||||
gh release upload "$TAG" \
|
||||
"release-output/${PACKAGE_NAME}.zip" \
|
||||
"release-output/${PACKAGE_NAME}.zip.sha256.txt" \
|
||||
--clobber
|
||||
|
||||
- name: Build-Artefakte sichern
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.package.outputs.package_name }}
|
||||
path: |
|
||||
release-output/${{ steps.package.outputs.package_name }}.zip
|
||||
release-output/${{ steps.package.outputs.package_name }}.zip.sha256.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
container:
|
||||
name: GHCR-Container veröffentlichen
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Repository auschecken
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker Buildx aktivieren
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Bei GHCR anmelden
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Container-Metadaten erzeugen
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=stable,enable=${{ !github.event.release.prerelease }}
|
||||
type=raw,value=latest,enable=${{ !github.event.release.prerelease }}
|
||||
|
||||
- name: Container bauen und veröffentlichen
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Container-Provenienz attestieren
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Vendoo Release Manifest Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/*-production-update-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: vendoo-release-manifest-sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Release-Manifest reproduzierbar synchronisieren
|
||||
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
|
||||
id: manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- release-manifest.json; then
|
||||
echo "Release-Manifest ist bereits aktuell."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
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}
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Finalen Manifest-Head explizit validieren
|
||||
if: ${{ steps.manifest.outputs.changed == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
final_head="${{ steps.manifest.outputs.head_sha }}"
|
||||
remote_head="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | awk '{print $1}')"
|
||||
if [[ -z "$remote_head" || "$remote_head" != "$final_head" ]]; then
|
||||
echo "Der gepushte Manifest-Head ist nicht stabil: lokal=$final_head remote=$remote_head" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
|
||||
gh workflow run sync-release-manifest.yml --repo "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME"
|
||||
echo "Vendoo CI und Manifest-Sync wurden für finalen Head $final_head explizit registriert."
|
||||
@@ -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 .
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
# Environment and secrets
|
||||
# Secrets and local configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -8,51 +8,89 @@
|
||||
*.p12
|
||||
*.pfx
|
||||
*.crt
|
||||
*.cer
|
||||
|
||||
# Node.js
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Databases and SQLite runtime files
|
||||
db/*.db
|
||||
db/*.db-shm
|
||||
db/*.db-wal
|
||||
db/*.sqlite
|
||||
db/*.sqlite3
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# SQLite runtime data
|
||||
/db/*.db
|
||||
/db/*.db-shm
|
||||
/db/*.db-wal
|
||||
/db/*.sqlite
|
||||
/db/*.sqlite3
|
||||
|
||||
# Runtime data
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
backups/*
|
||||
!backups/.gitkeep
|
||||
logs/
|
||||
# User and runtime data
|
||||
/uploads/*
|
||||
!/uploads/.gitkeep
|
||||
/backups/
|
||||
/logs/
|
||||
/tmp/
|
||||
/temp/
|
||||
/sessions/
|
||||
/data/
|
||||
/docker-data/
|
||||
*.log
|
||||
tmp/
|
||||
temp/
|
||||
sessions/
|
||||
data/
|
||||
docker-data/
|
||||
|
||||
# Generated files and local state
|
||||
coverage/
|
||||
dist/
|
||||
.cache/
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Editors and operating systems
|
||||
.vscode/
|
||||
.idea/
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Local Docker overrides
|
||||
# Local overrides
|
||||
compose.override.yaml
|
||||
docker-compose.override.yml
|
||||
|
||||
# Local AI runtimes and downloaded model data
|
||||
/local-ai/comfyui/
|
||||
/local-ai/downloads/
|
||||
/local-ai/extract-temp/
|
||||
/local-ai/fashn-vton/.venv/
|
||||
/local-ai/fashn-vton/engine/
|
||||
/local-ai/fashn-vton/weights/
|
||||
/local-ai/fashn-vton/outputs/
|
||||
/local-ai/fashn-vton/runtime/
|
||||
/local-ai/fashn-vton/hf-cache/
|
||||
/local-ai/fashn-vton/fashn-vton-*.zip
|
||||
/local-ai/fashn-vton/python311/
|
||||
/local-ai/fashn-vton/uv/
|
||||
/local-ai/fashn-vton/uv-cache/
|
||||
/local-ai/fashn-vton/downloads/
|
||||
|
||||
# Historical handover and feature-package archives (never source)
|
||||
/Archiv/
|
||||
/archive/
|
||||
/archives/
|
||||
|
||||
# Local update, rollback and slice archives
|
||||
/updates/
|
||||
|
||||
# Operations Center runtime
|
||||
/operations/staging/
|
||||
/operations/updates/
|
||||
/operations/rollback/
|
||||
/operations/apply-*/
|
||||
/operations/pending-operation.json
|
||||
/operations/last-operation.json
|
||||
|
||||
# Deployment assistant state
|
||||
/.vendoo-install-mode
|
||||
/runtime/
|
||||
# Generated deployment and release output
|
||||
/release-output/
|
||||
*.release.zip
|
||||
*.release.sha256.txt
|
||||
/.vendoo-github-state.json
|
||||
|
||||
# First-run and local installer state
|
||||
/operations/first-admin-code.txt
|
||||
/config/local/
|
||||
|
||||
|
||||
# Installed Vendoo module packages (runtime)
|
||||
/modules/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Vendoo 1.20.0 image-stack cleanup completed.
|
||||
@@ -0,0 +1,9 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
audit=false
|
||||
fund=false
|
||||
strict-ssl=true
|
||||
prefer-offline=true
|
||||
fetch-retries=5
|
||||
fetch-retry-factor=2
|
||||
fetch-retry-mintimeout=10000
|
||||
fetch-retry-maxtimeout=120000
|
||||
@@ -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`.
|
||||
@@ -1,21 +1,985 @@
|
||||
## 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
|
||||
|
||||
Alle wesentlichen Änderungen an Vendoo werden in dieser Datei dokumentiert.
|
||||
## 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.
|
||||
- `app/modules/` und `app/modules/sessions/` werden nicht mehr durch `modules/` beziehungsweise `sessions/` ausgeblendet.
|
||||
- Neues echtes Git-Regression-Gate prüft `git check-ignore`, `git add --all` und `git ls-files`.
|
||||
- GitHub-Deploy-Assistent blockiert den Push, wenn produktive Moduldateien fehlen, ignoriert oder nicht getrackt sind.
|
||||
- Keine Datenbankmigration und keine Änderung an Nutzdaten.
|
||||
|
||||
## 1.41.1 – CI Source Root & Archive Exclusion Hotfix
|
||||
|
||||
- ESM-Import-/Exportprüfung auf den produktiven Source-Root begrenzt.
|
||||
- Historische Ordner `Archiv/`, `archive/` und `archives/` aus Git-Staging, Release-Staging und Sicherheitsprüfung ausgeschlossen.
|
||||
- Vier produktive Listings-/Inventory-Dateien als Pflichtdateien abgesichert.
|
||||
- Neues Source-Root-Gate erkennt echte fehlende Module weiterhin, ignoriert aber archivierte Altstände.
|
||||
- GitHub CI und Release Workflow führen das neue Gate verpflichtend aus.
|
||||
|
||||
## 1.41.0 – Coolify Production Deployment & Controlled Web Updates
|
||||
|
||||
### Neu
|
||||
|
||||
- natives, geschütztes Modul `vendoo.deployments` für kontrollierte Produktionsdeployments.
|
||||
- Coolify-Unterstützung per Deploy-Webhook oder minimal berechtigtem API-Token.
|
||||
- Administratoroberfläche im Update Center für Provider, Repository, Branch, Kanal, Backup und Deploymentstatus.
|
||||
- verschlüsselte Secrets `COOLIFY_API_TOKEN` und `COOLIFY_DEPLOY_WEBHOOK_URL`.
|
||||
- Coolify-Produktionsvorlage `deploy/coolify.env.example` für `vendoo.flatlined.de`.
|
||||
- GitHub-/Merge-/Release-Anleitung und Coolify-Klickstrecke.
|
||||
- GitHub Release Workflow veröffentlicht GHCR-Tags `1.41.0`, `1.41`, `stable` und `latest` sowie SBOM und Provenienz.
|
||||
- 17 registrierte Module: 15 nativ, 2 Legacy Bridges.
|
||||
|
||||
### Betrieb und Sicherheit
|
||||
|
||||
- FLUX Studio wird beim ersten Coolify-Start über `VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio` deaktiviert.
|
||||
- persistente Administratorentscheidungen überschreiben den Initialwert bei späteren Starts.
|
||||
- Deploymentauftrag nur mit Berechtigung `updates.manage` und exakter Bestätigung `DEPLOY`.
|
||||
- vor einem Web-Deployment wird standardmäßig ein verifiziertes Backup erzeugt.
|
||||
- Vendoo führt weder `git pull` noch Docker-Kommandos aus und erhält keinen Docker-Socket.
|
||||
- Coolify-Endpunkte müssen im Produktionsmodus HTTPS verwenden.
|
||||
- keine Datenbankmigration und kein Reset bestehender Daten.
|
||||
|
||||
## 1.40.2 – Capability Graph Contract Hotfix
|
||||
|
||||
### Behoben
|
||||
|
||||
- `vendoo.module-manager` verlangte `settings.configuration`, obwohl `vendoo.settings` diese Capability nicht bereitstellte.
|
||||
- Der Plattformstart brach deshalb mit `MODULE_CAPABILITY_MISSING` ab.
|
||||
- `vendoo.settings` stellt nun den übergeordneten Konfigurationsvertrag zusätzlich zu `settings.application` und `settings.smtp` bereit.
|
||||
|
||||
### Qualitätssicherung
|
||||
|
||||
- neues Capability-Graph-Gate validiert den real aktivierten Gesamtgraph aller 16 Module.
|
||||
- `ModuleRegistry.validateGraph()` stellt Startreihenfolge und Capability-Menge ohne Lifecycle-Start für Tests bereit.
|
||||
- Architektur-, CI-, Release- und Deployment-Gates prüfen den realen Modulgraph statt ausschließlich künstlicher Testmodule.
|
||||
- keine Datenbankmigration, kein Reset und keine Änderung an Nutzdaten.
|
||||
|
||||
## 1.40.1 – FLUX Worker Lifecycle & ESM Export Contract Hotfix
|
||||
|
||||
### Behoben
|
||||
|
||||
- `server.mjs` konnte wegen des fehlenden Exports `stopFluxPromptJobLoop` nicht instanziiert werden.
|
||||
- FLUX-Prompt-Job-Worker besitzt jetzt einen idempotenten Stop-Lifecycle mit `clearInterval()` und Zustandsreset.
|
||||
|
||||
### Qualitätssicherung
|
||||
|
||||
- neues generisches ESM-Export-Vertragsgate für lokale benannte Imports.
|
||||
- separates FLUX-Loop-Hotfix-Gate in lokaler Prüfung, GitHub CI und Release-Workflow.
|
||||
- keine Datenbankmigration, kein Reset und keine Änderung an Nutzdaten.
|
||||
|
||||
## 1.40.0 – Native Publishing, AI, FLUX, Quality & Secure Module Manager
|
||||
|
||||
### Neu
|
||||
|
||||
- `vendoo.ai`, `vendoo.flux-studio`, `vendoo.quality`, `vendoo.publishing` und `vendoo.module-manager` als native Module.
|
||||
- 16 registrierte Module: 14 nativ, 2 Legacy Bridges.
|
||||
- administrative Modulmanager-Oberfläche für Aktivieren, Deaktivieren, Exportieren, Installieren und Entfernen.
|
||||
- persistenter Modulstatus und separater Modulpaketspeicher.
|
||||
- `.vmod`-Paketformat mit SHA-256-Integrität, sicherer Pfadprüfung und optionaler Ed25519-Signatur.
|
||||
- deklarative Fremdmodule ohne ausführbaren Code; ausführbare Pakete bleiben bis zum isolierten Extension-Host in Quarantäne.
|
||||
- Modulabhängigkeiten werden beim Aktivieren automatisch gestartet und beim Deaktivieren kontrolliert behandelt.
|
||||
- Modulrouten werden bei deaktiviertem Besitzer mit `MODULE_DISABLED` gesperrt.
|
||||
- Windows-, Docker-, NAS- und VPS-Backups enthalten Modulpakete und Aktivierungszustand.
|
||||
- GitHub Draft Pull Request #13 für `release/1.40.0-module-manager`.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- geschützte Kernmodule können weder deaktiviert noch gelöscht werden.
|
||||
- eingebaute Module können exportiert, aber nicht als Fremdmodule installiert werden.
|
||||
- Fremdmodule werden nicht durch bloßes Kopieren aktiviert.
|
||||
- der lokale Laufzeitordner `modules/` wird von Git- und Release-Staging ausgeschlossen.
|
||||
- keine ungeprüfte Ausführung von Drittcode im Vendoo-Hauptprozess.
|
||||
|
||||
|
||||
## 1.39.0 – Listings, Inventory & Media Native Module Migration
|
||||
|
||||
### Neu
|
||||
|
||||
- `vendoo.listings`, `vendoo.inventory` und `vendoo.media` als native Plattformmodule.
|
||||
- fünfzehn registrierte Module: neun nativ, sechs Legacy Bridges.
|
||||
- öffentliche Artikel-, Papierkorb-, Lager- und Medienrouten aus `server.mjs` entfernt und über Modulverträge registriert.
|
||||
- Repository-Injektion verhindert Datenbankzugriffe als Import-Seiteneffekt.
|
||||
- eigenes `verify:catalog`-Gate mit Service-Regressionstests.
|
||||
- GitHub Draft Pull Request #12 für `release/1.39.0-catalog-media`.
|
||||
|
||||
### Sicherheit und Datenintegrität
|
||||
|
||||
- Artikel-Payload-Allowlist blockiert unbekannte Felder, ungültige Preise, überlange Texte und Listen.
|
||||
- Rich-HTML wird weiterhin serverseitig bereinigt.
|
||||
- bestehende Bearbeitungssperren bleiben für Artikelupdates verbindlich.
|
||||
- endgültiges Löschen ist ausschließlich für Artikel im Papierkorb möglich.
|
||||
- Lager-Bulk-Aktionen sind auf 500 eindeutige positive IDs begrenzt.
|
||||
- Medienpfade blockieren Traversal, Nullbytes und Zeilenumbrüche.
|
||||
- Bilder, die von aktiven Artikeln referenziert werden, werden nicht gelöscht.
|
||||
- unbekannte Medien- und Lagerfelder werden serverseitig abgewiesen.
|
||||
|
||||
### Kompatibilität
|
||||
|
||||
- keine destruktive Datenbankmigration und kein Reset.
|
||||
- bestehende Artikel, Bilder, Uploads, Favoriten, Lagerorte und Publishing-Verknüpfungen bleiben erhalten.
|
||||
- API-Pfade und bestehendes Frontend bleiben kompatibel.
|
||||
- `lib/db.mjs` bleibt vorübergehend Kompatibilitätsschicht für AI, Publishing, Quality und Dashboard.
|
||||
|
||||
## 1.37.0 – Security, Secrets & Observability Foundation
|
||||
|
||||
### Neu
|
||||
|
||||
- `vendoo.security` als natives Kernmodul.
|
||||
- AES-256-GCM-verschlüsselter Secret-Speicher mit separatem Master-Key.
|
||||
- Automatische Migration vorhandener Runtime- und SMTP-Secrets mit Sicherungskopie.
|
||||
- Strikte Script-CSP ohne Inline-Skripte und Inline-Eventhandler.
|
||||
- Request- und Correlation-IDs sowie strukturierte JSON-Telemetrie.
|
||||
- Zentrale Input-Schema-Validierung für neue Kernel-Routen.
|
||||
- Erweitertes Security-Dashboard für Secret-Provider, CSP und Requeststatus.
|
||||
- SMTP-Zertifikatsprüfung standardmäßig aktiv.
|
||||
- GitHub Draft Pull Request #10 für `release/1.37.0-security-foundation`.
|
||||
|
||||
### Kompatibilität
|
||||
|
||||
- Keine Datenbankablösung und keine destruktive Migration.
|
||||
- Windows, Docker, NAS und VPS bleiben unterstützt.
|
||||
- Theme Manager und vorhandene Daten bleiben erhalten.
|
||||
|
||||
## 1.36.0 – Secure Theme Manager, Accessibility & Component Token Foundation
|
||||
|
||||
### Neu
|
||||
|
||||
- `vendoo.themes` als erstes natives Fachmodul auf dem Plattformkernel.
|
||||
- Persönliche Theme-Präferenzen pro Benutzer mit Theme, UI-Dichte und reduzierter Bewegung.
|
||||
- Sichere Theme-Profile für Administratoren mit Basis-Theme, Token-Overrides, Import, Export und Systemstandard.
|
||||
- Live-Vorschau im Browser; Speichern erst nach expliziter Bestätigung.
|
||||
- Serverseitige Validierung aller Theme-Werte und WCAG-orientierte Kontrastprüfung zentraler Text-/Oberflächenpaare.
|
||||
- Light, Dark, System und High Contrast als integrierte Modi.
|
||||
- 42 typisierte Design Tokens, darunter erste Komponenten-, Fokus- und Touch-Target-Tokens.
|
||||
- Tokenisierte Buttons, Eingabefelder, Karten, Dialoge und Fokuszustände.
|
||||
- Deny-by-default API-Verträge für Theme-Routen sowie getrennte Benutzer- und Administrator-Policies.
|
||||
- Audit-Protokoll für persönliche Präferenzen, Theme-Profile und Systemstandard.
|
||||
- Eigenes `verify:themes`-Gate in lokaler Prüfung, GitHub CI und Release-Workflow.
|
||||
- GitHub Draft Pull Request #9 für `release/1.36.0-theme-manager`.
|
||||
|
||||
### Sicherheit und Kompatibilität
|
||||
|
||||
- Keine freie CSS-, JavaScript-, URL- oder Dateipfad-Eingabe.
|
||||
- Unbekannte Tokens und Theme-IDs werden abgewiesen.
|
||||
- Normale Benutzer dürfen ausschließlich ihre eigene Darstellung ändern.
|
||||
- Systemstandard und benutzerdefinierte Profile erfordern administrative Berechtigung.
|
||||
- Bestehende Daten, Benutzer, Artikel, Uploads, Backups und FLUX-Daten bleiben erhalten.
|
||||
- Kein Reset, keine Framework-Migration und keine Datenbankablösung.
|
||||
|
||||
## 1.35.0 – Modular Platform Kernel, Design-System Contracts & Security Architecture Foundation
|
||||
|
||||
### Neu
|
||||
|
||||
- Modularer Plattformkernel mit Lifecycle, Event Bus, Feature Flags, Modul- und Policy-Registry.
|
||||
- Zwölf bestehende Fachbereiche als validierte `legacy-bridge`-Module mit expliziten Abhängigkeiten, Capabilities und Ownership.
|
||||
- Deny-by-default Route Registry: neue Kernel-Routen benötigen Besitzer und explizite Policy.
|
||||
- Administrativ geschützte Plattform-, Modul- und Theme-Vertragsendpunkte.
|
||||
- Kernel-Healthcheck in `/readyz` und geordneter Kernel-Shutdown.
|
||||
- Versionierte Design-Token-Quelle mit 33 Tokens für Light, Dark und High Contrast.
|
||||
- Deterministischer Token-Generator und browserseitige Theme Runtime.
|
||||
- Sichere Theme-Definitionen: nur freigegebene Farb- und Dimensionswerte, kein freies CSS/JS.
|
||||
- Modul-JSON-Schema, Architekturgrenzen, Migrationsroadmap und Security-Architekturdokumentation.
|
||||
- Neues `npm run verify:architecture` mit Zyklus-, Policy-, Event-, Theme- und Token-Regressionstests.
|
||||
- GitHub Draft Pull Request #7 für `release/1.35.0-platform-kernel`.
|
||||
|
||||
### Kompatibilität
|
||||
|
||||
- Kein Reset, keine Framework-Migration, keine Datenbankablösung.
|
||||
- Windows-Lokalbetrieb, Docker, NAS, VPS, Zero-Edit-Installer und Git-Sicherheitsgate bleiben erhalten.
|
||||
- Bestehende CSS-Variablen bleiben über eine Token-Kompatibilitätsbrücke funktionsfähig.
|
||||
- Bestehende Routen werden in 1.35.0 noch nicht massenhaft verschoben; die Migration erfolgt fachmodulweise.
|
||||
|
||||
## 1.34.3 – Git-Staging Update-Archiv-Ausschluss Hotfix
|
||||
|
||||
### Behoben
|
||||
- Der lokale Ordner `updates/` wird als Update-, Rollback- und Slice-Artefaktspeicher behandelt und nicht nach GitHub oder in Release-Staging-Verzeichnisse übernommen.
|
||||
- Alte Sicherungskopien wie `updates/backups/.../setup.bat`, `setup.ps1` oder `scripts/install-local-flux.ps1` lösen das Git-Sicherheitsgate nicht mehr aus, weil sie bereits vor dem Scan ausgeschlossen werden.
|
||||
- Die aktiven Installationsdateien im Projektroot und unter `scripts/` bleiben Teil des Quellimports.
|
||||
- Das Git-Sicherheitsgate blockiert `updates/` weiterhin, falls ein fremder Kopierweg den Ordner dennoch in das Staging einschleust.
|
||||
- `.gitignore` schützt den lokalen Updateverlauf zusätzlich vor versehentlichen Commits.
|
||||
|
||||
### Qualitätssicherung
|
||||
- Regressionstest mit den drei konkret gemeldeten Pfaden aus `slice22_1_20260707-194953`.
|
||||
- Release-Builder und GitHub-Deploy-Assistent verwenden weiterhin dieselbe Filterfunktion.
|
||||
- Neuer Importbranch `release/1.34.3-initial-import` und Draft Pull Request #6.
|
||||
|
||||
## 1.34.2 – Git-Sicherheitsdiagnose & gemeinsamer Staging-Builder
|
||||
|
||||
### Behoben
|
||||
- Die Meldung `Git-Sicherheitsgate fehlgeschlagen (4)` wird nicht mehr ohne Detailzeilen ausgegeben. Der Windows-Assistent fängt stdout und stderr getrennt ab und zeigt jeden konkreten Treffer an.
|
||||
- Der vollständige Fehlerbericht wird unter `logs/github-deploy-safety-report.txt` gespeichert und unter Windows automatisch im Editor geöffnet.
|
||||
- Windows, Linux und macOS verwenden denselben Node-basierten Staging-Builder statt unterschiedlicher Robocopy-/rsync-Regeln.
|
||||
- Lokale `.env.*`-Dateien, Operations-Daten, Uploads, Backups, Logs, Datenbanken, Schlüsseldateien und FLUX-/ComfyUI-Laufzeitdaten werden vor dem Sicherheitsgate automatisch aus dem temporären Git-Staging ausgeschlossen.
|
||||
- `.env.example` und `.env.docker.example` bleiben als sichere Vorlagen enthalten.
|
||||
- Der Staging-Builder leert nur den Arbeitsbaum des temporären Klons und erhält dessen `.git`-Verzeichnis vollständig.
|
||||
|
||||
### Qualitätssicherung
|
||||
- Neuer plattformübergreifender Git-Staging-Regressionstest.
|
||||
- Der Test belegt, dass lokale Secrets und Laufzeitdaten ausgeschlossen, öffentliche Vorlagen übernommen und `.git` nicht verändert werden.
|
||||
- CI und Release-Workflow führen den Staging-Regressionstest zusätzlich zum Secret-Regressionstest aus.
|
||||
- Neuer Importbranch `release/1.34.2-initial-import` und Draft Pull Request #5.
|
||||
|
||||
## 1.34.1 – Git-Sicherheitsgate Windows Hotfix
|
||||
|
||||
### Behoben
|
||||
- Windows-Pfade werden im Git-Sicherheitsgate mit `fileURLToPath(import.meta.url)` in echte Dateisystempfade umgewandelt.
|
||||
- Der Sicherheitscheck scannt den vollständigen Import-Arbeitsbaum und nicht nur bereits von Git verfolgte Dateien.
|
||||
- Noch unversionierte `.env.local`, `.env.production`, Token-Dateien und sonstige Secrets werden vor `git add` erkannt.
|
||||
- Node.js ist für den sicheren Import verpflichtend; ein stilles Überspringen des erweiterten Scans ist nicht möglich.
|
||||
|
||||
## 1.34.0 – GitHub CI/CD & Zero-Edit Installation Foundation
|
||||
|
||||
### Installation
|
||||
- Grafischer Windows-Installationsassistent für lokalen Node.js-Betrieb und Docker Desktop.
|
||||
- Geführte Linux-/NAS-/VPS-Konfiguration ohne manuelles Bearbeiten von `.env` oder Compose-Dateien.
|
||||
- Automatische Erzeugung und sichere Ablage des Erst-Admin-Setup-Codes.
|
||||
- VPS-Modus mit integriertem Caddy-Reverse-Proxy und automatischem HTTPS.
|
||||
- Konfiguration kann später über den Assistenten geändert werden, ohne bestehende Daten zu löschen.
|
||||
- Persistenter Runtime-Konfigurationsspeicher für Eingabemasken im gehärteten read-only Container.
|
||||
- Simulierbares Shell-Installer-Gate für Docker-, NAS- und VPS-Modus; dabei wurde die Standardwert-Abfrage gegen Prompt-Vermischung gehärtet.
|
||||
|
||||
### GitHub und Releases
|
||||
- Git-Sicherheitsgate gegen Secrets, Datenbanken, Uploads, Backups, Logs, Runtime- und Modellordner.
|
||||
- GitHub Actions für CI, Docker-Build, Release-Pakete, SHA-256 und GHCR-Images.
|
||||
- Windows- und Shell-GitHub-Deploy-Assistent für kontrollierten Erstimport auf einen Release-Branch.
|
||||
- Privater Branch `release/1.34.0-initial-import` und Draft Pull Request #3 real vorbereitet.
|
||||
- Release-Pakete werden aus einem bereinigten Staging-Verzeichnis erzeugt und nicht als Binärdateien in die Git-Historie aufgenommen.
|
||||
|
||||
### Sicherheit
|
||||
- GitHub Actions besitzen jobbezogen minimale Berechtigungen.
|
||||
- GHCR-Images erhalten eine Provenienz-Attestierung auf Basis des tatsächlich veröffentlichten Image-Digests.
|
||||
- Container-Registry-Veröffentlichung erfolgt nur bei veröffentlichten GitHub Releases und passendem Versionstag.
|
||||
- Produktions-Secrets bleiben in `.env` beziehungsweise GitHub Environments und niemals im Repository.
|
||||
|
||||
## 1.33.0 – Multiplatform Deployment Wizard & Flux/Admin Fixes
|
||||
|
||||
- Installationsziel-Assistent für Windows lokal, Docker Desktop, NAS/Portainer und VPS/Linux
|
||||
- neues `setup.sh` für Linux-, NAS- und VPS-Zielsysteme
|
||||
- Installationsmodus wird für Start, Update, Reparatur, Status und Deinstallation gespeichert
|
||||
- Docker-Host-Gateway für einen getrennten FLUX/ComfyUI-Dienst
|
||||
- FLUX Studio startet ohne automatische Bildauswahl
|
||||
- Reset-Button leert den Arbeitsbereich, ohne Verlauf, Bilder oder Jobs zu löschen
|
||||
- Benutzerbearbeitung auf stabile Event-Listener und Cachelogik umgestellt
|
||||
- Passwortvalidierung vor dem Update und sanitisierte Benutzerantwort
|
||||
|
||||
## 1.32.0 – Docker, NAS & Production Deployment Foundation
|
||||
|
||||
- produktionsgeeignetes, mehrstufiges Dockerfile mit Lockfile-Installation
|
||||
- Compose mit getrennten persistenten Volumes, non-root, read-only und Healthcheck
|
||||
- zentrale, konfigurierbare DB-/Upload-/Backup-/Operations-/Log-Pfade bei voller Windows-Kompatibilität
|
||||
- `/readyz` mit SQLite- und Schreibpfadprüfung
|
||||
- geordneter SIGTERM-/SIGINT-Shutdown mit WAL-Checkpoint
|
||||
- geschütztes Erst-Admin-Setup über `VENDOO_SETUP_TOKEN`; übrige API bleibt bis zum Setup gesperrt
|
||||
- konfigurierbare Proxy- und Cookie-Regeln
|
||||
- produktive Allowlist für Browser-Extension-Origins
|
||||
- Uploads nur angemeldet abrufbar und strengere Dateitypprüfung
|
||||
- NAS-, Portainer-, Backup-, Restore-, Update- und Rollback-Dokumentation
|
||||
- FLUX/ComfyUI bleibt getrennt und ist im Hauptcontainer standardmäßig deaktiviert
|
||||
|
||||
## 1.31.0 – Mehrbenutzer- und Server-Sicherheitsgrundlage
|
||||
|
||||
- Fünf feste Rollen mit serverseitig erzwungenen Berechtigungen: Administrator, Manager, Editor, Publisher und Leser.
|
||||
- Sichere Sitzungen mit absoluter und inaktiver Laufzeit, Widerruf, Benutzer-Sitzungsverwaltung und Login-Sperre.
|
||||
- Bearbeitungssperren für Artikel mit automatischer Verlängerung und kontrollierter Freigabe.
|
||||
- Filterbares und exportierbares Audit-Protokoll.
|
||||
- Rate Limits, Host-/Origin-Freigaben, Request-IDs, CSP/Helmet und standardmäßige Bindung an localhost.
|
||||
- Benutzer- und Sicherheitsoberfläche mit Rollenmatrix, aktiven Sperren und Sicherheitsstatus.
|
||||
- Additive Migrationen; bestehende Artikel, Datenbank, Bilder und Einstellungen bleiben erhalten.
|
||||
|
||||
## 1.30.0 – Operations Center: Backup, Restore, Updates & Extension-Diagnose
|
||||
|
||||
- Neue Betriebszentrale unter Admin → System mit Backup-, Update- und Extension-Diagnose-Tabs.
|
||||
- Konsistente SQLite-Backups über die Backup-API statt Kopieren der geöffneten Datenbank.
|
||||
- Vollbackups mit Uploads, optionaler `.env`, Manifest, SHA-256-Dateiprüfsummen und SQLite-Integritätsprüfung.
|
||||
- Tägliche rotierende automatische Backups mit konfigurierbarer Uhrzeit, Aufbewahrung und Upload-Option.
|
||||
- Verifizierte Wiederherstellung mit automatischem Sicherheitsbackup und bewusstem Offline-Anwenden.
|
||||
- Update Center prüft Version, Pflichtdateien und verbotene Laufzeitdaten, bevor ein Paket vorbereitet wird.
|
||||
- Setup-Menüpunkt 11 wendet vorbereitete Update-/Restore-Operationen an; Menüpunkt 12 führt den Rollback aus.
|
||||
- Migrationsstatus und letzte Systemoperation sind in der Oberfläche sichtbar.
|
||||
- Vendoo Link 1.9.0 sendet Diagnose-Heartbeats; Chrome, Edge, Firefox und Safari werden getrennt ausgewertet.
|
||||
- Keine Datenbank, Uploads, `.env`, API-Schlüssel oder Modelle werden durch ein Update-Paket gelöscht.
|
||||
|
||||
## 1.29.0 – Slice 34: Publishing Hardening
|
||||
|
||||
- plattformübergreifende persistente Publishing-Queue
|
||||
- Doppelveröffentlichungsschutz und Idempotenzprüfung
|
||||
- automatische Retry-Strategie und Neustart-Wiederaufnahme
|
||||
- Fehlerzentrale, Ereignisprotokoll, externe IDs und Links
|
||||
- eBay Sync, Update und Beenden über offizielle Inventory API
|
||||
- Etsy Statusabgleich
|
||||
- Extension-Aufträge für Vinted und Kleinanzeigen
|
||||
- Scheduler an die gehärtete Queue angebunden
|
||||
- Vendoo Link 1.8.0
|
||||
|
||||
## 1.28.0 – Slice 33: Artikel Quality Center
|
||||
|
||||
- Neuer Hauptmenüpunkt **Qualitätscenter** mit Score, Noten A–E, Blockern, Warnungen und Empfehlungen.
|
||||
- Serverseitige Prüfung von Titel, Beschreibung, Pflichtfeldern, Preis, Organisation, Suchbegriffen und plattformbezogenen Vendoo-Profilen.
|
||||
- Vollständige Bildprüfung mit Dateiexistenz, Lesbarkeit, Abmessungen, Format und Auflösungswarnungen.
|
||||
- Erkennung möglicher Duplikate über gleiche SKU und Titelähnlichkeit.
|
||||
- Plattformbereitschaft, Gebühren und geschätzter Erlös gemeinsam im Prüfbericht.
|
||||
- Persistenter Score-Verlauf je Artikel mit Vergleich zur vorherigen Analyse.
|
||||
- Filter, Suche und Pagination für bereite, blockierte und zu prüfende Artikel.
|
||||
- AI-Verbesserungen für Titel, Beschreibung und Tags über den aktuell aktiven Text-AI-Provider.
|
||||
- AI erzeugt ausschließlich Vorschläge; Änderungen werden erst nach sichtbarem Vorher-/Nachher-Vergleich und ausdrücklicher Bestätigung gespeichert.
|
||||
- Keine automatische Erfindung von Produktmerkmalen, Marken, Schäden oder Lieferumfang.
|
||||
|
||||
## 1.27.0 – Slice 32: Batch Image Factory
|
||||
|
||||
- Neuer Hauptmenüpunkt **Bildproduktion** als persistente Batch Image Factory.
|
||||
- Mehrere Quellbilder direkt aus der zentralen Bildergalerie auswählen oder aus der Galerie übergeben.
|
||||
- Acht integrierte Produktionsprofile für eBay, Vinted, Kleinanzeigen, Etsy, Instagram, Webshop und optimierte Originale.
|
||||
- Eigene Produktionsprofile mit Zielgröße, Anpassung, Hintergrund, Format, Qualität und Dateinamensschema anlegen, bearbeiten und löschen.
|
||||
- Mehrere Profile pro Auftrag erzeugen mehrere Ausgaben je Originalbild.
|
||||
- Optionale Hintergrundentfernung mit Intensität sowie Wasserzeichen mit Text, Position und Deckkraft.
|
||||
- Persistentes Job-Center mit 5/10/15 Aufträgen pro Seite, Pause, Fortsetzen, Abbruch, Gesamt-Retry und Einzel-Retry.
|
||||
- Neustart-Wiederaufnahme für wartende und laufende Aufträge; pausierte Aufträge bleiben pausiert.
|
||||
- Ergebnisse werden nicht destruktiv als neue Bildversionen unter `uploads/batch/` gespeichert und erscheinen automatisch in der Bildergalerie.
|
||||
- Fertige Ergebnisse eines Auftrags können gesammelt als ZIP heruntergeladen werden.
|
||||
- Bildrenderer um `contain`, `cover`, exakte Ziel-Canvas, Ausgabeordner und kontrollierte Dateinamen erweitert.
|
||||
|
||||
## 1.26.1 – Stabilitäts-, Responsive- und Regression-Gate
|
||||
|
||||
- Zentrale Systemprüfung im Adminbereich ergänzt: SQLite-Integrität, Schreibrechte, UI-Dateien, FLUX-Workflow, Browser-Extensions, AI-Konfiguration und optionale ComfyUI-Erreichbarkeit.
|
||||
- Öffentlichen, geheimnisfreien `/healthz`-Endpunkt für lokale Verbindungsüberwachung ergänzt.
|
||||
- API-Client um Request-IDs, definierte Zeitlimits, einmaligen GET-Retry bei Netzwerk-/Serverfehlern und strukturierte Fehlerprotokollierung erweitert.
|
||||
- Sichtbare Verbindungsleiste bei Serverausfall mit automatischer Wiederholungsprüfung ergänzt.
|
||||
- Unbehandelte JavaScript- und Promise-Fehler werden lokal mit Build, Seite und Request-ID für die Diagnose erfasst.
|
||||
- Responsive-Verhalten auf die tatsächlich verfügbare Workspace-Breite umgestellt; `ResizeObserver` vergibt Breitenklassen unabhängig von der Browserbreite.
|
||||
- Globale Schutzregeln gegen horizontales Überlaufen von Formularen, Bildern, Vorschauen, Werkzeugleisten und langen Inhalten ergänzt.
|
||||
- Neue statische Release- und UI-Vertragsprüfungen verhindern doppelte IDs, tote Navigation, fehlende Diagnoseelemente und veraltete Buildmarker.
|
||||
- Keine Datenbankstruktur, Nutzerdaten, API-Schlüssel, Extensions oder FLUX-Modelle verändert.
|
||||
|
||||
## 1.26.0 – Artikel-Navigation, Publish-Reparatur, Zoom-Galerie & Extension Center
|
||||
|
||||
- „Neues Listing“ konsequent in „Neuer Artikel“ und „Listing generieren“ in „Artikel generieren“ umbenannt.
|
||||
- „Neuer Artikel“ als eigener Hauptmenüpunkt direkt unter dem Dashboard ergänzt; „Listings“ heißt in der Navigation jetzt „Artikel“.
|
||||
- Publish-Datenaufbereitung gegen fehlerhafte ältere JSON-Felder gehärtet und einen Scope-Fehler im direkten Publish-Workspace behoben.
|
||||
- Smart Copy öffnet wieder zuverlässig als Popup; die mittlere Publish-Fläche zeigt Titel, Beschreibung, Tags, Metadaten und Fotos.
|
||||
- Medienvorschau um Fit-, 100-%-, Stufen- und Mausrad-Zoom sowie Verschieben bei Vergrößerung erweitert.
|
||||
- Galerie-Auswahl und Favoriten auf kleine, konsistente Lucide-basierte SVG-Icons umgestellt.
|
||||
- Gebührenrechner für private Verkäufer aktualisiert: eBay.de, Vinted und normaler Kleinanzeigen-Privatverkauf ohne Verkäuferprovision; Etsy separat als Shop-Kalkulation.
|
||||
- Neuer Menüpunkt „Extensions“ mit Browsererkennung, Ein-Klick-Vorbereitung, Pfadkopie und Downloadpaketen für Chrome, Edge, Firefox und Safari.
|
||||
- Safari-WebExtension-Quelle samt Xcode-Konvertierungsskript ergänzt.
|
||||
- Doppelte Settings-ID und doppelten Galerie-Event-Listener bereinigt.
|
||||
|
||||
## 1.25.1 – Adaptive Bildergalerie
|
||||
|
||||
- Galerie-Raster vollständig auf automatische Kartenbreiten mit `auto-fit` und Container Queries umgestellt.
|
||||
- Die gesamte Bildfläche öffnet jetzt die Lightbox; die übergroße Vorschau-/Öffnen-Schaltfläche wurde entfernt.
|
||||
- Auswahl und Favorit skalieren mit der Kartenbreite und bleiben sauber in den Bildecken ausgerichtet.
|
||||
- Aktionsleiste auf vier kompakte, adaptive Bildaktionen umgestellt; auf sehr schmalen Karten werden nur Symbole gezeigt.
|
||||
- Auswahlstatus wird zusätzlich an Karte und Bildfläche sichtbar, ohne das Motiv zu verdecken.
|
||||
- Seitennavigation mit direkter Seitenauswahl und Bildbereich wird oberhalb und unterhalb der Galerie angezeigt.
|
||||
- Listenansicht und schmale Bildschirmbreiten wurden im selben Komponentenmodell neu gehärtet.
|
||||
|
||||
## 1.25.0 – Responsive Detail Editor, Gallery UX, Smart Copy Modal & FLUX Prompt Lab
|
||||
|
||||
- Listing-Bearbeiten reagiert jetzt auf die tatsächlich verfügbare Inhaltsbreite per Container Queries und bleibt auch mit Sidebar, Quill, Vorschau und Kontextkarten ohne horizontalen Überlauf bedienbar.
|
||||
- Die zentrale Bildergalerie erhielt sauber ausgerichtete Auswahlfelder, Favoriten-Overlay, eine eindeutige Vorschauaktion, zweizeilige Titel und ein stabiles 2×2-Aktionsraster.
|
||||
- Publish-Aktualisieren rotiert nicht mehr; während des Ladens werden ruhige Textzustände und `aria-busy` verwendet.
|
||||
- Smart Copy öffnet wieder als eigenes responsives Arbeitsfenster mit Kopierfeldern, Fotos, Plattformstart und Veröffentlichungsstatus.
|
||||
- FLUX Ergebnisse & Favoriten besitzen Pagination mit 5, 10 oder 15 Bildern pro Seite.
|
||||
- Das FLUX Job-Center ist auf 5, 10 oder 15 Aufträge pro Seite begrenzt.
|
||||
- Neues FLUX Prompt Lab: lokale Strukturierung oder AI-Veredelung über den aktuell aktiven Claude-, OpenAI-, OpenRouter- oder Ollama-Provider.
|
||||
- Prompt Lab schützt auf Wunsch Motivmerkmale und vermeidet erfundene Texte, Logos und Wasserzeichen.
|
||||
- Zusätzliche Breitenhärtung für Publish, Medienbibliothek, FLUX, Einstellungen und Generator.
|
||||
|
||||
## 1.24.0 – Responsive UI, FLUX-Auftragsarchiv & zentrale Bildergalerie
|
||||
|
||||
- Listing-Seite auf schmalen Breiten von einer starren Mindestbreite auf echte responsive Kartenzeilen umgestellt.
|
||||
- Responsive Härtung für Filter, Batch-Aktionen, FLUX-Job-Center, Formulare, Aktionsleisten und Medienverwaltung ergänzt.
|
||||
- Hintergrundentfernung im Advanced Editor um eine regelbare Intensität von 0 bis 100 erweitert.
|
||||
- FLUX-Aufträge können einzeln oder gesammelt archiviert und aus dem Archiv wiederhergestellt werden.
|
||||
- Im FLUX-Archiv können einzelne, mehrere oder alle Aufträge endgültig gelöscht werden; erzeugte Bilder bleiben erhalten.
|
||||
- Serverseitige Pagination und Seitengröße für aktive und archivierte FLUX-Aufträge ergänzt.
|
||||
- Neuer Navigationsbereich „Bilder“ als zentrale Galerie für FLUX-, bearbeitete, AI- und Listing-Bilder.
|
||||
- Galerie mit Raster-/Listenansicht, Suche, Quellenfiltern, Favoriten, Sortierung, Pagination, Mehrfachauswahl, ZIP-Download, Lightbox, Editor-Übergabe und geschützter Löschung aktiver Listing-Bilder.
|
||||
- Vinted-Titelfeldschutz aus Vendoo Link 1.6.1 unverändert beibehalten.
|
||||
|
||||
## 1.23.2 – Slice 31.2 Hotfix: FLUX-Layout, Editor-Werkzeuge & kompakte Aufträge
|
||||
|
||||
- „System & ComfyUI“ aus der angehefteten FLUX-Seitenspalte entfernt und direkt unter „Prompt & Auftrag“ eingeordnet.
|
||||
- FLUX Job-Center als Ganzes ein- und ausklappbar gemacht.
|
||||
- Einzelne FLUX-Aufträge kompakt auf- und zuklappbar; aktive Aufträge sind standardmäßig geöffnet, abgeschlossene kompakt geschlossen.
|
||||
- „Alle einklappen“ ergänzt, damit lange Verläufe die Seite nicht unnötig vergrößern.
|
||||
- Separate Buttons für Hintergrundentfernung und Wasserzeichen aus Generator und Bildleisten entfernt.
|
||||
- Hintergrundentfernung in den Advanced Image Editor integriert und als nicht destruktive Bildversion gespeichert.
|
||||
- Wasserzeichen mit eigenem Text, fünf Positionen und einstellbarer Deckkraft in den Advanced Image Editor integriert.
|
||||
- Editor-Werkzeuge gelten einheitlich für Generator-, Listing- und FLUX-Bilder.
|
||||
- Vendoo Link 1.6.1 und der Vinted-Titelfeldschutz bleiben unverändert erhalten.
|
||||
|
||||
## 1.23.1 – Slice 31.1 Hotfix: Editor-Sichtbarkeit & Vinted-Feldschutz
|
||||
|
||||
- Advanced Image Editor erhält im Generator einen deutlich sichtbaren eigenen Einstieg.
|
||||
- `app.js` und `style.css` werden versionsgebunden geladen; HTML/JS/CSS werden vom Vendoo-Server nicht mehr aus veraltetem Browser-Cache ausgeliefert.
|
||||
- Der Windows-Starter öffnet Vendoo mit Build-Kennung.
|
||||
- Vinted-Auto-Fill ordnet Felder nur noch über eindeutige Selektoren bzw. tatsächlich zugeordnete Labels zu.
|
||||
- Zustands-, Marken-, Größen- und Farbauswahl dürfen das Titelfeld nicht mehr als Fallback verwenden.
|
||||
- Vendoo Link auf Version 1.6.1 angehoben.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 1.40.2 – Capability Graph Contract Hotfix
|
||||
|
||||
### Behoben
|
||||
|
||||
- `vendoo.module-manager` verlangte `settings.configuration`, obwohl `vendoo.settings` diese Capability nicht bereitstellte.
|
||||
- Der Plattformstart brach deshalb mit `MODULE_CAPABILITY_MISSING` ab.
|
||||
- `vendoo.settings` stellt nun den übergeordneten Konfigurationsvertrag zusätzlich zu `settings.application` und `settings.smtp` bereit.
|
||||
|
||||
### Qualitätssicherung
|
||||
|
||||
- neues Capability-Graph-Gate validiert den real aktivierten Gesamtgraph aller 16 Module.
|
||||
- `ModuleRegistry.validateGraph()` stellt Startreihenfolge und Capability-Menge ohne Lifecycle-Start für Tests bereit.
|
||||
- Architektur-, CI-, Release- und Deployment-Gates prüfen den realen Modulgraph statt ausschließlich künstlicher Testmodule.
|
||||
- keine Datenbankmigration, kein Reset und keine Änderung an Nutzdaten.
|
||||
|
||||
## 1.23.0 – Slice 31: Advanced Image Editor
|
||||
|
||||
- Bestehenden einfachen Fotoeditor durch einen nicht destruktiven Produkt- und FLUX-Bildeditor ersetzt.
|
||||
- Undo/Redo und eine vollständige Bearbeitungshistorie innerhalb der aktuellen Sitzung ergänzt.
|
||||
- Persistente Bildversionen in `image_edit_versions` eingeführt; das Original bleibt unverändert und jederzeit wiederherstellbar.
|
||||
- Vorher-/Nachher-Ansicht und Auswahl älterer Bearbeitungsversionen integriert.
|
||||
- Freien Zuschnitt sowie 1:1, 4:5, 3:4, 16:9 und Original-Seitenverhältnis ergänzt.
|
||||
- Drehen, horizontales/vertikales Spiegeln und stufenloses Begradigen umgesetzt.
|
||||
- Belichtung, Helligkeit, Kontrast, Lichter, Schatten, Temperatur, Sättigung und Schärfe ergänzt.
|
||||
- Export als JPEG, WebP oder PNG mit Qualitätsregler und Zielgröße umgesetzt.
|
||||
- Bearbeitungen werden serverseitig mit `sharp` in voller Auflösung gerendert und immer als neue Datei gespeichert.
|
||||
- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor bleiben bewusst außerhalb dieses Slices; es gibt keine Platzhalterbuttons.
|
||||
|
||||
## 1.22.0 – Slice 30: FLUX Studio Pro
|
||||
|
||||
- Persistentes FLUX-Job-Center mit SQLite-Aufträgen und einzelnen Varianten eingeführt.
|
||||
- 1, 2 oder 4 Varianten, eindeutige Seeds und reproduzierbarer Seed Lock ergänzt.
|
||||
- „Ähnlich erneut erzeugen“, Favoriten und vollständige Prompt-/Parameterhistorie umgesetzt.
|
||||
- Abbruch, Wiederholen, Queue-Bereinigung und Neustart-Wiederaufnahme ergänzt.
|
||||
- ComfyUI-Warteschlange, Historie, System-, GPU- und VRAM-Daten angebunden.
|
||||
- Fortschrittsanzeige des FLUX Studios auf reale Phasen ohne geschätzte Prozentwerte umgestellt.
|
||||
- Freier FLUX-Promptworkflow bleibt ohne Produktreferenz und ohne `LoadImage`.
|
||||
- FASHN VTON und Virtual Try-on bleiben vollständig entfernt.
|
||||
|
||||
## 1.21.0 – Slice 29: FLUX Quality & Settings Tabs
|
||||
|
||||
- Einstellungsbereiche als horizontale Top-Tabs neu gestaltet.
|
||||
- FLUX Studio mit Prompt-Check, lokalem Prompt-Assistenten und Qualitätsprofilen erweitert.
|
||||
- FLUX.1-schnell standardmäßig auf vier Schritte optimiert; Detailprofil mit sechs Schritten.
|
||||
- Negative Prompt im reinen FLUX-Promptmodus entfernt.
|
||||
- Startprofile Auto, Schnell, Ausgewogen und Wenig VRAM ergänzt.
|
||||
- ComfyUI-Start verwendet je nach Profil Cache-/VRAM- und optionale Fast-Flags.
|
||||
- Laufzeit und final strukturierter Prompt werden je Bild gespeichert.
|
||||
- Vorschau und Bearbeitungsaktionen visuell aufgewertet.
|
||||
|
||||
## 1.20.1 – FLUX Runtime Startdiagnose und Health-Check
|
||||
|
||||
- FLUX/ComfyUI-Start wartet jetzt bis zu 180 Sekunden auf den echten `/system_stats`-Health-Check statt nach 2,5–5 Sekunden vorschnell „gestoppt“ zu melden.
|
||||
- Eigene Runtime-Statusdatei mit PID, Laufzeit, Startphase sowie bereinigten stdout-/stderr-Auszügen.
|
||||
- ANSI-Farbcodes werden aus den angezeigten Runtime-Logs entfernt.
|
||||
- Prozessabbruch während des Starts wird sofort erkannt und mit den letzten relevanten Logzeilen gemeldet.
|
||||
- Das reine Vendoo-FLUX-Studio startet ComfyUI ohne benutzerdefinierte Nodes; der verwendete Workflow benötigt ausschließlich eingebaute Standard-Nodes.
|
||||
- Start- und Stopp-Skripte liegen jetzt als feste Paketdateien vor und werden beim Drüberkopieren sofort aktualisiert.
|
||||
- FLUX Studio und Einstellungen zeigen während des Starts laufend „Wird gestartet“ statt einen falschen Stoppstatus.
|
||||
|
||||
## 1.20.0 – FASHN/VTO entfernt und eigenständiges FLUX Studio
|
||||
|
||||
- FASHN VTON, Modelbibliothek, lokale VTO-Dienste, API-Routen, UI, Installationsskripte und Einstellungen vollständig aus dem aktiven Produkt entfernt.
|
||||
- Bestehende VTO-Runtime, Autostarts, Logs und alte VTO-Einstellungen werden beim ersten Setup-Lauf entfernt; eigene alte Modelbilder werden nach `uploads/ai-generated/legacy-models` gesichert.
|
||||
- Neuer Hauptmenüpunkt **FLUX Studio** mit freiem Prompt, Stil, Format, Seed und Schritten.
|
||||
- Live-Status und Start/Stopp-Steuerung für das lokale ComfyUI/FLUX.
|
||||
- Vorschau, Download, Bildbibliothek, Löschen und Übernahme in den Listing-Generator.
|
||||
- Nachträglicher Bildeditor für Zuschneiden, Drehen, Spiegeln, Helligkeit, Kontrast und Sättigung. Bearbeitungen werden als neue Version gespeichert.
|
||||
- Neue Tabelle `flux_images`; aufgegebene VTO-Tabelle und `ai_vto_*`-Einstellungen werden aus bestehenden Datenbanken entfernt.
|
||||
- FLUX bleibt ein reiner Prompt-Bildgenerator; Ghost Mannequin und Flatlay bleiben als separate Produktbild-Werkzeuge bestehen.
|
||||
|
||||
## [1.15.0] - 2026-07-07
|
||||
|
||||
### Neu
|
||||
|
||||
- Komplett neu strukturierte Einstellungszentrale mit Bereichen für Allgemein, AI & APIs, Marktplätze und lokales FLUX.
|
||||
- Großer FLUX-Live-Status mit klarer Anzeige für Aktiv, Gestoppt, Deaktiviert und Nicht installiert.
|
||||
- Lokales FLUX kann direkt aktiviert, deaktiviert, gestartet, gestoppt, repariert, aktualisiert und sicher deinstalliert werden.
|
||||
- Sichere FLUX-Deinstallation entfernt ComfyUI und das Modell, behält standardmäßig aber die Installationsarchive für eine schnelle Neuinstallation.
|
||||
- Einstellbare Produktreferenz-Treue für AI-Model-Fotos.
|
||||
- Neuer Geschwindigkeitsmodus mit 768 × 768 Pixeln als Standard; 1024 × 1024 bleibt als Qualitätsoption verfügbar.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Produktfotos werden bei lokalem FLUX jetzt tatsächlich an ComfyUI übertragen und als Image-to-Image-Referenz in den Workflow eingebunden.
|
||||
- Hoodie, Farbe, Kapuze, Reißverschluss, Aufdrucke, Logos und Schnitt werden im Prompt zusätzlich als verbindliche Produktmerkmale geschützt.
|
||||
- AI-Bildjobs aktualisieren ihren Fortschritt jetzt während Vorbereitung, Upload, Rendering, Sicherheitsprüfung und Speicherung statt dauerhaft bei 10 % zu stehen.
|
||||
- Alte oder beschädigte Installationsstatusdateien können nicht mehr zu `unknown · Bereitschaft 100%` führen, wenn die reale Installation vollständig vorhanden ist.
|
||||
- Der konfigurierte Installationspfad `local-ai/comfyui` wird korrekt auf den enthaltenen Portable-Ordner aufgelöst.
|
||||
|
||||
### Technik
|
||||
|
||||
- Neue ComfyUI-Referenzpipeline: UploadImage → LoadImage → ImageScale → VAEEncode → KSampler.
|
||||
- Neue Datenbankspalte `ai_model_jobs.progress_message` als additive Migration.
|
||||
- BOM-sicheres Lesen des FLUX-Installationsstatus.
|
||||
- Neuer PowerShell-5.1-kompatibler Uninstaller `scripts/uninstall-local-flux.ps1`.
|
||||
|
||||
## [1.14.6] - 2026-07-07
|
||||
|
||||
### Behoben
|
||||
|
||||
- Lokaler FLUX-Workflow wird unter Windows PowerShell 5.1 als UTF-8 ohne BOM geschrieben.
|
||||
- Bereits vorhandene Workflow-Dateien mit UTF-8-BOM werden beim Einlesen robust bereinigt.
|
||||
- `Unexpected token '' ... is not valid JSON` bei der lokalen AI-Bildgenerierung behoben.
|
||||
- Installer-Status-JSON wird ebenfalls ohne BOM geschrieben, damit Node.js und andere JSON-Parser es zuverlässig lesen können.
|
||||
|
||||
# 1.14.5 – Slice 22.3 PowerShell 5.1 Parser Fix
|
||||
|
||||
- Behebt den PowerShell-5.1-Parserfehler `InvalidVariableReferenceWithDrive` im sichtbaren FLUX-Downloadstatus.
|
||||
- Ersetzt die beiden ungültigen Zeichenfolgen `$Label:` durch die eindeutig begrenzte Form `${Label}:`.
|
||||
- Der bestehende Resume-Download, die `.part`-Datei und der automatische Setup-Ablauf bleiben unverändert erhalten.
|
||||
- Keine Datenbank-, Konfigurations-, Upload- oder Listing-Daten werden verändert.
|
||||
|
||||
# 1.14.4 – Slice 22.2 Download Recovery
|
||||
|
||||
- Fortsetzbarer HTTP-Streaming-Download statt BITS/Invoke-WebRequest.
|
||||
- Sichtbare Fortschrittsanzeige mit Prozent, MB, Geschwindigkeit und Restzeit.
|
||||
- Automatische Wiederaufnahme über setup.bat.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 1.40.2 – Capability Graph Contract Hotfix
|
||||
|
||||
### Behoben
|
||||
|
||||
- `vendoo.module-manager` verlangte `settings.configuration`, obwohl `vendoo.settings` diese Capability nicht bereitstellte.
|
||||
- Der Plattformstart brach deshalb mit `MODULE_CAPABILITY_MISSING` ab.
|
||||
- `vendoo.settings` stellt nun den übergeordneten Konfigurationsvertrag zusätzlich zu `settings.application` und `settings.smtp` bereit.
|
||||
|
||||
### Qualitätssicherung
|
||||
|
||||
- neues Capability-Graph-Gate validiert den real aktivierten Gesamtgraph aller 16 Module.
|
||||
- `ModuleRegistry.validateGraph()` stellt Startreihenfolge und Capability-Menge ohne Lifecycle-Start für Tests bereit.
|
||||
- Architektur-, CI-, Release- und Deployment-Gates prüfen den realen Modulgraph statt ausschließlich künstlicher Testmodule.
|
||||
- keine Datenbankmigration, kein Reset und keine Änderung an Nutzdaten.
|
||||
|
||||
## Slice 22.1 / 1.14.3 – Setup-integrated PowerShell 5.1 recovery
|
||||
- Fixed the false 7-Zip failure when `Process.ExitCode` is empty under Windows PowerShell 5.1 despite a successful extraction.
|
||||
- A missing exit code is accepted only when `Everything is Ok` is present, stderr is empty and the extracted result passes file-count and size checks.
|
||||
- Complete `local-ai\extract-temp` results are reused instead of being deleted and extracted again.
|
||||
- `setup.bat` now starts with UTF-8 console handling, `-NoProfile` and explicit exit-code reporting.
|
||||
- Normal `setup.bat` startup detects the exact confirmed Slice-22 false-failure state and offers automatic continuation.
|
||||
- Automatic continuation installs ComfyUI, downloads the FLUX.1-schnell model and starts the local image server without a separate patch command.
|
||||
- Installer intent flags are persisted in `install-state.json` for future resumable runs.
|
||||
- No reset, database change or feature removal is performed.
|
||||
|
||||
## Slice 22 / 1.14.2 – 7-Zip extraction watchdog and real FLUX install telemetry
|
||||
- Replaced the blocking 7-Zip invocation with a supervised process and explicit path quoting.
|
||||
- Live extraction status now includes elapsed time, extracted file count, extracted MB, process ID and heartbeat.
|
||||
- Added 7-Zip stdout/stderr logs and a visible UI panel for extraction details.
|
||||
- Detects stalled installers instead of showing an endless running state.
|
||||
- Reuses complete downloads and safely restarts only the failed extraction/install step.
|
||||
- Handles nested ComfyUI portable archive layouts more robustly.
|
||||
- Setup status now shows live extraction counters and stale heartbeat detection.
|
||||
|
||||
## Slice 21 – FLUX Installer 7-Zip Hotfix & Detailed Status
|
||||
|
||||
- Fixed PowerShell 5.1 scalar-string bug where a single 7-Zip path such as `C:\Program Files\7-Zip\7z.exe` was indexed as the single character `C`.
|
||||
- 7-Zip discovery now uses a real string list, refreshes PATH after winget installation and validates the final executable path.
|
||||
- Partial ComfyUI and FLUX downloads now use `.part` files and are validated before being promoted to complete files.
|
||||
- BITS downloads now report actual transfer percentage, bytes transferred and total bytes into `local-ai/install-state.json`.
|
||||
- Installer failures preserve the last real progress value and include source position plus resumable-state metadata.
|
||||
- Settings UI now shows detailed checks for 7-Zip, archive, Python runtime, ComfyUI, FLUX model, workflow and start/stop scripts.
|
||||
- Install log tail, error details, readiness score and a resume-aware install button were added.
|
||||
- Setup option 8 now prints phase, progress, file checks, runtime reachability and recent install log lines.
|
||||
|
||||
## Slice 20 – Vollautomatischer lokaler FLUX-Installer
|
||||
|
||||
- Automatischer Download der aktuellen offiziellen ComfyUI-Windows-Portable-Version über die GitHub-Releases-API
|
||||
- GPU-Erkennung für NVIDIA, AMD, Intel und CPU-Fallback
|
||||
- Automatische 7-Zip-Installation über winget, falls erforderlich
|
||||
- Optionaler Download von FLUX.1-schnell FP8 aus dem offiziellen Comfy-Org-Hugging-Face-Repository
|
||||
- Fortschritts- und Statusdatei unter `local-ai/install-state.json`
|
||||
- Fortsetzbare Downloads über BITS mit WebRequest-Fallback
|
||||
- Sichere Startparameter: Bindung an `127.0.0.1`, kein Browser-Autostart, eigener Port 8188
|
||||
- Optionale Windows-Autostart-Aufgabe `Vendoo Local FLUX`
|
||||
- UI-Aktionen für Installieren, Aktualisieren, Starten, Stoppen und Status prüfen
|
||||
- Setup-Menü schützt weiterhin vor versehentlicher Neuinstallation von Vendoo und erkennt vorhandene FLUX-Installationen
|
||||
- Provider-Reihenfolge im Auto-Modus: lokal FLUX → OpenRouter Free → OpenAI
|
||||
|
||||
## Slice 19 – Local FLUX / ComfyUI Bootstrap, safer setup install flow
|
||||
- Setup hardened: Install checks whether Vendoo is already installed and suggests Start / Update / Repair instead of blindly reinstalling.
|
||||
- New setup options: local FLUX / ComfyUI bootstrap and status checks.
|
||||
- New local image provider architecture for AI model photos: Auto now supports Local FLUX -> OpenRouter Free -> OpenAI.
|
||||
- New server routes for local image status, bootstrap install and token regeneration.
|
||||
- Settings UI expanded with local FLUX URL, token, workflow path and install path.
|
||||
- Added local workflow bootstrap template and helper scripts under `scripts/` and `local-ai/workflows/`.
|
||||
|
||||
## Vendoo 1.12.0 – OpenRouter Diagnostics & Mobile Mediathek
|
||||
|
||||
- Mobiler QR-Upload besitzt jetzt getrennte Aktionen für **Kamera** und **Mediathek**
|
||||
- Mehrfachauswahl aus der Smartphone-Mediathek ohne `capture`-Zwang
|
||||
- HEIC/HEIF werden beim mobilen Upload nach Möglichkeit automatisch in JPG konvertiert
|
||||
- OpenRouter-Free-Erkennung bewertet nur bildrelevante Preisfelder
|
||||
- OpenRouter versucht je Modell mehrere kompatible Chat-Request-Varianten und danach die Images API
|
||||
- Referenzbilder werden nur an Modelle gesendet, die Bildinput unterstützen
|
||||
- Klare Diagnose, wenn aktuell **0 kostenlose Bildmodelle** verfügbar sind
|
||||
- Bei Fehlern werden weiterhin alle passenden Free-Modelle nacheinander versucht; Auto fällt anschließend auf OpenAI zurück
|
||||
|
||||
## Vendoo 1.11.0 – LAN QR Upload, OpenRouter Fallback & Mobile Nav Fix
|
||||
|
||||
- QR-Handy-Upload verwendet automatisch eine erreichbare LAN-IP statt localhost
|
||||
- Vendoo lauscht für lokale Netzwerkzugriffe auf `0.0.0.0` und zeigt erkannte LAN-Adressen beim Start an
|
||||
- Neue Einstellung für `PUBLIC_BASE_URL` inklusive automatischer LAN-IP-Erkennung
|
||||
- OpenRouter probiert bei Fehlern automatisch weitere passende Free-Bildmodelle und danach – im Auto-Modus – OpenAI
|
||||
- OpenRouter-Bildgenerierung unterstützt Chat-Completions und Images-API als zweistufigen Fallback
|
||||
- Mobile Navigation vollständig gehärtet: Overlay, Close-Button, Fokus, Escape, Scroll-Lock und responsive Labels
|
||||
|
||||
## Vendoo 1.10.0 – AI Jobs, 1–3 Varianten & History
|
||||
|
||||
- AI-Bildgenerierung läuft jetzt über eine Job-Queue statt nur synchron
|
||||
- Mindestanzahl auf **1 Bild** gesenkt, Maximum bleibt **3 Bilder**
|
||||
- Neue Modi: **Model**, **Ghost Mannequin**, **Flatlay**
|
||||
- Listing-Editor zeigt jetzt eine **AI-Bild-Historie** zum schnellen Übernehmen früherer Generierungen
|
||||
- Settings erweitert um Standard-Modus für AI-Bilder
|
||||
|
||||
## Vendoo 1.9.0 – AI Provider Settings Center
|
||||
|
||||
- Neuer Settings-Bereich für AI-Model-Fotos mit Provider-Steuerung
|
||||
- Admin kann jetzt `auto`, `openrouter` oder `openai` für Bildgenerierung wählen
|
||||
- OpenRouter-Free-Modelle können direkt aus der UI geladen und geprüft werden
|
||||
- Safety-Flags, Standard-Preset, Variantenanzahl und Fallback-Modell sind in der UI konfigurierbar
|
||||
|
||||
## Vendoo 1.8.0 – OpenRouter Free Image Discovery
|
||||
|
||||
- AI-Model-Fotos unterstützen jetzt einen Provider-Modus `auto`
|
||||
- OpenRouter Free-Bildmodelle werden dynamisch über die Models-API abgefragt und bei Verfügbarkeit bevorzugt genutzt
|
||||
- Fallback auf OpenAI (`gpt-image-1` oder konfiguriertes Modell), wenn keine freien OpenRouter-Modelle verfügbar sind
|
||||
- Neue API-Route für verfügbare OpenRouter-Bildmodelle sowie Persistenz von Provider und Modell je Generierung
|
||||
|
||||
## Vendoo 1.7.0 – AI-Model-Fotos & Safety Layer
|
||||
|
||||
- Neuer sicherer AI-Model-Foto-Workflow für Kleidung ohne Person auf dem Originalfoto
|
||||
- 2–3 KI-generierte Zusatzbilder mit fiktiven erwachsenen Models in Studio-, Indoor- oder Outdoor-Looks
|
||||
- Precheck & Output-Safety: blockiert Unterwäsche, Bademode, transparente Erotik-Looks, Kinderkleidung und Quellen mit Personen
|
||||
- Neue Backend-Routen, Datenbanktabellen und Listing-Integration für Generator und Detailansicht
|
||||
|
||||
# Changelog
|
||||
|
||||
## 1.40.2 – Capability Graph Contract Hotfix
|
||||
|
||||
### Behoben
|
||||
|
||||
- `vendoo.module-manager` verlangte `settings.configuration`, obwohl `vendoo.settings` diese Capability nicht bereitstellte.
|
||||
- Der Plattformstart brach deshalb mit `MODULE_CAPABILITY_MISSING` ab.
|
||||
- `vendoo.settings` stellt nun den übergeordneten Konfigurationsvertrag zusätzlich zu `settings.application` und `settings.smtp` bereit.
|
||||
|
||||
### Qualitätssicherung
|
||||
|
||||
- neues Capability-Graph-Gate validiert den real aktivierten Gesamtgraph aller 16 Module.
|
||||
- `ModuleRegistry.validateGraph()` stellt Startreihenfolge und Capability-Menge ohne Lifecycle-Start für Tests bereit.
|
||||
- Architektur-, CI-, Release- und Deployment-Gates prüfen den realen Modulgraph statt ausschließlich künstlicher Testmodule.
|
||||
- keine Datenbankmigration, kein Reset und keine Änderung an Nutzdaten.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.2] - 2026-07-07
|
||||
|
||||
### Branding
|
||||
|
||||
- neues Vendoo-Hauptlogo in die App-Sidebar integriert
|
||||
- neues Branding auf Login und mobilem QR-Fotoupload integriert
|
||||
- vollständiges Favicon-Paket mit ICO, PNG, Apple Touch Icon und Webmanifest eingebunden
|
||||
- mobile App-Icon-Größen von 16 bis 512 Pixel ergänzt
|
||||
- Chrome-, Edge- und Firefox-Erweiterungen auf die neue Vendoo-Icon-Familie aktualisiert
|
||||
- Vinted Assistant und minimierter Assistant verwenden das neue Logo
|
||||
|
||||
## [1.6.1] - 2026-07-07
|
||||
|
||||
### Verbessert
|
||||
|
||||
- Dashboard ist jetzt der erste Menüpunkt und die Standard-Startseite.
|
||||
- Dashboard-KPI-Karten verwenden eine fließende Auto-Fit-Struktur statt starrer Sechserspalten.
|
||||
- Sparklines besitzen eine reservierte Diagrammfläche und überlagern keine Texte mehr.
|
||||
- Umsatzdiagramme werden bei Größenänderungen automatisch neu berechnet.
|
||||
- Dashboard-Charts passen sich an Notebook-, Tablet- und Mobilbreiten an.
|
||||
- Studio-Flow-Karten verwenden proportionale Bildflächen mit vollständiger `contain`-Darstellung.
|
||||
- Produktbilder werden nicht mehr abgeschnitten und erhalten einen sauberen Fehlerzustand.
|
||||
- Ready-Karten verteilen sich automatisch auf die verfügbare Breite.
|
||||
|
||||
## [1.6.0] - 2026-07-07
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- neuer Sidebar-Menüpunkt **Dashboard** mit interaktiven Kennzahlen, Umsatzverlauf, Plattform- und Kategorieauswertung
|
||||
- Dashboard-Bereiche für Fokusaufgaben, Top Seller, Aktivität, Benachrichtigungen, AI-Qualität und Systemstatus
|
||||
- Fotoarchiv-Endpunkt: mehrere Fotos werden serverseitig als gültiges ZIP erzeugt
|
||||
- Foto-Download im Generator und vollständigen Listing-Editor
|
||||
|
||||
### Geändert
|
||||
|
||||
- Studio Flow vollständig neu strukturiert: Bereit, Geplant, Veröffentlicht, Verkauft und Benötigt Aufmerksamkeit
|
||||
- die fachlich unpassenden sichtbaren Stufen „Neu erfasst“ und „AI-Prüfung“ wurden entfernt
|
||||
- Studio Flow zeigt nur priorisierte Artikel statt einer überfüllten Spaltenwand
|
||||
- geplante Listings, Probleme und Aktivitäten sind in klar getrennten Arbeitsbereichen organisiert
|
||||
- Dashboard und Studio Flow für Notebook, iPad, Tablet und Smartphone neu priorisiert
|
||||
|
||||
### Behoben
|
||||
|
||||
- Publish-Center lädt Listings beim ersten Öffnen mit Wiederholungslogik und verhindert veraltete parallele Renderings
|
||||
- Publish-Ladefehler zeigt jetzt eine konkrete Wiederholen-Aktion statt einer Sackgasse
|
||||
- bei einem Foto erfolgt ein normaler Download, bei mehreren Fotos automatisch ein ZIP-Download
|
||||
|
||||
## [1.5.0] - 2026-07-07
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- Browser-Erweiterungen in getrennten Ordnern für Chrome, Edge und Firefox
|
||||
- vollständiger Design-Relaunch von Popup und Vinted Assistant
|
||||
- neues Vendoo-Link-Icon in 16, 32, 48 und 128 Pixel
|
||||
- QR-basierter Handy-Fotoupload für Generator und Listing-Editor
|
||||
- sichere, zeitlich begrenzte Mobile-Upload-Sitzungen
|
||||
- responsive Layout-Gates für Desktop, Notebook, iPad und Smartphone
|
||||
- umschaltbare Plattformvorschau und HTML-Codeansicht
|
||||
|
||||
### Verbessert
|
||||
|
||||
- Produktbilder der Extension werden vollständig dargestellt
|
||||
- CSRF-Unterstützung für schreibende Extension-Requests
|
||||
- öffentliche Serveradresse statt festem localhost für Extension-Bilder
|
||||
- Plattformvorschau und HTML-Code sind in Generator und Listing-Editor als Umschalter zusammengeführt
|
||||
- Layouts für Desktop, Notebook, iPad und Smartphone neu priorisiert
|
||||
- freigegebene Extension- und Icon-Mockups als verbindliche Designreferenz archiviert
|
||||
|
||||
### Behoben
|
||||
|
||||
- Studio Flow: „Prüfen & bearbeiten“ wechselt jetzt zuverlässig zur sichtbaren Listing-Detailansicht.
|
||||
- Detailaufrufe aus Attention Queue und anderen Bereichen verwenden denselben sicheren Navigationspfad.
|
||||
- Ungültige Listing-IDs werden abgefangen und als Hinweis angezeigt.
|
||||
|
||||
- Windows-Installer behandelt npm `ENOTEMPTY`/`EPERM` robust, bereinigt unvollstaendige `node_modules`, protokolliert npm-Ausgaben und wiederholt die Installation einmal.
|
||||
- Installation nutzt mit vorhandenem Lockfile reproduzierbar `npm ci` statt eines stillen `npm install`.
|
||||
|
||||
### Geplant
|
||||
|
||||
- vollständiger Import des lokalen Vendoo-Quellprojekts
|
||||
- Docker- und Online-Readiness-Foundation
|
||||
- sichere Server-Konfiguration und Healthchecks
|
||||
- Mandantenfähigkeits- und Rechtekonzept für externe Benutzer
|
||||
- Lager, Vorlagen, Einstellungen und Admin gemäß Design-Freeze
|
||||
- Docker- und Online-Readiness
|
||||
|
||||
## [1.0.0-baseline] - 2026-07-07
|
||||
## [1.4.0-full-listing-editor] - 2026-07-07
|
||||
|
||||
### Dokumentiert
|
||||
### Hinzugefügt
|
||||
|
||||
- bestehender Funktionsumfang mit 17 Feature-Bereichen
|
||||
- verbindlicher Tech Stack und Projektstruktur
|
||||
- Sicherheits- und Repository-Grundregeln
|
||||
- privates GitHub-Repository als Versionsbasis
|
||||
- Listings lassen sich jetzt vollständig in einem eigenen Arbeitsbereich bearbeiten
|
||||
- Fotoverwaltung mit Hinzufügen, Entfernen, Cover-Auswahl, Drag-to-Sort und vollständigem Bildeditor ergänzt
|
||||
- WYSIWYG-Editor und HTML-Code sind im Listing-Editor bidirektional und live synchronisiert
|
||||
- Generator zeigt HTML-Code und Plattformvorschau gleichzeitig; Änderungen werden ohne Umschalten übernommen
|
||||
- Plattform-HTML wird während der Bearbeitung live neu gerendert
|
||||
- neue Datenbankspalte `description_html` mit Migration und Rückwärtskompatibilität
|
||||
- neuer API-Endpunkt `POST /api/html-render` für nicht persistierende Live-Vorschauen
|
||||
- Editor-HTML und vollständiges Plattform-HTML können getrennt kopiert werden
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Rich-Text-HTML wird client- und serverseitig auf eine begrenzte Tag- und Attributmenge reduziert
|
||||
- Plattformvorschauen laufen in sandboxed iframes
|
||||
- Listing-Updates verwenden jetzt eine feste Feld-Allowlist statt beliebiger SQL-Spaltennamen
|
||||
|
||||
### Behoben
|
||||
|
||||
- `photos` werden bei Listing-Updates korrekt als JSON gespeichert
|
||||
- Bildbearbeitung funktioniert nun auch für bereits gespeicherte Listings
|
||||
- formatierte Beschreibungen bleiben nach Speichern und erneutem Öffnen erhalten
|
||||
|
||||
## [1.3.0-publish-center] - 2026-07-07
|
||||
|
||||
### Geändert
|
||||
|
||||
- Publish-Bereich als durchgängiges operatives Publish-Center neu aufgebaut
|
||||
- Smart Copy, Direkt-Publishing, eBay Queue und Zeitplanung in einer gemeinsamen Oberfläche zusammengeführt
|
||||
- bildorientierte Listing-Auswahl mit Suche, Plattform-/Statusfilter, Sortierung und Mehrfachauswahl ergänzt
|
||||
- internes Workspace-Modell statt separater Queue- und Smart-Copy-Modale eingeführt
|
||||
- direkte Listing-Bearbeitung vor Etsy-/eBay-Veröffentlichung integriert
|
||||
- Publish-Bereitschaft, Pflichtfelder, Integrationsstatus und Fotoprüfung sichtbar gemacht
|
||||
- Queue-Status, Schnellaktionen und Publish-Aktivität in einer festen Statusleiste gebündelt
|
||||
- eBay-Fehler, Wiederholungen und geplante Veröffentlichungen inline bedienbar gemacht
|
||||
- responsive Darstellung und Dark Mode für das komplette Publish-Center ergänzt
|
||||
|
||||
### Behoben
|
||||
|
||||
- Batch-Smart-Copy öffnete denselben Vorgang teilweise doppelt
|
||||
- nicht passende Etsy-Batch-Auswahl wird jetzt gefiltert statt blind veröffentlicht
|
||||
- Produktbilder im Publish-Center werden vollständig und ohne Beschnitt dargestellt
|
||||
- Queue- und Planungsaktionen bleiben im Vendoo-Kontext und verlieren nicht den aktuellen Arbeitsstand
|
||||
|
||||
## [1.2.0-listing-studio] - 2026-07-07
|
||||
|
||||
### Geändert
|
||||
|
||||
- Listing-Generator als dreigeteilter Studio-Arbeitsplatz neu aufgebaut
|
||||
- Foto-Cover, Sortierung, Qualitätscheck und vollständige Bildanpassung integriert
|
||||
- AI-Konfiguration mit Plattform, Provider, Modell, Sprache, Varianten und Vorlagen neu strukturiert
|
||||
- artikelspezifische Verkäufernotizen mit Zeichenzähler ergänzt
|
||||
- Ergebnisbereich mit Live-Zählern, Qualitätswert, Attributen, Gebühren und HTML-Vorschau modernisiert
|
||||
- responsive Darstellung und Dark Mode für den Generator vollständig ergänzt
|
||||
|
||||
### API
|
||||
|
||||
- `POST /api/generate` akzeptiert `template_id` und `seller_notes`
|
||||
- globale Hinweise, Vorlagenhinweise und Artikelhinweise werden dedupliziert kombiniert
|
||||
- Standard-Tags aus Vorlagen werden mit AI-Tags zusammengeführt
|
||||
|
||||
### Behoben
|
||||
|
||||
- Upload begrenzt neue Bilder clientseitig auf die verbleibenden Plätze bis maximal zehn
|
||||
- Upload-Fehler werden mit HTTP-Status und Servermeldung korrekt angezeigt
|
||||
- Dateinamen in Vorschaubildern werden URL-sicher ausgegeben
|
||||
|
||||
## [1.0.1-ui-foundation.1] - 2026-07-07
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- zentrale Vendoo-Design-Tokens für Light und Dark Mode
|
||||
- neue kompakte App-Shell und Hauptnavigation
|
||||
- Command-Search-Einstieg mit `Ctrl/⌘ + K`
|
||||
- neue Startseite `Studio Flow`
|
||||
- Flow-Stufen: Neu erfasst, AI-Prüfung, Bereit, Geplant, Veröffentlicht, Verkauft
|
||||
- Attention Queue und Activity Stream
|
||||
- kontextueller Artikel-Inspector
|
||||
- neuer Endpunkt `GET /api/dashboard/workflow`
|
||||
- Dokumentation `docs/UI_FOUNDATION_2026.md`
|
||||
- sicherer `.gitignore`
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- `.env`, SQLite-Laufzeitdateien, Uploads und `node_modules` aus dem Übergabepaket entfernt
|
||||
- keine produktiven Zugangsdaten in der bereinigten Version
|
||||
|
||||
## [1.1.1-ui-polish] - 2026-07-07
|
||||
|
||||
### Geändert
|
||||
|
||||
- Etiketten und Artikeldatenblätter öffnen jetzt im internen Vendoo Print Studio statt in einem neuen Browserfenster
|
||||
- maßstabsgetreue A4-Vorschau mit zwei Etikettenformaten, Exemplaren und Preisoption
|
||||
- Druck erfolgt im aktuellen Vendoo-Fenster über den normalen Systemdruckdialog
|
||||
- Studio-Flow-Produktbilder verwenden vollständige automatische Anpassung (`object-fit: contain`)
|
||||
- Inspector- und Attention-Bilder werden ebenfalls ohne Beschnitt dargestellt
|
||||
- Vinted-Extension auf die freigegebene Vendoo-Designsprache 2026 umgestellt
|
||||
- Extension-Popup und eingeblendetes Vinted-Panel modernisiert
|
||||
- Produktbilder der Extension werden sicher über den Background-Service-Worker als Data-URL geladen
|
||||
- Pfade von Upload-Bildern werden korrekt URL-codiert und Bildfehler erhalten einen sichtbaren Zustand
|
||||
- fehlerhaft doppelt codierte deutsche Texte in der Extension repariert
|
||||
|
||||
### Behoben
|
||||
|
||||
- Produktvorschaubilder in der Vinted-Extension wurden durch Seiten-CSP/Mixed-Content-Konstellationen teilweise nicht angezeigt
|
||||
- große oder hochformatige Produktbilder wurden im Studio Flow abgeschnitten
|
||||
|
||||
## [1.1.0-ui-listings] - 2026-07-07
|
||||
|
||||
### Geändert
|
||||
|
||||
- Listings-/Historie-Seite vollständig an die freigegebene Vendoo-Designrichtung 2026 angepasst
|
||||
- neue Filterleiste mit Suche, Plattform, Status und Zeitraum
|
||||
- benutzerdefinierbare sichtbare Tabellenspalten mit lokaler Speicherung
|
||||
- dichte, bildorientierte Listing-Tabelle mit SKU, Plattform, AI-Provider, Preis, Status, Lagerort und Publish-Status
|
||||
- neue Batch-Aktionsleiste für Publish, Nachbearbeitung, Preis, Status, Duplizieren, Etiketten und Löschen
|
||||
- moderne Seitennavigation und Seitengrößensteuerung
|
||||
- neue Exportauswahl für CSV und JSON
|
||||
- verbesserter leerer Zustand und responsive Darstellung
|
||||
- bestehende CRUD-, Export-, Druck-, Status- und Papierkorb-Funktionen erhalten
|
||||
|
||||
## [1.6.1] - 2026-07-07
|
||||
|
||||
### Geändert
|
||||
|
||||
- Vendoo Assistant für Chrome, Edge und Firefox vollständig verdichtet und visuell neu strukturiert.
|
||||
- Queue-Karten zeigen Bild, Status, SKU, Preis und direkte Aktionen ohne große Leerflächen.
|
||||
- „Nur ausfüllen“ ist jetzt die primäre und sichere Einzelaktion.
|
||||
- Daten können in Vinted eingetragen werden, ohne automatisch einen Entwurf anzulegen oder zu veröffentlichen.
|
||||
- „Daten + Bilder ausfüllen“ überträgt Bilder und Felder, führt aber keinen Abschluss aus.
|
||||
- Entwurf und Veröffentlichung liegen bewusst in einem getrennten, aufklappbaren Abschlussbereich.
|
||||
- Popup öffnet Vinted mit einem vorgemerkten Listing und füllt es nach dem Laden aus.
|
||||
- Extension-Version auf 1.4.0 angehoben.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Vendoo — Projekt-Kontext
|
||||
|
||||
Du arbeitest am Projekt "Vendoo" — ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System.
|
||||
|
||||
Lies zuerst `FEATURES.md` im Projektroot — das ist die vollstaendige Feature-Dokumentation mit Tech Stack, Projektstruktur, DB-Schema, allen 80+ API-Endpunkten und 17 Feature-Beschreibungen.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Node.js + Express (ESM, `"type": "module"`)
|
||||
- **Frontend:** Vanilla HTML/CSS/JS — kein Framework, kein Build-Step, kein TypeScript
|
||||
- **Datenbank:** SQLite via `better-sqlite3` (WAL-Modus)
|
||||
- **AI:** Anthropic Claude, OpenAI, OpenRouter, Ollama (lokal)
|
||||
- **Bilder:** `sharp` (Resize, BG-Remove, Watermark)
|
||||
- **Rich-Text:** Quill Editor
|
||||
- **E-Mail:** Nodemailer (SMTP)
|
||||
- **Extension:** Chrome Manifest v3
|
||||
|
||||
## Dateistruktur (wo was hingehoert)
|
||||
|
||||
| Aenderung | Datei(en) |
|
||||
|---|---|
|
||||
| Neue API-Route | `server.mjs` — Route hinzufuegen + Import erweitern |
|
||||
| Neue DB-Funktion | `lib/db.mjs` — Funktion exportieren |
|
||||
| Neue DB-Spalte | `lib/db.mjs` — MIGRATIONS-Array erweitern (try/catch ALTER TABLE Pattern) |
|
||||
| Neues UI-Tab | `public/index.html` (Sidebar-Button + Section), `public/app.js` (TAB_TITLES + switchToTab + Logik), `public/style.css` |
|
||||
| Neuer AI-Provider | `lib/ai-*.mjs` + `lib/ai-router.mjs` erweitern |
|
||||
| Neue Plattform | `platforms/*.mjs` + `lib/categories.mjs` + `lib/fees.mjs` + `lib/html-templates.mjs` |
|
||||
| Styling | `public/style.css` — CSS Custom Properties fuer Theming |
|
||||
|
||||
## Konventionen
|
||||
|
||||
- **Sprache:** UI ist komplett Deutsch
|
||||
- **SKU-Format:** `VD-XXXX` (auto-generiert, 4-stellig aufsteigend)
|
||||
- **Navigation:** Sidebar mit `data-tab` Attributen, Tab-Inhalte als `<section id="..." class="tab-content">`
|
||||
- **Topbar-Titel:** `TAB_TITLES` Map in `app.js` muss fuer neue Tabs erweitert werden
|
||||
- **Dark Mode:** CSS-Variablen unter `[data-theme="dark"]`, niemals hardcoded Farben
|
||||
- **Auth:** Session-basiert, Rollen `admin`/`editor`, CSRF-Token bei state-changing Requests
|
||||
- **DB-Migrationen:** Neue Spalten als Eintrag in `MIGRATIONS`-Array (Zeile ~17-21 in db.mjs):
|
||||
```js
|
||||
const MIGRATIONS = [
|
||||
['spaltenname', 'TEXT'], // oder 'REAL', "TEXT DEFAULT 'wert'"
|
||||
];
|
||||
// Wird automatisch per try/catch ALTER TABLE ausgefuehrt
|
||||
```
|
||||
- **API-Pattern:** REST, JSON-Body, Fehler als `{ error: 'message' }`
|
||||
- **Frontend-API-Aufrufe:** `api(method, path, body)` Helper in app.js (fuegt Auth + CSRF automatisch hinzu)
|
||||
- **Toast-Benachrichtigungen:** `toast('Nachricht')` in app.js
|
||||
- **Quill-Editor:** `initQuill(selector, placeholder)` — gibt Editor-Instanz zurueck
|
||||
|
||||
## Server starten
|
||||
|
||||
```bash
|
||||
npm install # Dependencies installieren
|
||||
npm run dev # Dev-Server mit --watch (Port 8124)
|
||||
npm start # Produktiv-Server
|
||||
```
|
||||
|
||||
Oder: `setup.bat` fuer gefuehrte Installation.
|
||||
|
||||
## Wichtige Dateien nach Groesse
|
||||
|
||||
| Datei | Zeilen | Inhalt |
|
||||
|---|---|---|
|
||||
| `public/app.js` | ~3240 | Gesamte Frontend-Logik |
|
||||
| `server.mjs` | ~1250 | Alle API-Routen + Middleware |
|
||||
| `public/style.css` | ~1200 | Komplettes Styling + Dark Mode + Responsive |
|
||||
| `public/index.html` | ~845 | HTML-Struktur (Sidebar, Tabs, Modals, Forms) |
|
||||
| `lib/db.mjs` | ~390 | Datenbank-Zugriff (alle CRUD-Funktionen) |
|
||||
| `lib/categories.mjs` | ~360 | Kategorie-Baeume pro Plattform |
|
||||
| `lib/auth.mjs` | ~290 | Auth-System (Users, Sessions, Audit) |
|
||||
| `lib/ebay-api.mjs` | ~270 | eBay REST API + OAuth2 |
|
||||
| `lib/mailer.mjs` | ~210 | SMTP E-Mail-Versand |
|
||||
| `lib/etsy-api.mjs` | ~185 | Etsy REST API v3 + OAuth2 PKCE |
|
||||
| `extension/content.js` | ~1170 | Chrome Extension Content Script |
|
||||
|
||||
## Aktueller UI-Stand
|
||||
|
||||
- Version `1.3.0`
|
||||
- UI 2026 Slice 05: Publish-Center
|
||||
- Smart Copy, direkte Veröffentlichung, eBay Queue und Scheduling arbeiten in einer gemeinsamen Oberfläche.
|
||||
- Interne Publish-Arbeitsschritte dürfen keine separaten Browserfenster oder redundanten Modale öffnen.
|
||||
|
||||
## Bildarchitektur ab 1.23.0
|
||||
- FLUX/ComfyUI ist der einzige lokale generative Bilddienst.
|
||||
- Das FLUX Studio arbeitet textbasiert und getrennt von Produktbild-Werkzeugen.
|
||||
- Keine VTO-, Python-, Torch- oder ONNX-Laufzeit wieder einführen, solange kein neuer ausdrücklicher Architekturentscheid dokumentiert ist.
|
||||
|
||||
- FLUX-Studio-Aufträge und Varianten sind in `flux_prompt_jobs` und `flux_prompt_job_variants` persistent.
|
||||
- Fortschritt im FLUX Studio wird als reale Phase dargestellt; keine aus Laufzeit geschätzten Prozentwerte ergänzen.
|
||||
- ComfyUI-Prompt-IDs müssen für Abbruch, Historie und Neustart-Wiederaufnahme erhalten bleiben.
|
||||
|
||||
- Der Advanced Image Editor arbeitet nicht destruktiv: Originaldateien niemals überschreiben oder löschen.
|
||||
- Jede gespeicherte Bearbeitung muss eine neue Datei und einen Eintrag in `image_edit_versions` erzeugen.
|
||||
- Bildpfade müssen strikt auf `uploads/` begrenzt und gegen Traversal geschützt bleiben.
|
||||
- Hintergrundentfernung, Inpainting, Outpainting und Maskeneditor nicht als Platzhalter ergänzen; diese Funktionen benötigen eigene spätere Slices.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-bookworm-slim AS dependencies
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8124 \
|
||||
VENDOO_BIND_HOST=0.0.0.0 \
|
||||
VENDOO_DATA_DIR=/app/data \
|
||||
VENDOO_DB_PATH=/app/data/db/vendoo.db \
|
||||
VENDOO_UPLOADS_DIR=/app/data/uploads \
|
||||
VENDOO_BACKUP_DIR=/app/data/backups \
|
||||
VENDOO_OPERATIONS_DIR=/app/data/operations \
|
||||
VENDOO_LOG_DIR=/app/data/logs \
|
||||
VENDOO_CONFIG_PATH=/app/data/config/vendoo.env \
|
||||
VENDOO_MODULES_DIR=/app/data/modules \
|
||||
VENDOO_MODULE_STATE_PATH=/app/data/config/modules-state.json \
|
||||
VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --chown=node:node . .
|
||||
RUN mkdir -p /app/data/db /app/data/uploads /app/data/backups /app/data/operations /app/data/logs /app/data/config /app/data/modules \
|
||||
&& chown -R node:node /app/data \
|
||||
&& chmod +x /app/scripts/docker-entrypoint.sh
|
||||
USER node
|
||||
EXPOSE 8124
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:8124/readyz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||
CMD ["node", "bootstrap.mjs"]
|
||||
@@ -0,0 +1,544 @@
|
||||
f344f75df655bc133e6c4abb2d215b2234fb22a4bb155c217317c36ee4f0f8fb .dockerignore
|
||||
aa343f8ee7948e6b87603e39cf208f8fd1c42a023f1c2261c708c0cd8a46faba .env.example
|
||||
58e3f4b76cc9398f32dcd5481b2080e7e5ba322aca04b8b8374446299c5d7dd6 .github/CODEOWNERS
|
||||
5bd335eb750d084ca12b6a3b3f35d0cd7bc454ba24ed280187c66acf75eaf396 .github/dependabot.yml
|
||||
f65684e98b01d0e0b6bfe1284b5de3354559e4cd51e634fe9e9e2e5473b890e5 .github/pull_request_template.md
|
||||
505e50970bda1156b91a2f3f5031877495e95b63db06129910ac7326c14c9c92 .github/workflows/ci.yml
|
||||
a5b12e2d6bd326034c5c45ff137b649aee580dccc9cf4a41c9d6cc44e81c1b0f .github/workflows/release.yml
|
||||
851ad84ebaa6d07af5fe975b3461c2402e7315a294ae29f0cd1a5f13cb7fc44f .gitignore
|
||||
9bfa732952ad571d06527b32d96d79133fc23fa607df00de440b2802f543608c .npmrc
|
||||
9d7c2a587c12f2b12ba2923e367ade8cdd4601dcac800ca2ac34afb302e9a6e1 app/architecture-boundaries.json
|
||||
a6bdb9b6fdc97a0b2bbc052bd242a9885a75bcc46ee4f0414d8b7e215b6940de app/contracts/module.schema.json
|
||||
67fcafb51d5d17a83a04e55ecf877c8d9ac928d11a1951d0395f137ca6913401 app/core/identity/identity-store.mjs
|
||||
7cf2224714a10e337b4e8e6514de56d61200d27632440ce85c2ba20ccd7e9d7f app/core/modules/module-package-contract.mjs
|
||||
1ac6208b0cc85a3b5e2f86d61bb59a7a39fd46f51b4d8278c2abe236b53fdab5 app/core/modules/module-package-store.mjs
|
||||
c2f4ebceb06aa40d75c456f7448225436cedb8590228faff6da8f8107987e1b5 app/core/modules/module-state-store.mjs
|
||||
ce74a28bb663f0dcfa9f11cd4375f763db088ee66128f64b90bb91e99f732642 app/core/security/secret-contract.mjs
|
||||
7f38b3ac792e61e030c9f69355e2378f8f61e8e61c7b29c82cd0fa7ec930cfec app/core/themes/theme-contract.mjs
|
||||
d84a573699e413e0a0803459e0d05c5c4e6e35d3499eb634f95302d151e75c4b app/kernel/errors.mjs
|
||||
a34333cbc784d39b39a8071a437972c6175c551104dc2d82fc4c0e0506b18259 app/kernel/event-bus.mjs
|
||||
0e8f691f96e1ef0a7a86d32cae7b7e1db04325fe94a32b8c1f484a5b6950f3b4 app/kernel/feature-flags.mjs
|
||||
a4877c90f2c9040cb101b7fbb80385d4b1ea0814118a104873b891a580e88204 app/kernel/input-schema.mjs
|
||||
a0223a00691908862aaf1fd9383b6d86d67f26325c509280b8c2daa5cfcee28e app/kernel/lifecycle.mjs
|
||||
ba1f4f799a508ff113738cce7ac9f5af27739cb60d7f04d7a6c5878de3c5b7f4 app/kernel/module-contract.mjs
|
||||
4da6fa8b6d1a3aa345c924f426c405faecba9dcfde5983f74de45df4eb233430 app/kernel/module-registry.mjs
|
||||
35b305ae93b2d47ddf472e33a6ba7b285b0091926f977a03b37baf2d7ac85e10 app/kernel/platform-kernel.mjs
|
||||
7944f68d995a5d70273f8a985973f668fc62a414d85636d92f3774e34d39a37f app/kernel/policy-engine.mjs
|
||||
f98e4cd58b051efdf311dafa73569b1ecebe2a29431cd763380c9663035c1c2b app/kernel/route-registry.mjs
|
||||
0ff879fb8cf392a3786c39f2541637b2d9f018fb03941066ed540a2880134f89 app/modules/ai/index.mjs
|
||||
98e93589109cb34c7af71215a581fe40e80691884459c2d42260f6b001065e6e app/modules/ai/module.json
|
||||
3ae563b7e6232ed89f5bc5dc49fe359ecfd696123aee2cb780032040c53f149e app/modules/auth/index.mjs
|
||||
cae01362c58adac32badb0b102ce76a748c7301aafa64818a49456665b4de29d app/modules/auth/module.json
|
||||
04a72689ecea04caf79eec9207116d1da374600150fe1966032fe972736f246f app/modules/auth/routes.mjs
|
||||
53917fe8aa4832cc62f35d4746af9bfdfd1132448de0ca750f073a0a27cdbebb app/modules/auth/service.mjs
|
||||
7580002b0f9236b23c0c4193b6e65c761db93bb30fc3277d762d9674f04250c4 app/modules/catalog.mjs
|
||||
a3f88366d1f765c93f39e1f6a5b40da0690168d713be390ca6afaa28cbe97d2f app/modules/deployments/index.mjs
|
||||
50ae28b3efe9296deb32399b35acc6cb27ead17a2f7e1526ff05c5e4da664ac0 app/modules/deployments/module.json
|
||||
2f45596aa2cc7f5e532c3624156c5bdebfb0d79f94081990b06a33d4fca941af app/modules/deployments/routes.mjs
|
||||
a91940a47181e148ab24040e807cedc281682abf6d4e8d7a14b2f27bdc9ab8b2 app/modules/deployments/service.mjs
|
||||
99bde2c2871c48ff9e1d2895157197d9f1d5e194072a481abaa3495daaa683e0 app/modules/flux-studio/index.mjs
|
||||
5f28a104ef923c0f01cf71d765cb603e3c9bf7a30d087321f58161d701896024 app/modules/flux-studio/module.json
|
||||
fd798d294308dcb50d5ce90d3cfb969800cd1dfecf4805e69ec41f974e268829 app/modules/inventory/index.mjs
|
||||
febca770c3ee71c398bec9e6afc3aaaa8850ce8c9a2840c190d93b2e8d25f790 app/modules/inventory/module.json
|
||||
6925da30bd453695b82c8fcbc82c585b01cda71c410f59d62697a31c364954ae app/modules/inventory/repository.mjs
|
||||
b3d0c8a4643ff09091d48c4e1dfcd90ed0884fe9713a7bb44160c34761b59590 app/modules/inventory/routes.mjs
|
||||
94f3e5a3e6f3ed1120dad7c869b47978582717e231b57ae4c98e668d903f09bc app/modules/inventory/service.mjs
|
||||
fbf8db46e0ae7b483f898588c39a4f74d72717588d93d2058ab9250f9ada0c88 app/modules/listings/index.mjs
|
||||
79b21e2387b59eec5a23b3d547d954ddc1edf99e9ab691f6e410894811f1978c app/modules/listings/module.json
|
||||
a76054a2eca06d8412c211fc8b75f7fbd9cb1ed256f38360a7031b1f8ed7d73c app/modules/listings/repository.mjs
|
||||
d1e817f0c4251994bad0a7084e2e7dcd2873a0a099cb00a312f55aa2163c183d app/modules/listings/routes.mjs
|
||||
e7c722e449284d8ec9ade461e2b8fc94723e35ebec25c51113b40643d12f28ef app/modules/listings/service.mjs
|
||||
e12663a5b189660e34d72e034fa4b23b5bffdb0c8d5516b547020da11e70aefa app/modules/media/index.mjs
|
||||
4efc183346859a6786250d40a9762f7457e00f659a7b2408209d85f3ef75d1c2 app/modules/media/module.json
|
||||
67262d67fa85e5f2e09a51645432a2c7548a6dccdb0afbaaf63850e2fab67197 app/modules/media/repository.mjs
|
||||
9f8e54cabbb7271bac7253a26b6ffbf553f834147500141c9e7427a26c9dde81 app/modules/media/routes.mjs
|
||||
29a4e515e6ccc692b9cac78012e9571c4d6225f7e89bee2d4fefaf63f4d0fc3a app/modules/media/service.mjs
|
||||
9d2afb4ba28a144b7f75c6c409f43d2f7a34f2674c8a1f717c2ab631516b0cca app/modules/module-manager/index.mjs
|
||||
c4ab8673081ba03068cc5c76c1a0ea35c61771ad69de97cd1510dfc35f9bd93a app/modules/module-manager/module.json
|
||||
b4250e7028bc1ff041166f4099a05e71fd4e0f71abaeee09ffdd3678ce9c52c7 app/modules/module-manager/routes.mjs
|
||||
028fa76b052de388836ba75c14f45bcae67c9a99af6d727406674ee1fc7a4434 app/modules/module-manager/service.mjs
|
||||
9723a9b12a9b472b2ed8c4635cdce663b2f0f65643d8ae0b6219715d853f740d app/modules/operations/module.json
|
||||
8a2805286def5d0521765319eddcd44e680e32bd7065d45749edd31406228463 app/modules/publishing/index.mjs
|
||||
53e1dcab6bb9c5046eba6daa17c0f1ffb193bed38ae248dd1702fee91bdd23bd app/modules/publishing/module.json
|
||||
306a2c3c119b82f6f972fa503099705bd3ede6c2061f532a00dc208f0584445c app/modules/quality/index.mjs
|
||||
61381a51b86b64d59dde8c1a749ae46b7990ec323adc71c7eb62f741eeb6f807 app/modules/quality/module.json
|
||||
747febc12aec7d81ccfdf6e9ea7158655ef5fdc536c519ce447de329314612a8 app/modules/security/index.mjs
|
||||
700be862cd70be6947919c1e1f0e25e7e88c313b1fcdddc104d54d88a74edbb2 app/modules/security/module.json
|
||||
08a6015cd3588f08070d341ad8cc5c9be8e34d217cf8770e154d4571225b3e2e app/modules/sessions/index.mjs
|
||||
da856dca2a4e2533a8a224806ccf10f792ed46e8266505a27339cf27e8a1edda app/modules/sessions/module.json
|
||||
c2ed5ccf36a0c5398f3113cd79172bed917dfb6c9668f025e806e38d74f8c83b app/modules/sessions/routes.mjs
|
||||
a33995a82e02beaaa820bebc2875d14d684b1d4baeeb79d27dc7237c899f3249 app/modules/sessions/service.mjs
|
||||
edc35b82678ef00e24fc6cb6a6d135bbca9acc980a17e59b15ba90b0a79490de app/modules/settings/index.mjs
|
||||
a9eb8f3d9e1fe5c271c10f340885b840d8f4a8cb0f79825d0d36d497c9347914 app/modules/settings/module.json
|
||||
7b7ed59760da0338037b2d5038a9b422a62b4a818c62a74216d2fce215ac6490 app/modules/settings/repository.mjs
|
||||
3717914c11da56a54dd0a22d139830a064f4c842c0f718517570470ecb9ed551 app/modules/settings/routes.mjs
|
||||
07dcb652661f9c879877a5d0033f4388584b2c3a2eef58488c88cd7c5cadd78e app/modules/settings/service.mjs
|
||||
452a5262c418b393bdd8754fe90f9ea14edf61338f8dcfd6438f0852920f608c app/modules/system/module.json
|
||||
92d968c993ee5a2afc14df557b7cba552cae9a41722989007eb0b784514333ff app/modules/themes/index.mjs
|
||||
1e062db871f9bf13b487c972f6cd4dd9cfe1b3046cd9b53ed43e0a0babc9d285 app/modules/themes/migrations/001_theme_profiles.sql
|
||||
2af572390890e4d793aea9c1356c835917655c225a7848325d5a7c5ae3c08a09 app/modules/themes/module.json
|
||||
3c3dc5974976672cd0e9141867a8a1d78a0dfaa54582bafb8f360bea571bf11a app/modules/themes/repository.mjs
|
||||
4e2c348a184384739408894f26b02c56c6bfd4dd531eebb9c67d2fb7fbad3009 app/modules/themes/service.mjs
|
||||
68cf3325307263022cd5704fd77664f7269896b8aa0570033c84b7abfba993ed app/modules/users/index.mjs
|
||||
c6558b8a0cd9d7ef30d0e259747805e3d91a5359c2dbfbd79e6794bb403a9926 app/modules/users/module.json
|
||||
8640accd1550932459ecd14738139284cbcdd93f31a61ab6e7748791fc82933f app/modules/users/routes.mjs
|
||||
fc4af51a35fd08289ec4380a78c06b3236a2469041ecfc911b9e0d7d269afa19 app/modules/users/service.mjs
|
||||
c197ce417fbce6dab1d8ee157b0a105a099dd2021ece4dac3891d0b2022a0d58 bootstrap.mjs
|
||||
c525080bcf08aa43d694f5075c43c7ad9c06901b0aed453695b343393f7a8b9f CHANGELOG.md
|
||||
61823fc9fdc036170cce6edeecbb612b442d1c65fc9f7a7eaba47e5016316ee5 CLAUDE.md
|
||||
61199cecda2c84954de2b60c07eb9f3a2117fbaaa4c430319c461b2e76a10254 compose.bind-mounts.example.yaml
|
||||
b8167851bcdbefa4a22e143cc6de8ab4441d3970752629bb2d8fdb03984f2904 compose.vps.yaml
|
||||
b76a40463ce31202dbdc85db371eb9c6fcafce8cd263c685dac990311dbd50f4 compose.yaml
|
||||
6323f984f1e04ab135356a14ec151941638041f6b305ed68772d5e1812972075 db/schema.sql
|
||||
49690c4e4c8e84de8eb40ffe79823493b7be20fe46fa2ad9e6544f7f0afbfd2c deploy/Caddyfile
|
||||
4ffc43e64b0e1e51a97d3f7f6a344ebcff537395008cf909c0fc4e3d3ab0c0cf deploy/coolify.env.example
|
||||
6ce9932a68950033c7afc0969d4f9d75c9d26d1e536465e9cb5568bcd666268a Dockerfile
|
||||
cc794bca282d79a27d860d0bfef4641769f4e9f31172778a1d9d8205558d948e docs/ABNAHMEBERICHT_1_32_0.md
|
||||
de0d86f806a70195050613f5b56f6c4da1ee2eff02a9a991b6aacee229c46ca5 docs/ABNAHMEBERICHT_1_33_0.md
|
||||
947745174897470de0bebf4f7c2b29a0c0ef3f1a762f277374c2614d9e3dec4f docs/ABNAHMEBERICHT_1_34_0.md
|
||||
26c87fa38f804749a9363812127dc33ef69e201e4ef9b59206f6fe6e15c845b0 docs/ABNAHMEBERICHT_1_34_1.md
|
||||
f792d366c13c5a4a9b4e8d4054e26190d9c327a7a770005d74bbe2b31bb84ab3 docs/ABNAHMEBERICHT_1_34_2.md
|
||||
bc638fd21222ada7b15e40adff80a318aebec58139978109cbfd30040815a8d4 docs/ABNAHMEBERICHT_1_34_3.md
|
||||
c0ae5c4176ba6f7510be047d4feaf383cd646152131003ebccc6bfbf8c8056f5 docs/ABNAHMEBERICHT_1_35_0.md
|
||||
c050c8e11f2bdb0a742851423dcff864bafba9cc839c52b2f8d48b75f7b18633 docs/ABNAHMEBERICHT_1_36_0.md
|
||||
1f3e96ba13844a3f1cefecca99b3d6313aebf86faae914897db0404ea031c491 docs/ABNAHMEBERICHT_1_37_0.md
|
||||
361d19e6b7aeab3f22b337210f3a56221f2e42798c0fea75be978ebb7d72487f docs/ABNAHMEBERICHT_1_38_0.md
|
||||
2f6518fd50d9088f5580cfcc03e92543321ac7feb97dcdd570c23c51fd8c5391 docs/ABNAHMEBERICHT_1_39_0.md
|
||||
513144e961bcc6d3a6c6a5f82b80b24733f20cd2e0d14259b500b426a4f9dee6 docs/ABNAHMEBERICHT_1_40_2.md
|
||||
1439ecb811af31dc2d54ca63f0521ad445e30940fae91ac7d23adc352c8332ad docs/ABNAHMEBERICHT_1_41_0.md
|
||||
b23f235ef4ddf3bbc0fde848fefe9bdcc4b90d1efb25dc61d51cf7cb295415ec docs/ABNAHMEBERICHT_1_41_1.md
|
||||
2f199ee66a97bbec2671d528f3b22c4583530644f7c16a4820fbe9c05e731bbd docs/ABNAHMEBERICHT_1_41_2.md
|
||||
b39d2d892068e1313fc00c9345d54240b6d50caf8a940261a088e67d8778ac63 docs/architecture/CATALOG_MEDIA_MODULES_1_39_0.md
|
||||
e5f065ba8d516a5ebd47e7e6859d0aab40ef69797413e358d969ef27c454fcdd docs/architecture/DESIGN_SYSTEM_1_35_0.md
|
||||
732ef23db1ef50862a423841f58e4628ad3b2a2ef2744ed7c5ef1fe01e3e38d1 docs/architecture/IDENTITY_SETTINGS_MODULES_1_38_0.md
|
||||
cc0edf92756a51414563b4407dedb21d1e3b36682f4bfbbc334aeb33a22ae1b4 docs/architecture/MIGRATION_ROADMAP_1_35_0.md
|
||||
db659979dfd6e08f36a7aa9b4de42750ac39c536b11fad694a1846c902ea65bf docs/architecture/MODULE_MANAGER_1_40_0.md
|
||||
eb47b055dd336e8360ec7c4712dee4ae37f109f3820e3f185969e2327d790cf8 docs/architecture/MODULE_SYSTEM_1_35_0.md
|
||||
d7773faac0e93510a5a51c91de997df03547b51f3124267dcf137d4e99299562 docs/architecture/SECURITY_ARCHITECTURE_1_35_0.md
|
||||
58cd43938b06e29520b92516a1e816d17d2c08d4847b99c1ee700b2185f46f20 docs/architecture/SECURITY_FOUNDATION_1_37_0.md
|
||||
771c4771c4d7335750c5a2d976c765b4ed66ed936d8550d7af69de14a16705bb docs/architecture/THEME_MANAGER_1_36_0.md
|
||||
67a1be91b0c2d2797716f785f2f770c881cf88d7fd617722468df8d949d0638a docs/AUDIT_1_31_0_IST_UND_LUECKENANALYSE.md
|
||||
2acba8fcc5da42e5a808925a86628bc0c6fc817ddb082b0ead3857c6a43460f1 docs/BRANDING.md
|
||||
2b833d05defcb782eea32fe5cb1b69d03322677a7babf6a36ade616da18fd6ab docs/COOLIFY_PRODUCTION_1_41_0.md
|
||||
f23c643236c84b0e3715620598f358065748b6d090229003e23df8ad9320b54e docs/FULL_LISTING_EDITOR_2026.md
|
||||
ba9a9b93622fadc399cd9067d80b4ea339193eb19825106264a3b86b7128d107 docs/GENERATOR_STUDIO_2026.md
|
||||
54f45ae5c2f12b6314b3f1bc3e59e753c64ca7cfe39644725d765e9f02f637ef docs/GITHUB_DEPLOYMENT_1_34_0.md
|
||||
e5c6ac33f5c0dc93099148d95af123a9aeb72298d885cabf0b1dc96a9cad31d7 docs/GITHUB_IMPORT_STATUS_1_39_0.md
|
||||
509fd2822e4c547459fef9a54054d583dc21adb1b1101eda92603f1cbb783604 docs/GITHUB_IMPORT_STATUS_1_41_0.md
|
||||
8e4ee3c837df6e929a819491b977123872191c8e4f041f8598c9479df10d28fa docs/GITHUB_IMPORT_STATUS_1_41_1.md
|
||||
16effe96702a6951b6cf3bb06da052420a492024c252a88b69256a3f4a92eccf docs/GITHUB_IMPORT_STATUS_1_41_2.md
|
||||
5a25e952d7d79b562bc19c990451830ea2ac3e730c9af399425ea18ffb8fd61d docs/GITHUB_IMPORT_STATUS.md
|
||||
65b7c1cb3a300fa8209628f32d843d1cfba7baaae1f1078bd3cfc1737443df92 docs/GITHUB_MERGE_RELEASE_1_41_0.md
|
||||
65d19c1576f4a6011008a7e447a3530222517752fb59ebc5bbb506b791e32605 docs/HOTFIX_1_23_1_EDITOR_CACHE_VINTED_FIELDS.md
|
||||
c9c8f934a0c4192dae29df913588188aab1321364d1c46b9c66da5c754e9df41 docs/HOTFIX_1_23_2_FLUX_EDITOR_JOB_COLLAPSE.md
|
||||
2306ff399000c07a9d7e260a58d92c748fa3219922a4dda25f6b2121a1ad2dbc docs/HOTFIX_STUDIO_FLOW_DETAIL.md
|
||||
0584257245a29de62a6374eb8b114bd19fc6a36306ed990d820ed2062514d6a6 docs/INSTALLATION_1_32_0_WINDOWS_DOCKER_NAS.md
|
||||
7c1afdefd1e3a75a5d6b49aedae80e266bab4e91cbfc657fb862ddfd737dc792 docs/INSTALLATION_1_33_0_MULTIPLATFORM.md
|
||||
1475e484cccb0db67e02abbb05f8b8e676c85536ec4f5bda7dd99d4563fbc049 docs/INSTALLATION_1_34_0_ZERO_EDIT.md
|
||||
1df2aed413865c03e541e66249c1df3eed6d3c3aea9f0ffdeaee40453665df14 docs/LISTINGS_UI_2026.md
|
||||
4f74fef6877126d2579356b9731955e29f05ea8b55b59abb91569e47730a1c3f docs/PUBLISH_CENTER_2026.md
|
||||
bae236366d6d2cb0139e4dbf8baf099599bbff27aacc9eeeba20fdff644f5fb0 docs/RELEASE_1_24_0_RESPONSIVE_MEDIA_FLUX_ARCHIVE.md
|
||||
5223408e51f35ba5f6ce86155544311dd13f60522a6d482f372d64788ab466ac docs/RELEASE_1_24_0_VERIFICATION.json
|
||||
b66524ffd19976f650c6b26b299898b182355950f545c131de16c89fcffe0ebe docs/RELEASE_1_25_0_RESPONSIVE_GALLERY_SMARTCOPY_PROMPTLAB.md
|
||||
1c5ebf1a496c9571fe9d941793371f9f0be89d85a587c8fa9532f9b25bba92bc docs/RELEASE_1_25_0_VERIFICATION.json
|
||||
1eef86e13d9429701e9d60894f36fe0eba82c974565b1a6687ed157ca6da680e docs/RELEASE_1_25_1_ADAPTIVE_GALLERY.md
|
||||
14475ae4169f4bde4a5b7ae5512867f972ede69dcb621d7cc6a208d4fdc6a331 docs/RELEASE_1_25_1_VERIFICATION.json
|
||||
f02b80194f434dd3d82b5c4c111e8cae65f1f053a267cec2793a1ff551daff9a docs/RELEASE_1_26_0_ARTIKEL_EXTENSIONS_PUBLISH_GALLERY.md
|
||||
570522a6513134227122da1b5f2f56a421c62d3b0b5adba703872e9026fa2b58 docs/RELEASE_1_26_0_VERIFICATION.json
|
||||
4cfc47f35765259cbd6a841f51a85fa86600991d1ca545b10756f15b6d332f7f docs/RELEASE_1_26_1_STABILITY_GATE.md
|
||||
9646b913994b1bf8ce03eab57e7a6a87061540e44fdde63ad5b7eee93ca9f475 docs/RELEASE_1_26_1_VERIFICATION.json
|
||||
7c6c3a2e4ce478f47e540a34a3505dbbe11b8f2175d0b274bec7643457997140 docs/RELEASE_1_30_0_OPERATIONS_CENTER.md
|
||||
6f9c206869ba94c461b1d9f8e40847792b8891bb27809a0c6fa3d1d68a4e8598 docs/RELEASE_1_30_0_VERIFICATION.json
|
||||
5da62e06d768ce832d18fb3ecb3ccca2f051a6218d2d3cb492d1e5aebbcccb52 docs/RELEASE_1_31_0_MULTIUSER_SECURITY.md
|
||||
6288a31dfa61f71d13f79d40ab64bb7ca475de07ada154c002cd4b0d44a284dc docs/RELEASE_1_31_0_VERIFICATION.json
|
||||
4ee88ff8c3c655cb31bc18192a37ffdd0eedf8e26039b252094d5ce169c1883c docs/RELEASE_1_32_0_DOCKER_NAS_PRODUCTION.md
|
||||
628d4d1a6c7d837a0c8c22958253193642ddfeaab5f425f418f9f32b46e84340 docs/RELEASE_1_32_0_VERIFICATION.json
|
||||
624919ea60969079f767068771800dbc04ea7a87eb1a53d7f0497447492c25cb docs/RELEASE_1_33_0_MULTIPLATFORM_DEPLOYMENT_FLUX_ADMIN.md
|
||||
3f9ae39792bec600ddd534284ae62cb2b4f9db01fb54c09cee8ae161551caa2a docs/RELEASE_1_34_0_GITHUB_ZERO_EDIT.md
|
||||
c724b699894d5ec3c050af0253c6de0c645a789219a344ad7ffe31e330d9921b docs/RELEASE_1_34_0_VERIFICATION.json
|
||||
7caa9e7b1e51af362332e9c50165b60c54c9219c7384c8313d3735c387df1c01 docs/RELEASE_1_34_1_GIT_SAFETY_WINDOWS_HOTFIX.md
|
||||
ac834ee57ab6bd8fe3dd0238837193e66e01d7e78590a289347b002591da3fb4 docs/RELEASE_1_34_1_VERIFICATION.json
|
||||
51057d17e2636ef1bdfc6751dfad29ebd34fbe7f41b870f8f2f756a5c97d1c4a docs/RELEASE_1_34_2_GIT_SAFETY_DIAGNOSTICS_STAGING_FILTER.md
|
||||
4c2c141ffc85b1d7517672abf2b28bb19fe4769505c0eb4d37e22bae06826cb3 docs/RELEASE_1_34_2_VERIFICATION.json
|
||||
d55eaec79e10800a930fa030367ffa34037755e5757bc532e72d25711c5ca09a docs/RELEASE_1_34_3_UPDATE_ARCHIVE_EXCLUSION.md
|
||||
4bb731af51e786a4827ec48bc7c4d4843c287456aedc09d8b74aa0eed3c0ec16 docs/RELEASE_1_34_3_VERIFICATION.json
|
||||
e7bb3bb950e3c5303746f43139ca8f7c985d2d73896b44f5b707dd46a9152529 docs/RELEASE_1_35_0_PLATFORM_KERNEL.md
|
||||
33eb9f0059a2ddc4b4debe5bf733720f2a5bb22f2710bab0a0cc9ac1b898ff11 docs/RELEASE_1_35_0_VERIFICATION.json
|
||||
5a9fcfa0ea6e9c5c13f6d943e1de4d31c21e21fcff9186e8a714d7a16ed1c18f docs/RELEASE_1_36_0_SECURE_THEME_MANAGER.md
|
||||
2dfe705857424865f101e861f4e633c8a7fb3c0e8dab874cb6620fd09b1ca902 docs/RELEASE_1_36_0_VERIFICATION.json
|
||||
527806c00fb51334457726a9a7e9f6fc13f4dacd985fa2d2b97d5eb21c543853 docs/RELEASE_1_37_0_SECURITY_SECRETS_OBSERVABILITY.md
|
||||
c3a8a21f5a3c3edb05ab1762a06d05b7f6eced3be84cb4f6f5e7f31bc627f06b docs/RELEASE_1_38_0_AUTH_USERS_SESSIONS_SETTINGS.md
|
||||
c17771f19c2dfd6629fb91b66dfd661eed3459f820b71a427f0f98b07b45c56d docs/RELEASE_1_38_0_VERIFICATION.json
|
||||
26355d15c188b1ce3bd03a539e1b27ee884352db48e5db423f60cdcd4d9d49f3 docs/RELEASE_1_39_0_LISTINGS_INVENTORY_MEDIA.md
|
||||
c016becfaa0c1ae5bcee255476dd2a09fd465308f5ec2fba6d3c6809b009538b docs/RELEASE_1_39_0_VERIFICATION.json
|
||||
837ac91a926330b800a027a61d4a2aa0386cfd61f9b7949231bbb09e1ca0e6c3 docs/RELEASE_1_40_0_MODULE_MANAGER.md
|
||||
e52467c05ad08a262ff812989f2be19bfad3038f35f61e9deabcbf61b81fa5df docs/RELEASE_1_40_0_VERIFICATION.json
|
||||
dd77564536e5d85eaa76c7a429a61f73ac78547d72734620bc4102b3452cd7b4 docs/RELEASE_1_40_2_CAPABILITY_GRAPH_HOTFIX.md
|
||||
69b6df0262868a6e6ef81f406ee4c9acd3f662d1f6ee11d7fdd5fea96a2f0de7 docs/RELEASE_1_41_0_COOLIFY_PRODUCTION.md
|
||||
97f7bc395f2ce6bef24140e75dc8956a0d56cbaf15cb94876d4594d7aa54b1db docs/RELEASE_1_41_0_VERIFICATION.json
|
||||
29c795b5599bafdf0f911a4c7ab2844ca3fb311bcdd5f3983c7fa2742110c3ff docs/RELEASE_1_41_1_CI_SOURCE_ROOT_ARCHIVE_EXCLUSION_HOTFIX.md
|
||||
178927940cc1266307f81bbf418870e627d2e52b4956c2d9065c31e04c9bf7dc docs/RELEASE_1_41_2_GIT_IGNORE_BOUNDARY_HOTFIX.md
|
||||
1908713d7458ecacc833faced77984ef5c0155cd61b0eac13a4f874e3f3a5676 docs/ROADMAP_1_38_0.md
|
||||
185b1007c90273126ebe92bffe69f88ae4ec4a3f04451cb9d5d4b772a0482391 docs/ROADMAP_1_39_0.md
|
||||
b581d7d949139f37798dded557235a679621172f444a6271e7fc7ade3020299a docs/ROADMAP_1_40_0.md
|
||||
fe5756f788b2ab131f0832504d90d46d1ed744bfe470fd040803985234afa3cf docs/SLICE_02_ACCEPTANCE.md
|
||||
05614ecf41becc399acfa6a3b2f681f37a8c80f5ce0130b98c578b2c8c60713c docs/SLICE_05_ACCEPTANCE.md
|
||||
0838bcc6464a6beff39f0b4b056e513c1a390193521d9a8fc5d440d38034e9d0 docs/SLICE_06_ACCEPTANCE.md
|
||||
ae0f7c6f6e614ec77e362db1076288596ec48d11b78f33f26908fbc40ae5dd18 docs/SLICE_07_EXTENSIONS_RESPONSIVE_MOBILE.md
|
||||
b561983736639a08146d3642b718651907853d589ee32e5f421fc090f2c75a20 docs/SLICE_08_EXTENSION_AUTOFILL_UX.md
|
||||
c5bf7a0c9f787f100e422d0deb27bffc2c8c86b0e7c1ce1f35d2de8d7d8ea774 docs/SLICE_11_DASHBOARD_RESPONSIVE_HOTFIX.md
|
||||
876675bc3015838f568a6970ef6c01c9fcafe1d8d138c4379bc0f2ee1fd7445b docs/SLICE_22_1_SETUP_AUTO_RECOVERY.md
|
||||
223f50d05096b3bfa3ac3a9f0880a2055fc2029b7d70fabd086612c3348cb34a docs/SLICE_22_2_DOWNLOAD_RECOVERY.md
|
||||
95e1d560378120d7ccb0f363720ba832505631aa65616abd4df01baba5ecc269 docs/SLICE_22_3_POWERSHELL_51_PARSER_FIX.md
|
||||
88f5a2d021874764b6a7f56c690a1e2112155b83cdf0d4d964b2c6e00ca756de docs/SLICE_22_3_SHA256.json
|
||||
791a5d30f0d6ff9664722a2d00dff0db0bbdf2207e83ab391a40d6c4ab913bba docs/SLICE_22_4_SHA256.json
|
||||
d14c938c6c9397290f654406d4b995b63db0f22586e78fc97fb5fef575748807 docs/SLICE_22_4_UTF8_BOM_FIX.md
|
||||
f00c325ec3c879b47908dd5410cc943febb923200e9b9bd7d18197b1570a26fe docs/SLICE_22_4_VERIFICATION.json
|
||||
e140f3ee3298b3894126e37900b04ff1073ed400e6414e35897815b3f674d93e docs/SLICE_23_FLUX_REFERENCE_PROGRESS_SETTINGS.md
|
||||
b4b01b9f3751940d184d6fff269108798c7ec62ca7796b923cf82abbc142ff3b docs/SLICE_23_VERIFICATION.json
|
||||
05e3fcf5aa9a0d8d53175a3d24c5feb36e55f73edfeadf05384e87a5de73353a docs/SLICE_28_1_FLUX_RUNTIME_HEALTHCHECK.md
|
||||
43d867bb8b455f7450e5146648b28c759e50ebab45855d75a36dbc9f91d3f654 docs/SLICE_28_FLUX_STUDIO_VTO_CLEANUP.md
|
||||
0bdbeed4b57767427afbebeddc30ea58a211f851663939d6301af38820cf098f docs/SLICE_28_VERIFICATION.json
|
||||
1e20fd1abea3ce61f7f9cc88c6e8e4ddb0fe3513c5600e41b8f7c3f8d38e3ae9 docs/SLICE_29_FLUX_QUALITY_SETTINGS_TABS.md
|
||||
1df7305e6a43212a582d5f83630e0ba04fbdd3da0d673d9dcb8f1c5f7ca37731 docs/SLICE_29_VERIFICATION.json
|
||||
07d1ad0c19567cbdb05623567816f1506197a31e86fbcd1742f4ffa300335ae4 docs/SLICE_30_FLUX_STUDIO_PRO.md
|
||||
008237388c4816d3cd67f3f0abfd0512f4ebf2dce75ab6790de41b0481ae1d97 docs/SLICE_30_VERIFICATION.json
|
||||
db1674505e2ca7eb36c8752ea9b2d6fe2f2c12a5e6a1584e2722e20445b05e34 docs/SLICE_31_ADVANCED_IMAGE_EDITOR.md
|
||||
3133a06e68cd98e615797665956f1bc401c0427045298b46c48ca590be3ef9da docs/SLICE_31_VERIFICATION.json
|
||||
d425fc1724ac55853bf3433348486de8f8aab111f53fefee3c532beb1deb82af docs/SLICE_32_BATCH_IMAGE_FACTORY.md
|
||||
0a8917412d000168956f7ac55e60ab79b54a8834deef0386a681bb8f920514cd docs/SLICE_32_VERIFICATION.json
|
||||
25ff8492360d473e99c75c308be5f75cf84e0c11574d42aa6553d44f8bb35582 docs/SLICE_33_ARTICLE_QUALITY_CENTER.md
|
||||
1d5411f69c62222d3155f0e89f2b4b5a3effe317ea1e59347efa7730a8e1b0e9 docs/SLICE_33_VERIFICATION.json
|
||||
1161decfabd73c40ff1d2ffbc7d1b9b5168a8fb5fa392187c1b431e9a7f3a78f docs/SLICE_34_PUBLISHING_HARDENING.md
|
||||
aa7ebc90470071566749c61b0dece4169f87666dcca4b88922f7fbafcfdd5a84 docs/SLICE_34_VERIFICATION.json
|
||||
cc77a92ca268d3c444c6113a9cfaa38d3c805a579f6b1b21b7dd43a6ada58551 docs/superpowers/specs/2026-07-06-autolister-features-design.md
|
||||
4b66a14b4515e38477f10325ab9d03d9782ad0d426845aefe2d508986ed2cd81 docs/UI_FOUNDATION_2026.md
|
||||
5e655fcf3bb1182f8850b4104b56697ea952b01d2698af6d3d40b05e338b3506 docs/UI_UX_FIXES_SLICE_03.md
|
||||
31b3d3ef6368e0b09b073d5641fd5649264fe78662de5d9ca4c1eef71f300e15 docs/WEB_UPDATE_CENTER_1_41_0.md
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extension/background.js
|
||||
b86f00c7afa1dc921dd66f5cfea8c817eebc8876b9daca6a55dfd03680156c79 extension/content.css
|
||||
8ee8d1a94bbf83e5d09aee3c7d8c4ea5f216b2a51e73b6dbc74f7e6b99017a1c extension/content.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extension/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extension/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extension/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extension/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extension/icon.svg
|
||||
c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extension/manifest.json
|
||||
e61fd8e1264995fd633bd76e71329813dd02f361286329832a18dfd5a8bdd372 extension/popup.css
|
||||
bdf5b794473cae818789cf95a28a9c9a9d50f81529d72fdf45a536247a783922 extension/popup.html
|
||||
d7893cfdd686d4a6a1bea333468d4ba72cfff0e7166687181a2d37ba1faf3332 extension/popup.js
|
||||
c7db96df5fed3c8336062204322d62b4870f5c8ab1d30ea48cc6e7e38ed3513b extension/README.txt
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extension/vinted-categories.js
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/background.js
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/chrome/background.js
|
||||
c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/chrome/content.css
|
||||
22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/chrome/content.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/chrome/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/chrome/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/chrome/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/chrome/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/chrome/icon.svg
|
||||
c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/chrome/manifest.json
|
||||
e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/chrome/popup.css
|
||||
559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/chrome/popup.html
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/chrome/popup.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/chrome/vinted-categories.js
|
||||
c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/content.css
|
||||
22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/content.js
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/edge/background.js
|
||||
c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/edge/content.css
|
||||
22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/edge/content.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/edge/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/edge/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/edge/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/edge/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/edge/icon.svg
|
||||
c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/edge/manifest.json
|
||||
e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/edge/popup.css
|
||||
559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/edge/popup.html
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/edge/popup.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/edge/vinted-categories.js
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/firefox/background.js
|
||||
c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/firefox/content.css
|
||||
22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/firefox/content.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/firefox/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/firefox/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/firefox/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/firefox/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/firefox/icon.svg
|
||||
1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/firefox/manifest.json
|
||||
e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/firefox/popup.css
|
||||
559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/firefox/popup.html
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/firefox/popup.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/firefox/vinted-categories.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/icon.svg
|
||||
c2fdc0530c71ef400fe9d52aed914f65d331b0d89dfae7ef1d156d54d1a22a6b extensions/manifest.chromium.json
|
||||
1954ca9f34b3afe109935173f685482f06bb685a9b8f0d4c344f0c6133ccfaaf extensions/manifest.firefox.json
|
||||
e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-chrome-1.9.0.zip
|
||||
e4ea31f91a4c8df18901bfc77fc351a8808d19d690243b80bc4faa140e18f851 extensions/packages/vendoo-link-edge-1.9.0.zip
|
||||
74c219587cf0f827a1a1e2b8adacac55477be717fd4ae80e2ae027a9ffb0744d extensions/packages/vendoo-link-firefox-1.9.0.zip
|
||||
9e828844f41154c87ccfc5d8f3a19476396e2782fbd0f956465b42fb508cb098 extensions/packages/vendoo-link-safari-1.9.0.zip
|
||||
e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/popup.css
|
||||
559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/popup.html
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/popup.js
|
||||
48b5f9f4ecc1c1c42f37321e9bb2fc857c646b9c16c34520fc7d4ca59a1036ac extensions/README.md
|
||||
0b2a03d06faae7aaebeee8ec9fe1864422c8651f17ae90b185fd31474ac5eeb0 extensions/safari/build-safari-extension.command
|
||||
afd76deccd8bcf26d39f8c86deb1de578873ddd8b09ac18eebacae33169bb420 extensions/safari/README_SAFARI.md
|
||||
3873602c1529daad6647a7585c858d817821de0afbec93a26d1b3f5a7b9cb01e extensions/safari/web-extension/background.js
|
||||
c5a0ca59a8858e9bd484d1e5fb7a2aca72db092fb062674f531bb76f5a0c952e extensions/safari/web-extension/content.css
|
||||
22bd64723d279a6229bccba2a4cec56f1ae83f4cd5628bd1a1841930b0f442f6 extensions/safari/web-extension/content.js
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf extensions/safari/web-extension/icon-128.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b extensions/safari/web-extension/icon-16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 extensions/safari/web-extension/icon-32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 extensions/safari/web-extension/icon-48.png
|
||||
5904aad2263358a5be638db435d5a0576b8c2bf21d1a20114636d65dbdbc07a8 extensions/safari/web-extension/icon.svg
|
||||
772f1e2e33f4d6d882fa0af663e315fd3b8208141227bb5beb9baaf1f4e08397 extensions/safari/web-extension/manifest.json
|
||||
e3a3863b58a571a0f0180bdd333f9389b918415b4ea96b70f3ab6d4105d95c50 extensions/safari/web-extension/popup.css
|
||||
559dc70841ebb186ff96f9f2ba32ef26a3a7f8edd08363f09c0d7163049eae5a extensions/safari/web-extension/popup.html
|
||||
2bf2d5ccf02622cf1d62fd553d21c0ef1b744616742e7962ac302c10d37426e2 extensions/safari/web-extension/popup.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/safari/web-extension/vinted-categories.js
|
||||
273322e8dc29b0c6b639a6cc7e44a9570ceece09306fd46003b05a8fc86c88b7 extensions/vinted-categories.js
|
||||
4be8e17f83ebb805abb1beddf4d7a79e3a44ce58585cb6b8143bb01a47e16c6b FEATURES.md
|
||||
7f78a0d512de717ba76c9a5e3e1bd2e52db9edf146e8eeb7297458fde5d92afa GitHub-Deploy-Assistent.bat
|
||||
9a5bfcafdce53130d7bc36695090a8f87096b4ead9dd5ccc3d46d1d80c07a0fa lib/ai-claude.mjs
|
||||
ad6ba4ef521557d5c06bc546cda70db57e710e8173daadae74e8f15f99836c1c lib/ai-flux-prompt-jobs.mjs
|
||||
347778ce521a1919ec0679918171dc625d7f04d6dec26c8a4700e702bc78de67 lib/ai-local-images.mjs
|
||||
aeb5103c0179232b9954b7854be8099cf4bb0f3ef1ee908eb5e4faf6a8f1205f lib/ai-local.mjs
|
||||
29fbe09982b0b327b773bd119f57f50363424161815ac894ca4d677759e93e83 lib/ai-model-jobs.mjs
|
||||
9f474f665d6d610fe00d9e707e85314a854abb906e31db2bb9a3a7c76785ff4f lib/ai-model-photos.mjs
|
||||
3fb484456b9e6374f14b53a2276eea8533642b7c9073bb73a1bb0dbed7048bbd lib/ai-openai.mjs
|
||||
581c10b8346d0aa10169ed23ed49c038951ecd5cac014722c1080bbd5aec1c5b lib/ai-openrouter.mjs
|
||||
271696d7cbef3581b667a8ce3b07d0b17b1e8b467fbede4d8aa512ecc5225515 lib/ai-router.mjs
|
||||
76a2f1fdf60053739d82962264219060807022dfc9a2b3a644946fddff02a8d9 lib/archive.mjs
|
||||
2015974b9ceced554ecf0cfbdbf50a7d1db57459d50fb0241b8e9277559e70f4 lib/auth.mjs
|
||||
3e2b75c0758703e590233cbcc9b589e3177134accecf5ed921791891cbfc8522 lib/categories.mjs
|
||||
86793fb57a1dad32f4d93e34c27ffe9e77a95785dd30b2b55ffb67d0ad5e14fb lib/config-store.mjs
|
||||
8c203e70e9ab07b996d4a53031892bbf37b10efd024f9e0013266cf2af0244a4 lib/db.mjs
|
||||
54722d3f76ad9ee4fbe731d3a65c2a03d343eb82d98f8ea5a5996b1bec24d57f lib/ebay-api.mjs
|
||||
524f1b48d467998da819f0b947f2551ad45784e588eb442304c5f6e925dffaee lib/etsy-api.mjs
|
||||
5b2c566ff14fa502ba2cd4d4c918fe238a8e93c3283d521144093a897f9d8018 lib/fees.mjs
|
||||
7dcb2fb5606140afb7a019094d20ef88e9b4e2c70041f77b06d67d8992fd25ce lib/html-templates.mjs
|
||||
e41a13284429ac01652d979efd0734d5a2b29945e7e93a910b44eaefab9c00b6 lib/image-batch-jobs.mjs
|
||||
5ca5fc65a7de7c27243646d291dc42c0b5267a47705a09b306cd2c2eae2a77c8 lib/image-editor.mjs
|
||||
5e7aa66faddd3d19bc6ebb9ddc13abfc4ab4f17d8c51bb39b2c4e0279385d788 lib/images.mjs
|
||||
606f4cb1013c711d971dd201ab06df84ecb128bf5d5277095ce2e2ea56029e45 lib/mailer.mjs
|
||||
493895deb08e51ef1f47b55c88e3225bb267ade9fe305a7ed2c59a3f417fab16 lib/operations-center.mjs
|
||||
f10c3951bc604392e9fdaef7ba51b227893266baaa93981aa480409fed722062 lib/publishing-jobs.mjs
|
||||
3a728417dd668d60fd1526ec738cb9b302f86c01d62b2227f12b2e44b7d389c7 lib/quality-center.mjs
|
||||
74c783c1e06e9cc2da0165261a49511aaf374dab3159cbc24c0d69b6b467dcb9 lib/runtime-paths.mjs
|
||||
d3d862ab32fd276ecffb54c0307976ca680135d92f24d024c95d365a50257643 lib/secret-store.mjs
|
||||
4ac2e7f7c2235109a2ea949b2ff7b649d05ef607b95704676a43c60ddb1ae4a0 lib/security.mjs
|
||||
2fd970a6a357a26211f0b9bcf432e59e79f73dc9a4cd35f88cca939655eefdff lib/structured-logger.mjs
|
||||
ddb9c7677600795a4adf8a19a7525b7551455459d16eed4cdfbe751ef9ddf0eb lib/zip.mjs
|
||||
2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 local-ai/start-local-flux.ps1
|
||||
7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 local-ai/stop-local-flux.ps1
|
||||
5f220a2dca02cc0764f6d0157677611b26c88860b48367eb661b6e47a1b92265 local-ai/workflows/vendoo_flux_schnell_api.json
|
||||
302ab1609d08b3059ba6563b676c64f0709651f2e3c531857bfca3840c535a79 MockupDesign/AdminDashboard.png
|
||||
49f7625a1299a16576feabfd7824ec3511391bd9b7c53a1fba9232da7e8bd6c6 MockupDesign/ChatGPT Image 7. Juli 2026, 10_05_19 (1).png
|
||||
7b8cc9c5db8718b0b653ec6aa9d2a8bfa1bba03810248c3ba17745b6f15cc412 MockupDesign/Dashboard.png
|
||||
4e3e797ed5d3ed0b60d00d8e567fbe51543c08578b858ef71e77b274ae449fd0 MockupDesign/Einstellungen.png
|
||||
c78fd6f673c0e59789ea313396b12c2f21f75a2ee6c7ad2de81a6b89f6d90588 MockupDesign/ExtensionDesignMockup.png
|
||||
ed20bc04cab8e85245a855810a61b736a682f972eb85e23340edc9b558851a93 MockupDesign/Heute StudioFlow.png
|
||||
1ad88f45f377ab9b3f35f96a0ceaaa7f75cb0a996e83370d76c54f527392a08d MockupDesign/IconLogoMockup.png
|
||||
0e8b4a31d075f1f83ea62b5eeeb53e89859e395a696deb43155932a23e7cef86 MockupDesign/Lager.png
|
||||
b1c62e118d9be316274e966c558cdcccdd41afb3a9f05a60ec1e4a3433d2ca0c MockupDesign/Listings.png
|
||||
a52983b9ea2bf920d115485d10bc30226da88bc5cba8b00bebf801b21c096346 MockupDesign/Publish.png
|
||||
8c8bf278d0c6725e1dbb97b3c3d0c36a9bfc0753c3c86433fdc294bec70dbc0e MockupDesign/Startseite.png
|
||||
62f14221b1d71c9e631efdadd221397ff657adae4b737df52c8bff1610b90999 MockupDesign/Vorlagen.png
|
||||
a111671a21576140185a0c4d01038e200f87cdcbf903efe57bb84f65c972fd1a package-lock.json
|
||||
740a9ee1699aa8e24fab4ab66373bf4322bee0e60c6dd80dbc07e8f9d18a3771 package.json
|
||||
3da71dd28cb25c7c7317b9061855d40c6a3ab8bc39391963b49ee1e707f838e5 platforms/ebay-de.mjs
|
||||
cc1ca2b4384c8c548bf755dc33101d248541bf893c3d5f35369961f904afcc00 platforms/ebay-ka.mjs
|
||||
fa52b247dd758aeee06120499bd1ae6c53e9f460f8bd7762b8f06141b201e32b platforms/etsy.mjs
|
||||
5c925e3934a4c9e2f26dc15b3449a249f5ce20ecebbd459b15365a34804824d6 platforms/vinted.mjs
|
||||
a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/android-chrome-192x192.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/android-chrome-512x512.png
|
||||
cdf390c3ae338cc422ecd5338b3f50adb20fdbf4fe6193f2f4553c096efb2f10 public/app.js
|
||||
e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/apple-touch-icon.png
|
||||
a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/favicons/android-chrome-192x192.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/favicons/android-chrome-512x512.png
|
||||
e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/favicons/apple-touch-icon.png
|
||||
a0f1e01de4e8215e690c1ce869fecaae9d7ba3cc95883340a53ff38f6be52ce0 public/brand/favicons/browserconfig.xml
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/favicons/favicon-16x16.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/favicons/favicon-32x32.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/favicons/favicon-48x48.png
|
||||
15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/brand/favicons/favicon.ico
|
||||
2daa93cca0ad2c0cab90a9e8ad4ea0ae4b64feeb68c422954020d17e2376a381 public/brand/favicons/html-snippet.txt
|
||||
3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/favicons/mstile-150x150.png
|
||||
c372675942caa5010340f21fe521adc72af537d909008731976820fd84604d42 public/brand/favicons/site.webmanifest
|
||||
b2dc767e7a01395d23a7f3f5915324db1a7d1912bac60470d2f7c10815895173 public/brand/icons/vendoo-icon-120x120.png
|
||||
409a96b169bc91efbcd7e923614a8cb30c6ca36cbd727f41e02c2ee4f89098cf public/brand/icons/vendoo-icon-128x128.png
|
||||
d276f50b298e4dfcbba9147550463aee498531280399167d1e9774aa8e5f7e21 public/brand/icons/vendoo-icon-144x144.png
|
||||
3798313cc939d9fa582a92638356abfcfc8f8b4f143d9434b7012446195e6937 public/brand/icons/vendoo-icon-152x152.png
|
||||
47122dc91010721e2692d11986f0b91d9b76634130a95723e92b2a070268d16f public/brand/icons/vendoo-icon-167x167.png
|
||||
8c031a533aeec86818d442ce96e859497817d16c2c9bf037c68955b90e8ced6b public/brand/icons/vendoo-icon-16x16.png
|
||||
e0e7b8ef01c36f0625b6157aa46bd69de05c17be0b054208fb635d6507b52154 public/brand/icons/vendoo-icon-180x180.png
|
||||
a2ea3f5f56dba8bd4d23fa54a26fd941567b4febdd1ed1b4e9f5bcc1c9786e53 public/brand/icons/vendoo-icon-192x192.png
|
||||
3f2517cfd215cb624ed639d60b064dfa5e88ce71eb7bbebbbfaa70b3d62556db public/brand/icons/vendoo-icon-256x256.png
|
||||
9963f60ab46d7f015a9400ee22e8e7a9fed31781a3907ef14b674d179d8f5f45 public/brand/icons/vendoo-icon-32x32.png
|
||||
cae3eb737c8ad3a82a679b09d33d991b501bf482e0132e99aa1b8ad064e472b4 public/brand/icons/vendoo-icon-384x384.png
|
||||
64d7b63beed9f972848daabc86484b5672b42ac7ed402a0d3effb47e200bf253 public/brand/icons/vendoo-icon-48x48.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-512x512.png
|
||||
bc85af557be532dcce6beb82cf9bd61af683666b00fb61e4b2069f78e60a9a33 public/brand/icons/vendoo-icon-64x64.png
|
||||
01d59b665bc90dddbd34221e3a2ec3af11bff6fb328aab2c9947c03d74b0465d public/brand/icons/vendoo-icon-72x72.png
|
||||
ed3dfa59e989b154fac88a3c72ac840290ee3e1565d44aa75596bf0055c74020 public/brand/icons/vendoo-icon-96x96.png
|
||||
46ea6ba4ca6ca2352f37353b8bc4ca258935d29ecb8d3c9b133a48ea75c0c58f public/brand/icons/vendoo-icon-original-1254.png
|
||||
8fd9901e07dc9172d162706511ac8dcce220a4c907cb3efa9940c9c13fa32526 public/brand/logo/vendoo-logo-cropped.png
|
||||
3f39371abd1efa6cc8366aac9a6d067f3339b8f2a2da77ab8a26c6bdcf8c4fce public/brand/logo/vendoo-logo-original.png
|
||||
b56d07515f6220de7fd0cb149fed598a7361c985ae6ca7cb14329a99e8947aee public/brand/logo/vendoo-logo-transparent.png
|
||||
e83921d2af9930ab27df1abe64374cb9311189bfaaa951068109177443571a07 public/brand/logo/vendoo-wordmark-transparent.png
|
||||
1711a9387f2d6e75797b77c8daa92b94d7a15e128476e4b8aa9d54ccb0692a0e public/core/api-client.js
|
||||
f30395399a5c6d7e0a884b22f28f7648e6343695cee9960d6ff13b34de7b48f1 public/core/frontend-module-registry.js
|
||||
fb3dbcd03f7ed15b773b582c2a2b785c3040d3319aba5b7c682a7e74e1cf3b4e public/core/safe-dom.js
|
||||
38aa57d13aeb3ab70babcb903bfee71e47d80932ed24a73267c13ff81c24e3ba public/design-system/theme-contract.json
|
||||
308f1c0e2ed1de34aa11545a1a660aab02631e422d6c6945e3203d11666784fc public/design-system/theme-runtime.js
|
||||
4d69e2cb6b7f9f63b54d708a5d8b1ebefcdcb9f88a977053db5031c05b098464 public/design-system/tokens/source.json
|
||||
4a9e65edeaee3421edc3dd4831230062494f1a42f8495f4cb979fde8664d06c4 public/design-system/tokens.css
|
||||
15bf8dd655b34a8015447e834d387af12685238ede598f5f0e7927eea2933483 public/favicon.ico
|
||||
6d90d0dc93416395caf6155e5efc13e3e87c9c44b828542983d9033bb7a49e08 public/index.html
|
||||
85a655300980b6ec32b35faadec4b30b85cf45cd5799e8179050ac07a280917d public/login.html
|
||||
e37558ee9612a36db8a19999fbb320f1c249a8e155f94c83a82926bec920f989 public/login.js
|
||||
59291f75eca405e3856bafea76cc601d681060eb6b36bf82a4be3c3ab7eac290 public/mobile-upload-expired.html
|
||||
f046f166d89fc32774b87deb02e6d144fd2f40c0925aac67487e5b7c489a0ec1 public/mobile-upload.css
|
||||
6152e4fe715efbf8fd6a7c4286f23172bc3cf15dde085e5e4f7389ad5e65ad73 public/mobile-upload.html
|
||||
f13846801a8493aa43df6ec3d78b826b21b59e8f21d47735f68fd0b278e440c7 public/mobile-upload.js
|
||||
3132436d52caba719a3460c29a807e6f0703cb2118a80c9fae6c30360498d249 public/modules/module-manager/module-manager.css
|
||||
bf10d5ccc53f287a8b61d488af50c3151c764f36a29ee5d4fe9ee321fa0eda06 public/modules/module-manager/module-manager.js
|
||||
01f2bd124257220005032bf234d122b9942fdd8fd57ed8195748b2fb1f5a5cfb public/modules/README.md
|
||||
2d7a004db3e22b30ad3a682ba445bcee3fc1c5f0ed5e8fee45b5892c5b335e51 public/modules/theme-manager/theme-manager.css
|
||||
0edc61f0c2bb604c3adc40813b18fe5ca8d9b846ec2a3d9f406ae26ae313d3ee public/modules/theme-manager/theme-manager.js
|
||||
e0a6c981d4231eab26d981be62647d0e6f89e05e8371baad121348202ad54f49 public/style.css
|
||||
ff525e0af963e6f54636cfdc1bd2d045aaf82e7b0199755eb5cdf4c52110be2e public/vendoo-icons.js
|
||||
9cf6628dd714347563fdc5925e323c0a274ee5967d78555757be85c56fcdc0b0 README.md
|
||||
f397e46da92394d34c2fc01715f037cca380703ecfa1d4a451de8ec7342e05b3 scripts/docker-backup.sh
|
||||
86a411ae1fa5e36a11b0a8cb61e6c0169e0b80d3cedc9b7b82c3cfff23dd7390 scripts/docker-entrypoint.sh
|
||||
1b58c1e5f762c4928d42605c01872f38f6d6bca92342260f3cd88a132e6af095 scripts/docker-restore.sh
|
||||
1720c13fed290c08361266257279e86c8b5909c4b39d3729439335cbcc06a387 scripts/github-deploy.ps1
|
||||
d4d41170106979e1be9266a569215ff484e80452f568f0bf8dd9cdc9978dbb7c scripts/github-deploy.sh
|
||||
0a9bafc79c1c5d59fb9b51ebc34c00eec4a4e9a5e873ce1818f50f3ab983da70 scripts/install-local-flux.ps1
|
||||
2924f4e8abe5ebbd099fe1ceb2bc6ceb4bdda55fa3e57dd299cfc05d310a9246 scripts/runtime-start-local-flux.ps1
|
||||
7c6176d8311b1ba9d511beb40edd41b8248e61c298528bbec3f9ef1171856c05 scripts/runtime-stop-local-flux.ps1
|
||||
1a5736cf5fccc991df122163977c2eac80281bd99a54a5680b3d7eae99e4b79d scripts/start-local-flux.bat
|
||||
3bf3aa74f5cc1f2946bb2da637eb30fe8daec783a32f9721ebb0468e20df90bd scripts/uninstall-local-flux.ps1
|
||||
327bb6e7b99939508b807d0c3c6d5617a2d8f0d5464ba0b1865d8e9e1ed82dde SECURITY.md
|
||||
abb508872f30a0d6e4b6d33ab2f4cb14dea11838b15dbe502e6fac2ecc16cf13 server.mjs
|
||||
eae7a799aaeec708ea28aca9fc6b08132909cf0b41d412e889064876ce4ba94b setup-gui.ps1
|
||||
cf4519ce5adc01f05458e3286e86f3a419cceaff5fcdd869a1d07f1c1d1a0d8c setup.bat
|
||||
9433d663b6537ab5bb52a80733b18ac97e932ec50650b2d6bae8d67d5f05cdb4 setup.ps1
|
||||
9cf6480f9eeda7fecc82a6bf1d9bfb3d94b75ca42c92e7697679b9efd8816215 setup.sh
|
||||
2851c914a1b50a036cb265ddd32e5545cc7e8e34ba9d682445926acc23eb8f28 THIRD_PARTY_NOTICES.md
|
||||
90485a16e3468cb9a6750646700fbb036cb9ac410e9b8bd5c582f7b923bf3c31 tools/build-release.mjs
|
||||
94f4ced84834fceeb4f797bf8aab0703c873b57fd5d4a5b3fb02fee12e42bd2e tools/check-syntax.mjs
|
||||
ac8440bce2659a035302609722f1dfed5d125223376cd575ffc0636397c94b54 tools/generate-design-tokens.mjs
|
||||
5e1716ecb03aefc626bb8258ceb75c9372bef5d401b798a16703a65d87a9e6a7 tools/prepare-git-staging.mjs
|
||||
e3d8c6e8dfd566acbe037ad744edbf60816152d4994698c2a9c46d2b890e246a tools/verify-capability-graph-1.40.2.mjs
|
||||
8916ca58f8f67b7186d4b3644d13a56743965a57a50dc30bb33bd6baa8b2b646 tools/verify-capability-graph-1.41.0.mjs
|
||||
e7cbaaa0ca1f0a655af500aea14044455a80cfae10246716a17488f96e99dae7 tools/verify-capability-graph-1.41.1.mjs
|
||||
62b3e3c998759d59b78ae0a6609f51bf87369880ea1384b274c771d70d2a6476 tools/verify-capability-graph-1.41.2.mjs
|
||||
85114eff887ba65ca7df00f43333414c44b3aadc16fbc2fe10dc4dd4f9aa7aed tools/verify-catalog-media-1.39.0.mjs
|
||||
753288b83a655dbe4a9da8e93d8c3956ae17544699e2221873d479d72b977e2f tools/verify-catalog-media-1.40.0.mjs
|
||||
f208ce5267103ab81c30da71e7b7c51c973b36dd617a1bc42c12063fbf1473f0 tools/verify-catalog-media-1.41.0.mjs
|
||||
1c8da7af9bb5d743a60997fcba3345e568a862c97ff7102621134fa347a0056f tools/verify-catalog-media-1.41.1.mjs
|
||||
b05890cd996c6dc28a47fb662026ac794bd096c23c3b1bb7a9fdc01224e8032e tools/verify-catalog-media-1.41.2.mjs
|
||||
93d8fdee3feaf6e82ef15e7ac84bfd7e43ad13192f94655ccfce37f5a87b21ef tools/verify-catalog-services-1.39.0.mjs
|
||||
60de35103ed4c4abf30445b4465b849f8be8842f1a4c22da6ae5d74943dba386 tools/verify-catalog-services-1.40.0.mjs
|
||||
f89edc3b9d32c1618522c5810e53f25b47e72d52e8a041a2e7ae7939fbe169f2 tools/verify-catalog-services-1.41.0.mjs
|
||||
122d1664ce9eff57331fd5f86554943494d46ba38e90f7e1a13c0de33ad6906d tools/verify-catalog-services-1.41.1.mjs
|
||||
fb8ee4afa1d6abc09106ab032793da8dfef43bcb8614c7fe04086eb52cfb2a0b tools/verify-catalog-services-1.41.2.mjs
|
||||
8f9ede5f112ee03323db3a6c73aee00b7037f5139dad241b11baf5f0e9cee4e0 tools/verify-config-store.mjs
|
||||
e5a0614ef4f2ea73bdaaa1924cfafe86550a1a50c0105c569e405c332d8389a5 tools/verify-coolify-deployment-1.41.0.mjs
|
||||
63afde37353f750f4bfe0b02324f774a7b3c7ece9339d85e5428b9a427c0d11e tools/verify-coolify-deployment-1.41.1.mjs
|
||||
a66efb128565db5267c03a1503eb29d1f12024e1a357e1fd17d409430c2503f3 tools/verify-coolify-deployment-1.41.2.mjs
|
||||
5752f6edb956696781da8bddb1c1848d26941fbed5a524691653c84c88c34bff tools/verify-db-migrations.py
|
||||
3745de55a110d8254983b096c6afab196ddaec7f1153cd2f34c1e480b15aa33e tools/verify-deployment-1.32.0.mjs
|
||||
44f6b349b722e5a83d4725c4879fa1a9383f59ab5634c49d3d7d9d7aa0cd5e12 tools/verify-deployment-1.33.0.mjs
|
||||
cbd91b45d97e11bd4f5ca2f9d2edf2b5931cb2aa336cb71afc30aad9cffbe1e0 tools/verify-deployment-1.34.0.mjs
|
||||
01a43040a95cdac40703f49031acd9267cdba99d9027a51718f35ffb643237fc tools/verify-deployment-1.34.2.mjs
|
||||
906767e574c06aedebb2d7797150bda2f1e4c4536e8f82f4c7de2e3cc8398c8e tools/verify-deployment-1.34.3.mjs
|
||||
71d00227a59592cc0ad8ba321c63350ec25748ef965d8cd0eff1317738c7fba8 tools/verify-deployment-1.35.0.mjs
|
||||
b65eec7fd54e1459b363d99d20f9438ae5bc4b518e775de8b97e33e89a382e42 tools/verify-deployment-1.36.0.mjs
|
||||
22a5ba1951b2e8dbc924e60aaff8be7c41189ae7b30de9ecf50d0b741e84e13a tools/verify-deployment-1.37.0.mjs
|
||||
84961aed097ee00496dd853677895614263baf425b00218cc0e1f3ced1b5b7b9 tools/verify-deployment-1.38.0.mjs
|
||||
2ecf465073b7ec9e8ba38bf83364f681886d50440fbdd579a5df14df5d1bb3af tools/verify-deployment-1.39.0.mjs
|
||||
4b37972998d87c1336590e2550895a826d63b47e4828d5bb3ba619376c287271 tools/verify-deployment-1.40.0.mjs
|
||||
947942c84416b126f29281a8bef7b8230ecde09ea8b3bf3304cfacb0f9623fff tools/verify-deployment-1.40.1.mjs
|
||||
77f44ad4e13da47bcf4bc9c6a37d014c8b5f6c3e735ecfdb63830e09e43df296 tools/verify-deployment-1.40.2.mjs
|
||||
9a33b8594744ed4c7774f12da5b14dce64049c1c0df65e98c9cd26d5404fda0c tools/verify-deployment-1.41.0.mjs
|
||||
a885727e8847bf32ead7d7dc80e2e27deac62df53763a187717fcd8e6157a1f5 tools/verify-deployment-1.41.1.mjs
|
||||
ca6e6baebc9489fd101d005f75c34c90b28e914443a11e3ad759513f653f4c82 tools/verify-deployment-1.41.2.mjs
|
||||
c2d270efae9209d6fdf810f44db4cfdb9642db29060d5b75c2d920a859bc6918 tools/verify-esm-export-contracts.mjs
|
||||
58c275a972393d7db7cab087523d3963a743e5a0d654fbc9b2cc09618abfea6c tools/verify-flux-loop-hotfix-1.40.1.mjs
|
||||
58c275a972393d7db7cab087523d3963a743e5a0d654fbc9b2cc09618abfea6c tools/verify-flux-loop-hotfix-1.40.2.mjs
|
||||
824ea8ed05fb68ef6bedba83e76697f9b03eb9830db981781e002c64f8b6858d tools/verify-flux-loop-hotfix-1.41.0.mjs
|
||||
a055379e9ca3e7ebded24d4c75ccd54f8f81aa3618d63cdf41d79e3a17f126ac tools/verify-flux-loop-hotfix-1.41.1.mjs
|
||||
c84f814c5e7dba7144709c6be860ab8ecbcb0541d167dca1e3172717196a327b tools/verify-flux-loop-hotfix-1.41.2.mjs
|
||||
e06cda8d9850a84cde34a99009c51a18dbc1b7a66e45c82f35aadd631a71540b tools/verify-git-ignore-boundaries-1.41.2.mjs
|
||||
d716f48436179c7f156b43a687b6149648fd38de12767da2be1bc63e3e1fce31 tools/verify-git-safety-regression.mjs
|
||||
872cb8d1e0f86e2e571ddcce1adbb002d0a86f2d834f4ee7f601e011c7306e70 tools/verify-git-safety.mjs
|
||||
ad2d1df5725da388ca427ddf6da0e0d62bf02999c6554e8c1ab918d63f5b8d50 tools/verify-git-staging.mjs
|
||||
1d4351e4a827e718766a1677ddfbc3771a91942d0d5a93753e39f1b99c68a4dd tools/verify-hotfix-1.23.2.mjs
|
||||
2f43dd1a89a36527cd3b4873b8cc9f7386cdfc06ade96fcc05dd5deb15dbcec7 tools/verify-identity-db-1.38.0.py
|
||||
2f43dd1a89a36527cd3b4873b8cc9f7386cdfc06ade96fcc05dd5deb15dbcec7 tools/verify-identity-db-1.39.0.py
|
||||
2f43dd1a89a36527cd3b4873b8cc9f7386cdfc06ade96fcc05dd5deb15dbcec7 tools/verify-identity-db-1.40.0.py
|
||||
1b5c21eb5688bc15895541968fa1ab69a2384ed611b01d23901abc040dcc3255 tools/verify-identity-settings-1.38.0.mjs
|
||||
a1dddb35f4b6eeafe47ff3f40e5565bdb4477c8f63101d3c0d7f84c7393abe4c tools/verify-identity-settings-1.39.0.mjs
|
||||
ff839066b02121a19046f19093904550e1184785e73dd2d079ab518cc74583ff tools/verify-identity-settings-1.40.0.mjs
|
||||
3725fdbb20e93dd0bcbf3ad385bbb0c459cdd68448371757e549543b007155cc tools/verify-identity-settings-1.41.0.mjs
|
||||
3dc54d074497913255774603f043e1154e2ae865ca62d7d4d682826cb02906c2 tools/verify-identity-settings-1.41.1.mjs
|
||||
6d1d890a184a4cb4e1ae9edf784a0df250563e7287f6d3ee5fc43c89913b95c9 tools/verify-identity-settings-1.41.2.mjs
|
||||
9492911fac3771e3b906b5a583e4bbf136ec8383b5fc23b0478f58b7407f7c65 tools/verify-initial-disabled-modules-1.41.0.mjs
|
||||
932e2359c3ee00924e1a89cdcdabe44ddf1a60479f75f4d4094f2102d1c12949 tools/verify-initial-disabled-modules-1.41.1.mjs
|
||||
745cb929519db756ba23e3198b4c92ff9e91173bd03d821bc2e37280af0a32b7 tools/verify-initial-disabled-modules-1.41.2.mjs
|
||||
44c95844422be3deaad6054ee3f0cced9c25986815d976e0c64b042d0c2c5e1e tools/verify-installer-shell.sh
|
||||
33ca957bf8acd3cc73354ef4c8d5e4b61c44810ecd8a8994ff89305cf1b9429c tools/verify-module-manager-1.40.0.mjs
|
||||
2a80e837f754e7490f775956161e4fc8866c127c37b95d688baa239fee4cd883 tools/verify-module-manager-1.41.0.mjs
|
||||
6b83edfaa57dd3633c7d100a8ed476ef63ae41384d215c787a2cbb6e608b22e5 tools/verify-module-manager-1.41.1.mjs
|
||||
f4da6e0be42aa7eda0a496f4ab77547af6e3238a6d405aec00a61a9bb3622c1a tools/verify-module-manager-1.41.2.mjs
|
||||
cab03e98687af87f359f6424d440fc0b644da97b3c614509b8c7d008d4b18a8d tools/verify-platform-architecture-1.35.0.mjs
|
||||
732bb15431746a829e45ae55c695360cbf1dd21d4fc3be26a98f35708d571e84 tools/verify-platform-architecture-1.36.0.mjs
|
||||
ee8ae7bed4e93a2ef278662552a4ae5d751306aad7133a93f380bbf2a5d5a28c tools/verify-platform-architecture-1.37.0.mjs
|
||||
584ea4735937238b4c3bffa70364d0f45cf1f3d41bf34adde64c7cbb7b729efa tools/verify-platform-architecture-1.38.0.mjs
|
||||
f8590c6986786cbbce95e58af1d1d3fec61d163c19fcf4c22aff57f5954c398e tools/verify-platform-architecture-1.39.0.mjs
|
||||
c9e704c372cf6a973fcdb887b337ead6509fd03e78570e6d1c9e24b1d879fe6b tools/verify-platform-architecture-1.40.0.mjs
|
||||
8b1b0eda98f0d16a99b47b0cb7a9e550d40d3dd048abbec6695c11ceca96f259 tools/verify-platform-architecture-1.40.2.mjs
|
||||
2a06e9f6e73b37f35d055d0dd911e9cb741fa1fdb2cdfc5d926a2635f817356c tools/verify-platform-architecture-1.41.0.mjs
|
||||
957cc887090f8d9c3a477c532e1aca79d24a3725924cb6e5a934f121a155c0e4 tools/verify-platform-architecture-1.41.1.mjs
|
||||
ef13554f4d1a16dc97e5c17af3a719a0bd25839dae0f0f28608a706f893010f4 tools/verify-platform-architecture-1.41.2.mjs
|
||||
36e35fedb08923c22fd8551389d33432b66dbd4215d95c594ef29bfc790c98e0 tools/verify-release-1.25.0.mjs
|
||||
89621694b7bad18b6f3f1ac1956032f64112c40d3edc069ca581dc813d57d628 tools/verify-release-1.25.1-static.mjs
|
||||
c908f5186cf2d71ef83d5e649327ae19965568933acfb32057ca00d29cf890bc tools/verify-release-1.25.1.mjs
|
||||
e31a055d2ebb8e439f5b0e0235a71dacecb891e299377cfbbcfa8a32f53a85b0 tools/verify-release-1.26.0-static.mjs
|
||||
7e107b5f9a1b4ee381ff31105feb6d38dda5c40e4afe105a955badc36e6b8d10 tools/verify-release-1.26.1-static.mjs
|
||||
d9a75c45f40d857c84790d51dca2fd088c3851ca250405871a7e32773918c66e tools/verify-release-1.27.0.mjs
|
||||
3989e3ec236c1aa4c387b26cc15e04cf200a018e29c095321acf826c6bae4d66 tools/verify-release-1.28.0.mjs
|
||||
7d196d913a697248ab1233907fed56c10329cf201e3bdc173bca73cb02755369 tools/verify-release-1.29.0.mjs
|
||||
3f30260dabbdf0fa7b042381cb9e24f840d0c3ddbc44fe309763789a7d14afb7 tools/verify-release-1.30.0.mjs
|
||||
b1ceed5dfc17dbd3b038b7168e61076e4d39a0f1d67e2b87677a91c4dbb2daff tools/verify-release-1.31.0.mjs
|
||||
a372258b97aa68d59a1fe0fd3f380d019c59412d5c408a6064f437d5cfc6e709 tools/verify-security-1.31.0.py
|
||||
08820d9bb7be1dec496c04e9ea4a39b2ebb84d373a9481136610d16dea0edde3 tools/verify-security-foundation-1.37.0.mjs
|
||||
a415d294679adab8ea76b86235a522e73ad9dd9ea0e2348691b1de3191255b81 tools/verify-security-foundation-1.38.0.mjs
|
||||
805aa61d7f9cba8c20afda0dd871189108b02f3751f8a75963784cc12fd6cf76 tools/verify-security-foundation-1.39.0.mjs
|
||||
f5296410e7c56a924978569a8ae904a1de8d4e327cb833e34e4b49e0384f3892 tools/verify-security-foundation-1.40.0.mjs
|
||||
cd21f37b51ca99312c624a3b353d9eb5bff7e3dbca774600ae429f468b1d6b3f tools/verify-security-foundation-1.41.0.mjs
|
||||
a619c7ecff5febe8afa746125ba100c603598951dfc177562307614ecf509715 tools/verify-security-foundation-1.41.1.mjs
|
||||
6610a5d5ea6e4debedcf582c5d9992914110526bc2c371d6b9c5472fb8083768 tools/verify-security-foundation-1.41.2.mjs
|
||||
31a120622ce8bb8451014f2662366f81abd28d0f3be59adfe35d17e0ada1838f tools/verify-slice30-mock.mjs
|
||||
20957a4a038b9afdb01a8c98b881644daf793d2a6bb778a4c8e04780687d00e4 tools/verify-slice31-image-editor.mjs
|
||||
21061ed5dc88feef48e80efffd9a1054d38598dce5a5d3f9f6bde39b539b83f0 tools/verify-slice32-batch.mjs
|
||||
0f05b1ac3b686cf535bcfb3e6f6df24a29c3429ade1b3c666c1b99abb1a7311b tools/verify-slice32-image-render.mjs
|
||||
168e58bd08b657abf2c7a84c7d0210e8393720e3f2b665703b03d45646480b04 tools/verify-source-boundaries-1.41.1.mjs
|
||||
4424e4abf9a1dddd138b0dcb91b6d63f646c3a0c8eaae484d1e9d5b34bb35d77 tools/verify-source-boundaries-1.41.2.mjs
|
||||
c9aa6693aa93ff1f220a2ce3bc35ef714a0b18a53eae0f907879fc92c66c1e38 tools/verify-theme-manager-1.36.0.mjs
|
||||
1e61c4b180aa09de3dae9e72aef313b09a6f422229f180984b69a8d226e7cd8d tools/verify-theme-manager-1.37.0.mjs
|
||||
3257b31d80034c077b25d3f1b13f8378378772bc6512445aa5ac8e882e4be109 tools/verify-theme-manager-1.38.0.mjs
|
||||
41033ba36a11a14ab74a80d65c1124848eddee21a5f3fef8bd5ab001699e2e4b tools/verify-theme-manager-1.39.0.mjs
|
||||
072a20e27b80b911ace2c9c18d1bb0298f59345967b6ee8f3d6b9075c522a6ba tools/verify-theme-manager-1.40.0.mjs
|
||||
251bc56d2d52cbac1e126e35ad5e0077affb2b5cde6b251ad3df6a39ce67d16d tools/verify-theme-manager-1.41.0.mjs
|
||||
33a6f24d26cda4d2129501c4e826ed0dda4eb9e15a324bf823b2ef19540e31ef tools/verify-theme-manager-1.41.1.mjs
|
||||
8d825f95253f96b182ac9322b20c8f32c189eedd2a8c366170791c7623acbb1f tools/verify-theme-manager-1.41.2.mjs
|
||||
0f3f684019763a14f5fbaec42e42c680cf748d930d46cd607fc61eb413ca4553 tools/verify-ui-contracts-1.26.1.mjs
|
||||
a7ba863085e196ba5629cfbc344fcf8a05f45406f6bc6c6b75b3f45bf48bfa46 update-manifest.json
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
rem Alter Dateiname bleibt als Kompatibilitaetsstarter erhalten.
|
||||
call "%~dp0UPDATE_VENDOO.bat"
|
||||
exit /b %ERRORLEVEL%
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,47 +1,369 @@
|
||||
# Vendoo
|
||||
# Vendoo 1.43.0
|
||||
|
||||
Vendoo ist ein lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System.
|
||||
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.
|
||||
|
||||
## Status
|
||||
|
||||
- Version: `1.0.0-baseline`
|
||||
- Hauptbranch: `main`
|
||||
- Entwicklungsbranch: `develop`
|
||||
- UI-Sprache: Deutsch
|
||||
- Repository: privat
|
||||
## Release 1.43.0: Module-aware Navigation, Listing Studio & Updater UI
|
||||
|
||||
Die vollständige System- und Feature-Dokumentation befindet sich in [`FEATURES.md`](FEATURES.md).
|
||||
- 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
|
||||
|
||||
## Tech Stack
|
||||
## Release 1.42.0: Production Operations & Controlled Update Completion
|
||||
|
||||
- Node.js + Express als ESM
|
||||
- Vanilla HTML, CSS und JavaScript ohne Framework oder Build-Schritt
|
||||
- SQLite über `better-sqlite3`
|
||||
- CSS Custom Properties für Theming und Dark Mode
|
||||
- Session-basierte Authentifizierung mit Rollen `admin` und `editor`
|
||||
- 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
|
||||
|
||||
## Projektregeln
|
||||
## Hotfix 1.41.2: native Module zuverlässig in Git
|
||||
|
||||
- Neue Datenbankspalten werden als Migration im `MIGRATIONS`-Array von `lib/db.mjs` ergänzt.
|
||||
- Neue API-Routen werden in `server.mjs` angelegt.
|
||||
- Neue Datenbankfunktionen werden aus `lib/db.mjs` exportiert und in `server.mjs` importiert.
|
||||
- Frontend-Tabs verwenden `data-tab`, `<section class="tab-content">` und die `TAB_TITLES`-Map.
|
||||
- Farben werden ausschließlich über CSS-Variablen definiert; Dark Mode liegt unter `[data-theme="dark"]`.
|
||||
- SKU-Format: `VD-XXXX`.
|
||||
- Schreibende Requests benötigen Authentifizierung, Rollenprüfung und CSRF-Schutz.
|
||||
Runtimeordner werden in `.gitignore` ausschließlich am Projektroot ausgeschlossen. Dadurch bleiben `app/modules/` und `app/modules/sessions/` normale, versionierte Quellordner. Der Deploy-Assistent prüft vor jedem Push, dass alle produktiven Moduldateien physisch vorhanden und durch Git getrackt sind.
|
||||
|
||||
## Betriebsmodelle
|
||||
## CI-Hotfix 1.41.1
|
||||
|
||||
1. Lokale Windows-Installation
|
||||
2. Privater Docker-Betrieb für einen geschlossenen Benutzerkreis
|
||||
3. Öffentlicher Docker-Betrieb auf einem getrennten Server/VPS
|
||||
- `Archiv/`, `archive/` und `archives/` werden nicht mehr als produktiver Quellcode geprüft, nach GitHub übertragen oder in Releases gepackt.
|
||||
- Das ESM-Gate prüft ausschließlich `server.mjs`, `bootstrap.mjs`, `app/`, `lib/`, `public/`, `scripts/` und `tools/`.
|
||||
- Die produktiven Listings-/Inventory-Moduldateien werden separat als Pflichtdateien geprüft.
|
||||
- Ein Regressionstest beweist: Archivstände werden ignoriert, echte fehlende Imports im Source-Root bleiben ein harter Fehler.
|
||||
|
||||
Ein direktes Freigeben des NAS oder von Port `8124` ins Internet ist nicht vorgesehen.
|
||||
## Coolify-Produktionsbetrieb
|
||||
|
||||
- Dockerfile-Build direkt aus dem privaten GitHub-Repository
|
||||
- Domain und HTTPS über den Coolify-Reverse-Proxy
|
||||
- interner Anwendungsport `8124` ohne öffentliches Host-Port-Mapping
|
||||
- ein persistentes Volume unter `/app/data`
|
||||
- Healthcheck über `/readyz`
|
||||
- FLUX Studio beim ersten Produktionsstart standardmäßig deaktiviert
|
||||
- keine Weitergabe des Docker-Sockets an Vendoo
|
||||
- genaue Anleitung: `docs/COOLIFY_PRODUCTION_1_41_0.md`
|
||||
|
||||
## Kontrollierte Updates
|
||||
|
||||
- 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
|
||||
|
||||
- Entwicklung und Import ausschließlich über Release-Branches
|
||||
- vollständiges Sicherheits-Staging mit `GitHub-Deploy-Assistent.bat`
|
||||
- Pull Request und GitHub Actions vor jedem Merge
|
||||
- empfohlenes `Squash and merge` nach `main`
|
||||
- GitHub Release `v1.41.1` erzeugt Release-Artefakte und GHCR-Images
|
||||
- GHCR-Tags: `1.41.1`, `1.41`, `stable` und `latest`
|
||||
- genaue Klick-für-Klick-Anleitung: `docs/GITHUB_MERGE_RELEASE_1_41_0.md`
|
||||
|
||||
## Plattformstand
|
||||
|
||||
- 18 registrierte Module
|
||||
- 16 native Module, davon Produktbild Studio standardmäßig deaktiviert
|
||||
- 2 transparente Legacy Bridges: System und Operations
|
||||
- 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
|
||||
|
||||
## FLUX Studio
|
||||
|
||||
Für Coolify wird beim ersten Start gesetzt:
|
||||
|
||||
```env
|
||||
VENDOO_INITIAL_DISABLED_MODULES=vendoo.flux-studio
|
||||
LOCAL_IMAGE_ENABLED=false
|
||||
```
|
||||
|
||||
FLUX Studio ist damit zunächst deaktiviert. Eine spätere bewusste Aktivierung über **Administration → Module** wird persistent gespeichert und nicht bei jedem Neustart wieder überschrieben.
|
||||
|
||||
## Einfachster Start
|
||||
|
||||
- **Windows lokal:** `setup.bat` starten und den geführten Assistenten verwenden.
|
||||
- **Linux/NAS:** `chmod +x setup.sh && ./setup.sh`.
|
||||
- **Coolify-Produktion:** GitHub-Repository per GitHub App anbinden, Dockerfile-Build wählen, Port `8124`, Domain und Volume `/app/data` setzen.
|
||||
- **GitHub-Erstimport:** `GitHub-Deploy-Assistent.bat` starten, Sicherheitsbericht prüfen und erst danach `PUSH` eingeben.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Keine `.env`-Dateien, API-Schlüssel, OAuth-Tokens, SQLite-Datenbanken, Uploads, Backups oder Logs committen. Weitere Hinweise stehen in [`SECURITY.md`](SECURITY.md).
|
||||
- `.env`, Datenbanken, Uploads, Backups, Logs, Secret Store, Master-Key, installierte Module und lokale Updatearchive werden nicht committed.
|
||||
- der Produktionscontainer besitzt keinen Docker-Socket.
|
||||
- Coolify-Token und Deploy-Webhook werden nie im Klartext an den Browser zurückgegeben.
|
||||
- Deployments benötigen `updates.manage`, CSRF-Schutz, Rate Limit, Audit und die Bestätigung `DEPLOY`.
|
||||
- Updates löschen keine persistenten Daten.
|
||||
|
||||
## Lizenz
|
||||
---
|
||||
|
||||
## Vorheriger Stand 1.31.0
|
||||
|
||||
Vendoo 1.31.0 schafft die Grundlage für einen kontrollierten Mehrbenutzer- und Serverbetrieb. Rollen und Berechtigungen werden nicht nur in der Oberfläche, sondern an jeder geschützten API erzwungen. Artikelbearbeitung wird mit kurzlebigen Sperren koordiniert; Sitzungen, fehlgeschlagene Logins, Audit-Ereignisse und Rate Limits sind nachvollziehbar.
|
||||
|
||||
## Rollen
|
||||
|
||||
- **Administrator:** vollständiger Zugriff einschließlich Benutzer, Backups, Updates und Sicherheit.
|
||||
- **Manager:** operative Verwaltung von Artikeln, Medien und Publishing, ohne Benutzer- oder Systemadministration.
|
||||
- **Editor:** Artikel, Bilder, AI, FLUX, Bildproduktion und Qualitätsprüfung.
|
||||
- **Publisher:** Artikel lesen, Qualität prüfen und Veröffentlichungen ausführen.
|
||||
- **Leser:** reiner Lesezugriff.
|
||||
|
||||
## Sicherer Netzwerkbetrieb
|
||||
|
||||
Standardmäßig bindet Vendoo ausschließlich an `127.0.0.1`. Einen privaten LAN-Zugriff aktiviert der Installationsassistent nur nach bewusster Auswahl und setzt die erforderlichen Werte automatisch. Für öffentlichen Zugriff verwendet der VPS-Assistent den getrennten Caddy-Stack mit HTTPS; Port 8124 wird dabei nicht direkt veröffentlicht.
|
||||
|
||||
## Bestehende Funktionen
|
||||
|
||||
Alle Funktionen aus 1.30.0 bleiben enthalten: Operations Center, Publishing Hardening, Quality Center, Batch Image Factory, FLUX Studio Pro, Advanced Image Editor, Galerie und Browser-Extensions.
|
||||
|
||||
## Betriebszentrale (1.30.0)
|
||||
|
||||
Unter **Admin → System** stehen jetzt drei zusammenhängende Bereiche bereit:
|
||||
|
||||
- **Backup & Restore** mit SQLite-Snapshot, Datei-Prüfsummen, Integritätsprüfung, Download, Rotation und Wiederherstellungs-Staging
|
||||
- **Update Center** mit Versionsprüfung, Paketvalidierung, Migrationsstatus, Sicherheitsbackup und bewusstem Offline-Anwenden über `setup.bat`
|
||||
- **Extension-Diagnose** mit Paketversionen, Heartbeats, zuletzt verbundenen Browsern und Live-/Offline-Status
|
||||
|
||||
Update und Wiederherstellung ersetzen niemals während des laufenden Servers Dateien. Nach erfolgreicher Prüfung wird eine Operation vorbereitet und anschließend bei beendetem Vendoo über Setup-Menüpunkt 11 angewendet. Menüpunkt 12 stellt den zuvor gesicherten Programm- und Datenstand wieder her.
|
||||
|
||||
## Sicherheitsprinzipien
|
||||
|
||||
- Keine direkte Kopie einer geöffneten SQLite-Datei; Backups verwenden die SQLite-Backup-API.
|
||||
- Jedes Backup enthält ein Manifest mit SHA-256-Prüfsummen und wird sofort geprüft.
|
||||
- Eine Wiederherstellung erzeugt vorab automatisch ein vollständiges Sicherheitsbackup.
|
||||
- Update-ZIPs mit `.env`, Datenbank, Uploads oder `node_modules` werden abgelehnt.
|
||||
- Zugangsdaten aus `.env` werden nur nach ausdrücklicher Auswahl in manuelle Backups aufgenommen.
|
||||
- Automatische Backups enthalten niemals `.env`.
|
||||
- Update und Restore besitzen einen lokalen Rollback-Snapshot.
|
||||
|
||||
## Publishing Hardening (1.29.0)
|
||||
|
||||
- persistente Publishing-Queue für API- und Extension-Aufträge
|
||||
- Schutz vor Doppelveröffentlichungen
|
||||
- automatische Wiederholungen mit wachsendem Abstand
|
||||
- Abbruch, manueller Retry und sichere Protokolllöschung
|
||||
- Fehlerzentrale mit Audit-Ereignissen
|
||||
- externe IDs, URLs und eBay Offer-IDs
|
||||
- eBay Statusabgleich, Aktualisierung und Beenden über offizielle API
|
||||
- Etsy Statusabgleich über offizielle API
|
||||
- Vinted/Kleinanzeigen als bestätigungspflichtiger Extension-Workflow
|
||||
- Wiederaufnahme laufender API-Aufträge nach Vendoo-Neustart
|
||||
- geplante Veröffentlichungen werden an dieselbe gehärtete Queue übergeben
|
||||
|
||||
Vendoo 1.28.0 ergänzt die produktive **Batch Image Factory**. Mehrere Bilder aus der zentralen Galerie können in einem persistenten Auftrag über ein oder mehrere Produktionsprofile für Marktplätze, Webshops und Social Media verarbeitet werden. Ergebnisse bleiben nicht destruktiv, erscheinen wieder in der Galerie und lassen sich gesammelt als ZIP exportieren.
|
||||
|
||||
## Batch Image Factory (1.28.0)
|
||||
|
||||
- Hauptmenü **Bildproduktion** mit Quellenwahl aus der zentralen Bildergalerie
|
||||
- Acht integrierte Ausgabeprofile sowie eigene speicherbare Profile
|
||||
- Mehrfachprofile pro Auftrag und bis zu 200 Quellbilder
|
||||
- Hintergrundentfernung, Sensitivität und Wasserzeichen als globale Auftragsoptionen
|
||||
- persistente Job- und Positionshistorie mit Pause, Fortsetzen, Abbruch und Retry
|
||||
- Wiederaufnahme nach Vendoo-Neustart
|
||||
- nicht destruktive Ergebnisse unter `uploads/batch/`
|
||||
- automatische Galerieintegration und ZIP-Export pro Auftrag
|
||||
|
||||
|
||||
## Neu in 1.12.0
|
||||
|
||||
Der mobile QR-Upload bietet nun zwei eindeutige Wege: **Kamera öffnen** und **Mediathek auswählen**. Dadurch erzwingt das Smartphone nicht mehr ausschließlich die Kamera. Mehrere vorhandene Bilder können direkt gewählt werden; HEIC/HEIF werden nach Möglichkeit serverseitig zu JPG konvertiert.
|
||||
|
||||
OpenRouter wurde zusätzlich gehärtet: Vendoo erkennt Free-Bildmodelle präziser, prüft Bildinput-Fähigkeit, versucht mehrere kompatible Request-Formen und wechselt bei Fehlern automatisch zum nächsten Modell. Sind aktuell keine kostenlosen Bildmodelle vorhanden, zeigt die Diagnose das ausdrücklich an.
|
||||
|
||||
## Neu in 1.11.0
|
||||
|
||||
Der QR-Handy-Upload erzeugt jetzt automatisch Links mit der erkannten LAN-IP des Vendoo-PCs. Unter **Einstellungen → Vendoo LAN-Adresse für QR-Upload** kann die Adresse erkannt, geprüft und dauerhaft gespeichert werden. Die mobile Navigation wurde für Smartphone und Tablet komplett stabilisiert. Bei OpenRouter-Bildgenerierung versucht Vendoo automatisch mehrere passende Free-Modelle nacheinander und nutzt im Auto-Modus anschließend OpenAI als Fallback.
|
||||
|
||||
## Neu in 1.10.0
|
||||
|
||||
Vendoo unterstützt jetzt eine **AI-Job-Queue** für Bildgenerierung. Kleidung kann mit **1 bis 3** Varianten als **Model**, **Ghost Mannequin** oder **Flatlay** generiert werden. Im Listing-Editor gibt es zusätzlich eine **Historie früherer AI-Generierungen**, damit erzeugte Bilder schnell wiederverwendet werden können.
|
||||
|
||||
## Neu in 1.9.0
|
||||
|
||||
Die Einstellungen wurden um ein eigenes Steuerzentrum für **AI-Model-Fotos** erweitert. Dort lassen sich Provider, Free-Filter, Safety-Blocker, Standard-Preset, Variantenanzahl und das OpenAI-Fallback-Modell pflegen. Zusätzlich kann die UI die aktuell verfügbaren OpenRouter-Bildmodelle direkt laden und anzeigen.
|
||||
|
||||
## Neu in 1.8.0
|
||||
|
||||
Vendoo kann jetzt neben OpenAI auch OpenRouter für AI-Model-Fotos verwenden. Wenn `ai_model_photo_provider=auto` gesetzt ist und `OPENROUTER_API_KEY` vorhanden ist, fragt Vendoo automatisch aktuelle Bildmodelle von OpenRouter ab, filtert auf Bildausgabe und – sofern aktiviert – nur kostenlose Varianten. Gibt es kein passendes freies Modell mehr, fällt Vendoo sauber auf OpenAI zurück.
|
||||
|
||||
### Neue relevante Settings
|
||||
|
||||
- `ai_model_photo_provider`: `auto`, `openrouter`, `openai`
|
||||
- `ai_model_photo_openrouter_free_only`: `1` oder `0`
|
||||
- `ai_model_photo_openrouter_model`: optional feste OpenRouter-Model-ID
|
||||
- `ai_model_photo_openai_model`: OpenAI-Fallback-Modell, Default `gpt-image-1`
|
||||
|
||||
### Neue ENV-Variablen
|
||||
|
||||
- `OPENROUTER_API_KEY`
|
||||
- optional `OPENROUTER_SITE_URL`
|
||||
- optional `OPENROUTER_APP_NAME`
|
||||
|
||||
## Neu in 1.7.0
|
||||
|
||||
Vendoo kann jetzt für geeignete Kleidungsstücke sichere AI-Model-Fotos erzeugen. Der Workflow prüft zuerst, ob ein Kleidungsartikel ohne Person vorliegt, blockiert sensible Kategorien und erstellt danach 1–3 zusätzliche Lifestyle-/Studio-Bilder mit fiktiven erwachsenen Personen oder alternative Ghost-Mannequin-/Flatlay-Varianten. Die Bilder lassen sich direkt in Generator und Listing-Editor übernehmen.
|
||||
|
||||
|
||||
## Neu in 1.26.0
|
||||
|
||||
- Hauptnavigation mit **Neuer Artikel** direkt unter dem Dashboard und **Artikel** statt Listings
|
||||
- repariertes Publish Center mit zuverlässigem Smart-Copy-Popup
|
||||
- zoombare Medienvorschau mit Fit, 100 %, Mausrad und Verschieben
|
||||
- kompakte lokale SVG-Iconbibliothek für Galerieaktionen
|
||||
- aktualisierte Verkäufergebühren für eBay, Vinted, Kleinanzeigen und Etsy
|
||||
- eigenes Extension Center für Chrome, Edge, Firefox und Safari
|
||||
|
||||
# Vendoo
|
||||
|
||||
Lokaler Multi-Plattform Listing-Generator mit AI, Lagerverwaltung und Publishing-System.
|
||||
|
||||
## Aktueller Entwicklungsstand
|
||||
|
||||
- Version: `1.39.0`
|
||||
- UI-Slice: `UI 2026 / Slice 07 – Cross-Browser Extensions, Responsive UI & Mobile Upload`
|
||||
- Startseite: `Studio Flow`
|
||||
- Tech Stack: Node.js, Express (ESM), Vanilla JS, SQLite (`better-sqlite3`)
|
||||
|
||||
Die vollständige Funktionsbeschreibung steht in [`FEATURES.md`](FEATURES.md). Die freigegebene Designrichtung liegt unter [`MockupDesign/`](MockupDesign/). Der vollständige Listing-Editor ist in [`docs/FULL_LISTING_EDITOR_2026.md`](docs/FULL_LISTING_EDITOR_2026.md) dokumentiert.
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Danach ist Vendoo standardmäßig unter `http://localhost:8124` erreichbar.
|
||||
|
||||
## Wichtige Sicherheitsregel
|
||||
|
||||
Nicht versionieren oder weitergeben:
|
||||
|
||||
- `.env`
|
||||
- `db/vendoo.db*`
|
||||
- `uploads/`
|
||||
- Backups und Logs
|
||||
- API-Schlüssel, OAuth-Tokens und SMTP-Passwörter
|
||||
|
||||
Die bereitgestellte Projektversion enthält ausschließlich `.env.example` und keine produktiven Laufzeitdaten.
|
||||
|
||||
## Browser-Erweiterungen
|
||||
|
||||
Die aktuellen Erweiterungen liegen getrennt unter `extensions/chrome`, `extensions/edge`, `extensions/firefox` und `extensions/safari`. Über den Vendoo-Menüpunkt **Extensions** wird der aktive Browser erkannt, der Installationsordner geöffnet und der Pfad kopiert. Safari benötigt auf macOS zusätzlich Xcode und die mitgelieferte WebExtension-Konvertierung.
|
||||
|
||||
## Mobiler Foto-Upload
|
||||
|
||||
Im Generator und im Listing-Editor kann ein zeitlich begrenzter QR-Code erzeugt werden. Nach dem Scan können Fotos direkt vom Smartphone aufgenommen und ohne Neuladen an Vendoo übertragen werden. Für andere Geräte muss Vendoo über eine erreichbare LAN-IP oder `PUBLIC_BASE_URL` geöffnet sein; `localhost` funktioniert nur auf demselben Gerät.
|
||||
|
||||
## Browser-Erweiterung 1.4.0
|
||||
|
||||
Die Erweiterungen für Chrome, Edge und Firefox verwenden jetzt eine kompakte, aktionsorientierte Oberfläche. Die primäre Aktion **Nur ausfüllen** trägt ein Listing in das geöffnete Vinted-Formular ein, ohne einen Entwurf zu speichern und ohne eine Veröffentlichung auszulösen. Abschlussaktionen sind davon deutlich getrennt.
|
||||
|
||||
|
||||
## Aktueller UI-Stand
|
||||
|
||||
Slice 10 ergänzt das interaktive Dashboard, den aufgeräumten Studio Flow, robuste Publish-Initialisierung, ZIP-Fotodownloads und erweiterte Responsive-Gates.
|
||||
|
||||
|
||||
## Branding
|
||||
|
||||
Logo, App-Icons, mobile Größen und Favicons liegen unter `public/brand/`. Die Browser-Erweiterungen für Chrome, Edge und Firefox verwenden dieselbe Icon-Familie.
|
||||
|
||||
|
||||
## Local FLUX / ComfyUI
|
||||
Vendoo can now prefer a local image backend for AI model photos. The intended order is: **Local FLUX / ComfyUI -> OpenRouter Free -> OpenAI**.
|
||||
|
||||
### What was added
|
||||
- Settings for local URL, token, workflow path and install path
|
||||
- Backend-only access to the local image server
|
||||
- PowerShell bootstrap via `setup.ps1` or Settings UI
|
||||
- Token regeneration for restricted local access
|
||||
|
||||
### Default assumptions
|
||||
- ComfyUI is reachable at `http://127.0.0.1:8188`
|
||||
- Workflow file: `local-ai/workflows/vendoo_flux_schnell_api.json`
|
||||
- Vendoo talks to the local server from the backend, not directly from the browser
|
||||
|
||||
> Note: the included workflow template is a bootstrap starter and may need adjustment to the exact ComfyUI / FLUX node setup on the target machine.
|
||||
|
||||
|
||||
## Vollautomatische lokale FLUX-Installation
|
||||
|
||||
Vendoo kann ComfyUI Portable und optional FLUX.1-schnell FP8 direkt aus der Einstellungsseite oder über `setup.bat` installieren.
|
||||
|
||||
### Über die Benutzeroberfläche
|
||||
|
||||
1. **Einstellungen → AI-Model-Fotos** öffnen.
|
||||
2. Als Provider `Auto` oder `Nur lokales FLUX / ComfyUI` auswählen.
|
||||
3. Installationsoptionen wählen.
|
||||
4. **Lokales FLUX installieren** anklicken.
|
||||
5. Den Fortschritt direkt in Vendoo verfolgen.
|
||||
|
||||
### Über setup.bat
|
||||
|
||||
- `7` – lokales FLUX / ComfyUI installieren oder aktualisieren
|
||||
- `8` – Status anzeigen
|
||||
- `9` – lokalen Server starten
|
||||
- `10` – lokalen Server stoppen
|
||||
|
||||
### Sicherheitsmodell
|
||||
|
||||
ComfyUI wird ausschließlich an `127.0.0.1:8188` gebunden. Der Browser greift nicht direkt auf ComfyUI zu; nur das Vendoo-Backend sendet Bildjobs. Standardmäßig werden Remote-URLs abgelehnt. Ein zufälliger lokaler Token wird zusätzlich in der `.env` gespeichert. Das schützt den Dienst vor Zugriff aus dem LAN, ersetzt aber keine vollständige Prozessisolation gegenüber anderer Software auf demselben Windows-PC.
|
||||
|
||||
### Große Downloads
|
||||
|
||||
Der Modell-Download ist optional und wird vor dem Start ausdrücklich bestätigt. Bereits vorhandene ComfyUI- oder Modelldateien werden nicht ohne `Force` überschrieben. Downloads und Installationsfortschritt werden protokolliert.
|
||||
|
||||
|
||||
## FLUX installer status and recovery
|
||||
The local FLUX installer now records a detailed, resumable state in `local-ai/install-state.json`. The settings page displays individual checks for 7-Zip, the ComfyUI archive, embedded Python, ComfyUI, the FLUX model, workflow and runtime scripts. Failed installations can be started again without blindly discarding complete downloads.
|
||||
|
||||
The PowerShell 5.1 path bug that could turn `C:\Program Files\7-Zip\7z.exe` into the command `C` has been fixed.
|
||||
|
||||
|
||||
## FLUX installer status hotfix (1.14.2)
|
||||
The local FLUX installer now supervises 7-Zip instead of waiting without feedback. During extraction Vendoo shows files, extracted MB, elapsed time, heartbeat and the current 7-Zip log. A stale installer is marked as **stalled** and can be resumed without downloading the complete ComfyUI archive again.
|
||||
|
||||
## FLUX Studio ab 1.21.0
|
||||
|
||||
Vendoo enthält einen eigenen Hauptmenüpunkt **FLUX Studio**. Dort werden Bilder ausschließlich aus freien Prompts über das lokale ComfyUI/FLUX erzeugt. Einstellbar sind Stil, Ausgabeformat, Seed und Inferenzschritte. Ergebnisse werden in einer lokalen Bibliothek gespeichert, können heruntergeladen, in den Listing-Generator übernommen oder mit dem integrierten Editor nachbearbeitet werden.
|
||||
|
||||
Der frühere FASHN-/Virtual-Try-on-Stack wurde vollständig aufgegeben. Es werden kein Python-, Torch-, ONNX- oder separater VTO-Dienst mehr installiert. Beim ersten Start des aktualisierten Setups werden alte VTO-Runtime, Autostarts und Einstellungen entfernt; eigene frühere Modelbilder werden zur Sicherheit nach `uploads/ai-generated/legacy-models` verschoben.
|
||||
|
||||
### Update
|
||||
1. Vendoo schließen.
|
||||
2. Paketinhalt über die bestehende Installation kopieren und Dateien ersetzen.
|
||||
3. `setup.bat` starten und **Reparieren** ausführen.
|
||||
4. Vendoo starten und den Menüpunkt **FLUX Studio** öffnen.
|
||||
## FLUX Studio Pro ab 1.22.0
|
||||
|
||||
Slice 30 erweitert das reine Prompt-Studio zu einem persistenten Job-Center. Aufträge können 1, 2 oder 4 Varianten mit eigenen Seeds erzeugen. Seed Lock, Wiederholen, „Ähnlich erneut erzeugen“, Favoriten, Queue-/History-Anbindung, System-/GPU-Daten und Wiederaufnahme nach Vendoo-Neustart sind integriert.
|
||||
|
||||
Die Fortschrittsanzeige verwendet im FLUX-Studio-Pro-Pfad ausschließlich reale Phasen und verstrichene Laufzeit. Es werden keine Prozentwerte aus einer angenommenen Renderdauer errechnet.
|
||||
|
||||
### Update auf 1.22.0
|
||||
|
||||
1. Vendoo und ComfyUI-Fenster schließen.
|
||||
2. Den Inhalt des Paket-Unterordners `vendoo` über die bestehende Installation kopieren und Dateien ersetzen.
|
||||
3. Den bestehenden Zielordner und Laufzeitdaten nicht löschen.
|
||||
4. `setup.bat` starten und **3 – Update** ausführen.
|
||||
5. Bei Bedarf **9 – Lokales FLUX / ComfyUI starten**, danach **8 – Status** prüfen.
|
||||
6. Vendoo starten und FLUX Studio öffnen.
|
||||
|
||||
|
||||
|
||||
## Artikel Quality Center
|
||||
|
||||
Vendoo 1.28.0 prüft Artikel vor dem Publishing auf Blocker, Vollständigkeit, Bildqualität, mögliche Duplikate und Plattformtauglichkeit. AI-Verbesserungen werden nur als Vorschlag angezeigt und erst nach ausdrücklicher Bestätigung gespeichert.
|
||||
|
||||
## Installationsziel wählen (1.36.0)
|
||||
|
||||
`setup.bat` führt auf Windows durch lokale Node-Installation oder Docker Desktop. Für Linux, NAS, Portainer und VPS steht `setup.sh` bereit. Beide Wege verwenden dieselbe `.env`, dieselben Compose-Dateien und dieselben persistenten Datenbereiche.
|
||||
|
||||
Privates, proprietäres Projekt. Alle Rechte vorbehalten.
|
||||
|
||||
@@ -1,34 +1,151 @@
|
||||
# Sicherheitsrichtlinie
|
||||
## Source-Root- und Archivgrenzen ab 1.41.1
|
||||
|
||||
Vendoo ist derzeit ein privates Projekt. Sicherheitsprobleme dürfen nicht als öffentliche GitHub-Issues mit Zugangsdaten, Tokens oder personenbezogenen Daten gemeldet werden.
|
||||
Historische Übergabe- und Featurepakete unter `Archiv/`, `archive/` oder `archives/` sind keine ausführbare Produktquelle. Sie werden aus Git-Staging, Release-Staging, Secret-Scan und ESM-Vertragsprüfung ausgeschlossen. Der produktive Source-Root bleibt separat vollständig geprüft; fehlende reale Moduldateien blockieren CI und Release.
|
||||
|
||||
## Niemals committen
|
||||
# Sicherheit
|
||||
|
||||
- `.env` und produktive Konfigurationsdateien
|
||||
- API-Schlüssel und OAuth-Tokens
|
||||
- SMTP-Zugangsdaten
|
||||
- Session-Secrets
|
||||
- SQLite-Datenbanken einschließlich WAL- und SHM-Dateien
|
||||
- Uploads, Backups und Logs
|
||||
- private Zertifikate und Schlüssel
|
||||
## Vertrauliche Dateien
|
||||
|
||||
## Mindestanforderungen für Online-Betrieb
|
||||
Folgende Dateien dürfen niemals in Git oder öffentliche Pakete gelangen:
|
||||
|
||||
- kein direktes Port-Forwarding auf Vendoo oder die NAS-Verwaltung
|
||||
- HTTPS über vorgeschalteten Reverse Proxy oder sicheren Tunnel
|
||||
- sichere Cookies (`HttpOnly`, `Secure`, `SameSite`)
|
||||
- korrekt begrenztes Express `trust proxy`
|
||||
- CSRF-Schutz für alle schreibenden Requests
|
||||
- Rollen- und Organisationsprüfung auf jeder geschützten Ressource
|
||||
- Rate-Limits für Login, Magic Links und sensible APIs
|
||||
- nicht privilegierter Container ohne Docker-Socket
|
||||
- persistente Daten ausschließlich in expliziten Vendoo-Volumes
|
||||
- automatische, geprüfte Backups
|
||||
- `.env`
|
||||
- `db/*.db`, `db/*.db-wal`, `db/*.db-shm`
|
||||
- `uploads/`
|
||||
- Backups, Logs und Session-Daten
|
||||
- private Schlüssel und Zertifikate
|
||||
|
||||
## Geheimnisse
|
||||
## Online-Betrieb
|
||||
|
||||
Produktive Geheimnisse werden über Server-Secrets beziehungsweise `_FILE`-Umgebungsvariablen bereitgestellt und nicht in der Datenbank oder im Repository im Klartext abgelegt.
|
||||
Vendoo darf nicht durch eine direkte Portfreigabe von Port 8124 veröffentlicht werden. Für öffentliche Nutzer ist eine getrennte Server-/VPS-Instanz hinter HTTPS und Reverse Proxy vorgesehen.
|
||||
|
||||
## Abhängigkeiten
|
||||
## Meldung von Sicherheitsproblemen
|
||||
|
||||
Vor Releases werden Abhängigkeiten, Container-Basisimages und bekannte Sicherheitslücken geprüft. Sicherheitsupdates erhalten Vorrang vor neuen Features.
|
||||
Sicherheitsprobleme nicht mit Zugangsdaten oder personenbezogenen Daten in öffentliche Issues schreiben.
|
||||
|
||||
|
||||
## Lokaler FLUX / ComfyUI
|
||||
|
||||
- ComfyUI wird standardmäßig nur an `127.0.0.1` gebunden.
|
||||
- Vendoo blockiert Remote-ComfyUI-URLs, solange `LOCAL_IMAGE_ALLOW_REMOTE` nicht ausdrücklich aktiviert wird.
|
||||
- Die ComfyUI-Oberfläche wird nicht über das LAN veröffentlicht.
|
||||
- API-Aufrufe erfolgen serverseitig durch Vendoo.
|
||||
- Ein zufälliger Token wird für die lokale Integrationskonfiguration erzeugt.
|
||||
- Andere Prozesse auf demselben Windows-Rechner können theoretisch ebenfalls auf einen localhost-Dienst zugreifen; für echte Prozessisolation wäre ein separates Windows-Konto oder eine VM erforderlich.
|
||||
|
||||
## ESM-Modulverträge ab 1.41.0
|
||||
|
||||
- lokale benannte ESM-Imports werden vor CI und Release gegen die tatsächlich exportierten Symbole geprüft
|
||||
- fehlende Exporte blockieren den Build vor der Auslieferung
|
||||
- Worker-Start- und Stop-Verträge werden separat geprüft
|
||||
|
||||
## FLUX Studio
|
||||
|
||||
- ComfyUI bleibt standardmäßig ausschließlich an `127.0.0.1` gebunden.
|
||||
- Das FLUX Studio akzeptiert nur Textprompts und erzeugt Dateien unter `uploads/ai-generated`.
|
||||
- API-Pfade für Bibliothek und Bearbeitungen werden serverseitig auf dieses Verzeichnis begrenzt.
|
||||
- Start und Stopp der lokalen Engine erfordern eine Admin-Rolle.
|
||||
- Bearbeitungen erzeugen neue Dateien; Originalausgaben werden nicht still überschrieben.
|
||||
|
||||
|
||||
## Operations Center, Backups und Updates
|
||||
|
||||
- Backups werden mit einem Manifest und SHA-256-Prüfsummen erzeugt und vor der Freigabe verifiziert.
|
||||
- Sicherungen, die `.env` enthalten, enthalten möglicherweise API-Schlüssel, Tokens und Zugangsdaten. Sie sind wie Passwörter zu behandeln und dürfen nicht unverschlüsselt weitergegeben werden.
|
||||
- Restore- und Update-Archive werden vor der Anwendung in einem lokalen Staging-Bereich geprüft.
|
||||
- Archive mit Pfad-Traversal, absoluten Pfaden oder unzulässigen Laufzeitdaten werden abgewiesen.
|
||||
- Updates dürfen keine `.env`, produktive Datenbank, Uploads, Backups, Logs, Modelle oder `node_modules` mitbringen.
|
||||
- Das eigentliche Anwenden erfolgt nur bei gestopptem Vendoo über `setup.bat` und erzeugt vorher einen Rollback-Snapshot.
|
||||
- Eine optionale Update-Manifest-URL sollte ausschließlich über HTTPS und aus einer kontrollierten Quelle verwendet werden.
|
||||
- Extension-Heartbeats enthalten nur Diagnosemetadaten wie Browser, Version, Plattform und Zeitpunkt; keine Formularinhalte oder Zugangsdaten.
|
||||
## Mehrbenutzer- und Server-Sicherheit ab 1.31.0
|
||||
|
||||
- Vendoo bindet standardmäßig an `127.0.0.1`. LAN-Zugriff muss ausdrücklich über `VENDOO_BIND_HOST=0.0.0.0` aktiviert werden.
|
||||
- Öffentlicher Betrieb ausschließlich über HTTPS und einen korrekt konfigurierten Reverse Proxy. `VENDOO_TRUST_PROXY=true` nur setzen, wenn der Proxy vertrauenswürdig ist und Client-IP-Header kontrolliert.
|
||||
- `VENDOO_ALLOWED_HOSTS` und `VENDOO_ALLOWED_ORIGINS` eng begrenzen.
|
||||
- Rollen ersetzen keine Betriebssystemrechte: Datenbank, `.env`, Backups und Uploads müssen auf Dateisystemebene geschützt bleiben.
|
||||
- Bearbeitungssperren verhindern parallele Änderungen, ersetzen aber keine Backups.
|
||||
- Audit-Logs können personenbezogene Betriebsdaten wie Benutzer, IP und Aktionen enthalten; Aufbewahrung und Zugriff müssen organisatorisch geregelt werden.
|
||||
|
||||
|
||||
|
||||
## Architektur-Sicherheitsregeln ab 1.35.0
|
||||
|
||||
- Neue Kernel-Routen sind Deny-by-default und benötigen eine registrierte Policy.
|
||||
- Modulmanifeste, Abhängigkeiten, Capabilities und Ownership werden vor dem Start validiert.
|
||||
- Zyklische Abhängigkeiten, fehlende Capabilities und doppelte Ressourcen-Ownership blockieren den Kernelstart.
|
||||
- Drittanbieter-Erweiterungen dürfen nicht im Vendoo-Hauptprozess ausgeführt werden.
|
||||
- Themes dürfen nur freigegebene, typisierte Design Tokens verändern; freies CSS, JavaScript und externe URLs sind verboten.
|
||||
- Der Event Bus ordnet Listener einem Owner zu, damit Module beim Stoppen ihre Listener vollständig entfernen können.
|
||||
- Das Architektur-Gate `npm run verify:architecture` ist verpflichtender Bestandteil von CI und Releases.
|
||||
|
||||
## Theme-Sicherheit ab 1.36.0
|
||||
|
||||
- Theme-Profile akzeptieren ausschließlich bekannte, typisierte Design Tokens.
|
||||
- Freies CSS, JavaScript, HTML, URLs und Dateipfade sind nicht zulässig.
|
||||
- Alle Werte werden im Browser zur Benutzerführung und erneut auf dem Server validiert.
|
||||
- Normale Benutzer können nur die eigene Theme-Präferenz verändern.
|
||||
- Theme-Profile und Systemstandard erfordern `settings.manage`.
|
||||
- Theme-Änderungen werden auditiert.
|
||||
- Importierte Profile durchlaufen dieselbe Validierung wie manuell erstellte Profile.
|
||||
- Kontrastprüfungen verhindern nicht automatisch jede bewusste administrative Gestaltung, zeigen problematische Paare aber vor dem Speichern deutlich an.
|
||||
|
||||
|
||||
|
||||
## Security Foundation ab 1.37.0
|
||||
|
||||
Zugangsdaten werden bevorzugt im verschlüsselten Secret-Speicher unter dem persistenten Konfigurationsbereich abgelegt. Die Verschlüsselung verwendet AES-256-GCM; der Master-Key liegt getrennt und wird nicht in Git oder Release-Pakete aufgenommen. Bestehende Secrets aus der Runtime-Konfiguration werden beim ersten Start gesichert, verschlüsselt migriert und aus der Klartextdatei entfernt.
|
||||
|
||||
Die Weboberfläche verwendet eine erzwungene Content Security Policy ohne Inline-Skripte und Inline-Eventhandler. Request- und Correlation-IDs werden in Antworten ausgegeben und in der strukturierten Telemetrie geführt.
|
||||
|
||||
## Identity- und Settings-Sicherheit ab 1.38.0
|
||||
|
||||
- Auth, Benutzer, Sitzungen und Einstellungen laufen als native Module über explizite Policies und Eingabeschemata.
|
||||
- Der letzte aktive Administrator kann nicht gelöscht, deaktiviert oder zu einer Nicht-Admin-Rolle herabgestuft werden.
|
||||
- Benutzer können nur Sitzungen widerrufen, die ihrer eigenen Benutzer-ID gehören; administrative globale Revocation erfordert `sessions.manage`.
|
||||
- Login-Historie erfordert `users.manage`, SMTP-Verwaltung `settings.manage`.
|
||||
- Die Settings-Allowlist weist unbekannte oder nicht freigegebene Felder zurück.
|
||||
- Passwort-Hashes, Session-Tokens und Secret-Klarwerte dürfen nicht in Listen-, Status- oder Audit-Antworten erscheinen.
|
||||
- `lib/auth.mjs` ist nur noch eine Kompatibilitätsfassade; neue Identitätslogik gehört in die nativen Module.
|
||||
|
||||
## Catalog- und Medien-Sicherheit ab 1.39.0
|
||||
|
||||
- Artikelrouten verwenden explizite Policies für Lesen, Bearbeiten, Papierkorb und endgültiges Löschen.
|
||||
- Unbekannte Artikel-, Lager- und Medienfelder werden abgewiesen.
|
||||
- Rich-HTML wird serverseitig bereinigt; Preise, Texte, Tags und Fotolisten besitzen feste Grenzen.
|
||||
- Endgültiges Löschen ist nur für bereits im Papierkorb befindliche Artikel möglich.
|
||||
- Lager-Bulk-Aktionen sind auf 500 eindeutige positive Artikel-IDs begrenzt.
|
||||
- Medienpfade werden normalisiert und gegen Traversal, Nullbytes sowie Zeilenumbrüche geprüft.
|
||||
- Von aktiven Artikeln referenzierte Bilder sind beim Löschen geschützt.
|
||||
- Modulimporte initialisieren keine Datenbank; Repositories werden beim Serverstart injiziert.
|
||||
|
||||
|
||||
## Modulsicherheit ab 1.40.0
|
||||
|
||||
- Kernmodule sind geschützt und können weder deaktiviert noch gelöscht werden.
|
||||
- Aktivierungszustände werden atomar im persistenten Konfigurationsbereich gespeichert.
|
||||
- `.vmod`-Pakete werden auf Format, Manifest, Pfade, Dateigrößen, Einzeldatei-SHA-256 und Gesamtintegrität geprüft.
|
||||
- Installierbare Fremdmodule müssen den Typ `extension-host` verwenden.
|
||||
- Deklarative Pakete dürfen keine JavaScript-, Shell-, Native- oder Binärdateien enthalten.
|
||||
- Ausführbare Pakete benötigen eine vertrauenswürdige Ed25519-Signatur und dürfen ausschließlich in einem isolierten Extension-Host laufen. Solange dieser Host nicht freigegeben ist, bleiben solche Pakete deaktiviert und quarantänisiert.
|
||||
- Fremdmodule werden niemals durch bloßes Kopieren automatisch registriert oder aktiviert.
|
||||
- Abhängigkeiten werden vor Aktivierung geprüft; geschützte Abhängigkeiten verhindern unsichere Deaktivierung.
|
||||
- Installierte Modulpakete und `modules-state.json` werden gemeinsam gesichert und wiederhergestellt.
|
||||
- Der Laufzeitordner `modules/` ist von Git-Import und Release-Paketen ausgeschlossen.
|
||||
|
||||
## Produktionsdeployments über Coolify (1.41.0)
|
||||
|
||||
- der Vendoo-Container erhält keinen Zugriff auf `/var/run/docker.sock`
|
||||
- Vendoo verändert seinen eigenen Container nicht und führt kein `git pull` aus
|
||||
- Coolify-API-Token und Deploy-Webhook liegen ausschließlich im verschlüsselten Secret Store
|
||||
- Deploymentrouten benötigen `updates.manage`, CSRF-Schutz, Rate Limit und Audit
|
||||
- ein Deployment erfordert die exakte Bestätigung `DEPLOY`
|
||||
- standardmäßig wird vor jedem Auftrag ein Backup einschließlich Konfiguration erzeugt
|
||||
- Produktionsendpunkte müssen HTTPS verwenden
|
||||
- Antworten externer Deploymentdienste werden begrenzt und Redirects nicht automatisch verfolgt
|
||||
- der Initialwert für deaktivierte Module überschreibt keine spätere persistente Administratorentscheidung
|
||||
|
||||
|
||||
|
||||
## Git-Ignore-Grenzen
|
||||
|
||||
Lokale Runtimeordner werden ausschließlich als Root-Pfade (`/modules/`, `/sessions/`, `/logs/` usw.) ignoriert. Produktive Quellpfade unter `app/modules/` dürfen weder ignoriert noch ungetrackt sein; CI und Deploy-Assistent erzwingen diese Grenze.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Drittanbieter-Hinweise für lokale AI-Engines
|
||||
|
||||
## FLUX / ComfyUI
|
||||
|
||||
Vendoo kann eine lokale ComfyUI-Installation mit einem FLUX-kompatiblen Modell ansteuern. ComfyUI, Modelle, Custom Nodes und deren Gewichte werden nicht als Vendoo-eigene Software ausgegeben. Für jede installierte Komponente gelten die jeweiligen Lizenz- und Nutzungsbedingungen der Herausgeber.
|
||||
|
||||
Vendoo liefert keine FASHN-VTON-, Python-, Torch-, ONNX- oder Human-Parser-Komponenten mehr aus und installiert diese auch nicht mehr.
|
||||
|
||||
|
||||
## Lucide Icons
|
||||
|
||||
Die lokale Vendoo-Iconbibliothek verwendet eine kompakte Auswahl von Lucide-Icon-Geometrien.
|
||||
Lucide steht unter der ISC-Lizenz. Projekt: https://lucide.dev/
|
||||
@@ -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.4 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%
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"architecture": "modular-monolith",
|
||||
"rules": {
|
||||
"modulePrefix": "vendoo.",
|
||||
"denyImplicitRoutes": true,
|
||||
"denyCrossModuleDatabaseAccess": true,
|
||||
"denyCrossModuleInternalImports": true,
|
||||
"thirdPartyExtensionsInMainProcess": false,
|
||||
"themeCustomCss": false,
|
||||
"themeTokensOnly": true,
|
||||
"denyUnknownSettings": true,
|
||||
"sessionOwnershipRequired": true,
|
||||
"lastActiveAdminProtected": true,
|
||||
"catalogPayloadAllowlist": true,
|
||||
"mediaReferenceProtection": true,
|
||||
"inventoryBulkLimit": 500
|
||||
},
|
||||
"nativeModules": [
|
||||
"vendoo.auth",
|
||||
"vendoo.users",
|
||||
"vendoo.sessions",
|
||||
"vendoo.settings",
|
||||
"vendoo.security",
|
||||
"vendoo.themes",
|
||||
"vendoo.media",
|
||||
"vendoo.listings",
|
||||
"vendoo.inventory"
|
||||
],
|
||||
"migration": {
|
||||
"mode": "strangler",
|
||||
"legacyBridgeStatus": "legacy-bridge",
|
||||
"frameworkMigration": false,
|
||||
"databaseReplacement": false,
|
||||
"identityCompatibilityFacade": "lib/auth.mjs",
|
||||
"catalogCompatibilityLayer": "lib/db.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://vendoo.local/contracts/module.schema.json",
|
||||
"title": "Vendoo Module Manifest",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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-]*$"
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { db } from '../../../lib/db.mjs';
|
||||
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||
|
||||
// --- Schema ---
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
avatar_color TEXT,
|
||||
last_active_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
invited_by INTEGER,
|
||||
FOREIGN KEY (invited_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'login',
|
||||
role TEXT DEFAULT 'editor',
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
user_email TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
details TEXT,
|
||||
ip TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: add password_hash column if missing
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN password_hash TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN locked_until TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN password_changed_at TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE sessions ADD COLUMN last_seen_at TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE sessions ADD COLUMN revoked_at TEXT"); } catch {}
|
||||
try { db.exec("UPDATE sessions SET last_seen_at = COALESCE(last_seen_at, created_at)"); } catch {}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'#4a7c59', '#2d6a9f', '#8b5a3c', '#6b4c8a', '#c4713b',
|
||||
'#3a8c7a', '#7c4d6e', '#5a7c3a', '#8c5a5a', '#3a6b8c',
|
||||
];
|
||||
|
||||
const SESSION_DURATION_MS = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000;
|
||||
const SESSION_IDLE_MS = Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)) * 60 * 1000;
|
||||
const SESSION_TOUCH_INTERVAL_MS = 60 * 1000;
|
||||
const LOGIN_TOKEN_EXPIRY_MIN = 15;
|
||||
const INVITE_TOKEN_EXPIRY_HOURS = 48;
|
||||
|
||||
function hashToken(token) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function randomAvatarColor() {
|
||||
return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
||||
}
|
||||
|
||||
// --- Password Hashing (scrypt) ---
|
||||
export function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, storedHash) {
|
||||
if (!storedHash) return false;
|
||||
const [salt, hash] = storedHash.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const hashBuf = Buffer.from(hash, 'hex');
|
||||
const supplied = scryptSync(password, salt, 64);
|
||||
return timingSafeEqual(hashBuf, supplied);
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
export function createUser(email, name, role = 'editor', invitedBy = null, password = null) {
|
||||
role = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor';
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existing) throw new Error('User existiert bereits');
|
||||
const color = randomAvatarColor();
|
||||
const pwHash = password ? hashPassword(password) : null;
|
||||
const r = db.prepare('INSERT INTO users (email, name, role, avatar_color, invited_by, password_hash) VALUES (?, ?, ?, ?, ?, ?)').run(email, name, role, color, invitedBy, pwHash);
|
||||
return getUser(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function setPassword(userId, password) {
|
||||
const pwHash = hashPassword(password);
|
||||
db.prepare("UPDATE users SET password_hash = ?, password_changed_at = datetime('now'), failed_login_count = 0, locked_until = NULL WHERE id = ?").run(pwHash, userId);
|
||||
destroyUserSessions(userId);
|
||||
}
|
||||
|
||||
export function validatePasswordStrength(password) {
|
||||
const value = String(password || '');
|
||||
const problems = [];
|
||||
if (value.length < 10) problems.push('mindestens 10 Zeichen');
|
||||
if (!/[a-z]/.test(value)) problems.push('einen Kleinbuchstaben');
|
||||
if (!/[A-Z]/.test(value)) problems.push('einen Großbuchstaben');
|
||||
if (!/\d/.test(value)) problems.push('eine Zahl');
|
||||
if (!/[^A-Za-z0-9]/.test(value)) problems.push('ein Sonderzeichen');
|
||||
return { valid: problems.length === 0, problems };
|
||||
}
|
||||
|
||||
export function registerFailedLogin(userId, maxAttempts = 8, lockMinutes = 15) {
|
||||
if (!userId) return null;
|
||||
const user = getUser(userId);
|
||||
if (!user) return null;
|
||||
const count = Number(user.failed_login_count || 0) + 1;
|
||||
if (count >= maxAttempts) {
|
||||
const lockedUntil = new Date(Date.now() + lockMinutes * 60 * 1000).toISOString();
|
||||
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = ? WHERE id = ?').run(lockedUntil, userId);
|
||||
destroyUserSessions(userId);
|
||||
return { locked: true, lockedUntil };
|
||||
}
|
||||
db.prepare('UPDATE users SET failed_login_count = ? WHERE id = ?').run(count, userId);
|
||||
return { locked: false, remaining: Math.max(0, maxAttempts - count) };
|
||||
}
|
||||
|
||||
export function clearFailedLogins(userId) {
|
||||
if (!userId) return;
|
||||
db.prepare('UPDATE users SET failed_login_count = 0, locked_until = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
export function isUserLocked(user) {
|
||||
if (!user?.locked_until) return false;
|
||||
const until = Date.parse(user.locked_until);
|
||||
if (!Number.isFinite(until) || until <= Date.now()) {
|
||||
clearFailedLogins(user.id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getUser(id) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
export function getUserByEmail(email) {
|
||||
return db.prepare('SELECT * FROM users WHERE email = ? COLLATE NOCASE').get(email);
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return db.prepare("SELECT id, email, name, role, active, avatar_color, last_active_at, created_at, locked_until, failed_login_count, CASE WHEN password_hash IS NOT NULL AND password_hash != '' THEN 1 ELSE 0 END as has_password FROM users ORDER BY created_at").all();
|
||||
}
|
||||
|
||||
export function updateUser(id, data) {
|
||||
const allowed = ['name', 'role', 'active', 'avatar_color'];
|
||||
const fields = [], values = [];
|
||||
for (const [k, rawValue] of Object.entries(data)) {
|
||||
if (!allowed.includes(k) || rawValue === undefined) continue;
|
||||
const v = k === 'role' ? (['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(rawValue) ? rawValue : 'viewer') : rawValue;
|
||||
fields.push(`${k} = ?`); values.push(v);
|
||||
}
|
||||
if (!fields.length) return getUsers().find(user => Number(user.id) === Number(id)) || null;
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getUsers().find(user => Number(user.id) === Number(id)) || null;
|
||||
}
|
||||
|
||||
export function deleteUser(id) {
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
export function touchUserActivity(userId) {
|
||||
db.prepare("UPDATE users SET last_active_at = datetime('now') WHERE id = ?").run(userId);
|
||||
}
|
||||
|
||||
export function getUserCount() {
|
||||
return db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
}
|
||||
|
||||
// --- Sessions ---
|
||||
export function createSession(userId, ip, userAgent) {
|
||||
const token = generateToken();
|
||||
const hash = hashToken(token);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_DURATION_MS).toISOString();
|
||||
const lastSeenAt = now.toISOString();
|
||||
db.prepare('INSERT INTO sessions (user_id, token_hash, ip, user_agent, expires_at, last_seen_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)').run(userId, hash, ip, userAgent || '', expiresAt, lastSeenAt);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function validateSession(token) {
|
||||
if (!token) return null;
|
||||
const hash = hashToken(token);
|
||||
const session = db.prepare("SELECT s.*, u.email, u.name, u.role, u.active, u.avatar_color, u.locked_until FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now')").get(hash);
|
||||
if (!session || !session.active || isUserLocked(session)) return null;
|
||||
const lastSeen = Date.parse(session.last_seen_at || session.created_at || 0);
|
||||
if (Number.isFinite(lastSeen) && Date.now() - lastSeen > SESSION_IDLE_MS) {
|
||||
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(session.id);
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(lastSeen) || Date.now() - lastSeen >= SESSION_TOUCH_INTERVAL_MS) {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare('UPDATE sessions SET last_seen_at = ? WHERE id = ?').run(now, session.id);
|
||||
session.last_seen_at = now;
|
||||
touchUserActivity(session.user_id);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
const hash = hashToken(token);
|
||||
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE token_hash = ?").run(hash);
|
||||
}
|
||||
|
||||
export function destroySessionById(id) {
|
||||
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function getSessionById(id) {
|
||||
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = ?").get(id);
|
||||
}
|
||||
|
||||
export function destroyOwnedSessionById(userId, id) {
|
||||
const result = db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL").run(id, userId);
|
||||
return Number(result.changes || 0);
|
||||
}
|
||||
|
||||
export function destroyUserSessions(userId) {
|
||||
db.prepare("UPDATE sessions SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL").run(userId);
|
||||
}
|
||||
|
||||
export function getUserSessions(userId) {
|
||||
return db.prepare("SELECT id, ip, user_agent, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > datetime('now') ORDER BY created_at DESC").all(userId);
|
||||
}
|
||||
|
||||
export function getAllActiveSessions() {
|
||||
return db.prepare("SELECT s.id, s.user_id, s.ip, s.user_agent, s.created_at, s.last_seen_at, s.expires_at, u.email, u.name, u.role FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.revoked_at IS NULL AND s.expires_at > datetime('now') ORDER BY s.created_at DESC").all();
|
||||
}
|
||||
|
||||
// clean expired sessions periodically
|
||||
export function cleanExpiredSessions() {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now') OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', '-7 days'))").run();
|
||||
}
|
||||
|
||||
// --- Auth Tokens (Magic Links) ---
|
||||
export function createAuthToken(email, type = 'login', role = 'editor', createdBy = null) {
|
||||
const token = generateToken();
|
||||
const hash = hashToken(token);
|
||||
const minutes = type === 'invite' ? INVITE_TOKEN_EXPIRY_HOURS * 60 : LOGIN_TOKEN_EXPIRY_MIN;
|
||||
const expiresAt = new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||
|
||||
// invalidate previous unused tokens of same type for same email
|
||||
db.prepare("UPDATE auth_tokens SET used = 1 WHERE email = ? COLLATE NOCASE AND type = ? AND used = 0").run(email, type);
|
||||
|
||||
db.prepare('INSERT INTO auth_tokens (email, token_hash, type, role, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(email, hash, type, role, expiresAt, createdBy);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function validateAuthToken(token) {
|
||||
const hash = hashToken(token);
|
||||
const row = db.prepare("SELECT * FROM auth_tokens WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')").get(hash);
|
||||
if (!row) return null;
|
||||
db.prepare('UPDATE auth_tokens SET used = 1 WHERE id = ?').run(row.id);
|
||||
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();
|
||||
|
||||
export function checkRateLimit(key, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
|
||||
const now = Date.now();
|
||||
const entry = rateLimits.get(key);
|
||||
if (!entry) {
|
||||
rateLimits.set(key, { count: 1, firstAttempt: now });
|
||||
return { allowed: true, remaining: maxAttempts - 1 };
|
||||
}
|
||||
if (now - entry.firstAttempt > windowMs) {
|
||||
rateLimits.set(key, { count: 1, firstAttempt: now });
|
||||
return { allowed: true, remaining: maxAttempts - 1 };
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > maxAttempts) {
|
||||
const retryAfter = Math.ceil((entry.firstAttempt + windowMs - now) / 1000);
|
||||
return { allowed: false, remaining: 0, retryAfter };
|
||||
}
|
||||
return { allowed: true, remaining: maxAttempts - entry.count };
|
||||
}
|
||||
|
||||
// --- CSRF ---
|
||||
export function generateCsrfToken() {
|
||||
return randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
// --- Audit Log ---
|
||||
export function auditLog(userId, userEmail, action, targetType = null, targetId = null, details = null, ip = null) {
|
||||
db.prepare('INSERT INTO audit_log (user_id, user_email, action, target_type, target_id, details, ip) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, userEmail, action, targetType, targetId, details, ip);
|
||||
}
|
||||
|
||||
export function getAuditLog(limit = 100, offset = 0) {
|
||||
return db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
|
||||
}
|
||||
|
||||
export function searchAuditLog({ limit = 100, offset = 0, userId = null, action = '', from = '', to = '', query = '' } = {}) {
|
||||
const clauses = [];
|
||||
const values = [];
|
||||
if (userId) { clauses.push('user_id = ?'); values.push(Number(userId)); }
|
||||
if (action) { clauses.push('action LIKE ?'); values.push(`%${String(action).slice(0, 80)}%`); }
|
||||
if (from) { clauses.push('created_at >= ?'); values.push(String(from).slice(0, 30)); }
|
||||
if (to) { clauses.push('created_at <= ?'); values.push(String(to).slice(0, 30)); }
|
||||
if (query) {
|
||||
const q = `%${String(query).slice(0, 120)}%`;
|
||||
clauses.push('(user_email LIKE ? OR action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR details LIKE ? OR ip LIKE ?)');
|
||||
values.push(q, q, q, q, q, q);
|
||||
}
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 100));
|
||||
const safeOffset = Math.max(0, Number(offset) || 0);
|
||||
const rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...values, safeLimit, safeOffset);
|
||||
const total = db.prepare(`SELECT COUNT(*) AS count FROM audit_log ${where}`).get(...values)?.count || 0;
|
||||
return { rows, total, limit: safeLimit, offset: safeOffset };
|
||||
}
|
||||
|
||||
export function getAuditLogForUser(userId, limit = 50) {
|
||||
return db.prepare('SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
||||
}
|
||||
|
||||
// --- Login History ---
|
||||
export function recordLogin(userId, ip, userAgent, success = true) {
|
||||
db.prepare('INSERT INTO login_history (user_id, ip, user_agent, success) VALUES (?, ?, ?, ?)').run(userId, ip, userAgent || '', success ? 1 : 0);
|
||||
}
|
||||
|
||||
export function getLoginHistory(userId, limit = 20) {
|
||||
return db.prepare('SELECT * FROM login_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
||||
}
|
||||
|
||||
export function getRecentFailedLogins(email, windowMinutes = 15) {
|
||||
const user = getUserByEmail(email);
|
||||
if (!user) return 0;
|
||||
const r = db.prepare("SELECT COUNT(*) as count FROM login_history WHERE user_id = ? AND success = 0 AND created_at > datetime('now', ?)").get(user.id, `-${windowMinutes} minutes`);
|
||||
return r.count;
|
||||
}
|
||||
|
||||
// --- Setup: ensure at least one admin exists ---
|
||||
export function ensureAdminExists() {
|
||||
const count = getUserCount();
|
||||
return count === 0;
|
||||
}
|
||||
|
||||
export function isSetupMode() {
|
||||
return getUserCount() === 0;
|
||||
}
|
||||
|
||||
// Clean expired sessions every 10 minutes
|
||||
setInterval(cleanExpiredSessions, 10 * 60 * 1000);
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createHash, createPublicKey, verify as verifySignature } from 'crypto';
|
||||
import { posix as pathPosix } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { validateModuleManifest } from '../../kernel/module-contract.mjs';
|
||||
|
||||
export const MODULE_PACKAGE_FORMAT = 'vendoo.module.package';
|
||||
export const MODULE_PACKAGE_SCHEMA = 1;
|
||||
export const MAX_PACKAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_FILE_COUNT = 200;
|
||||
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).sort().reduce((result, key) => {
|
||||
result[key] = stable(value[key]);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function canonicalJson(value) {
|
||||
return JSON.stringify(stable(value));
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function packageIntegrityPayload(modulePackage) {
|
||||
return {
|
||||
format: MODULE_PACKAGE_FORMAT,
|
||||
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
||||
manifest: modulePackage.manifest,
|
||||
runtime: modulePackage.runtime,
|
||||
files: modulePackage.files.map(file => ({ path: file.path, sha256: file.sha256, size: file.size })),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePackageDigest(modulePackage) {
|
||||
return sha256(canonicalJson(packageIntegrityPayload(modulePackage)));
|
||||
}
|
||||
|
||||
function safeRelativePath(value) {
|
||||
const raw = String(value || '').replace(/\\/g, '/').trim();
|
||||
const normalized = pathPosix.normalize(raw);
|
||||
if (!raw || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || normalized.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.includes('\0')) {
|
||||
throw new PlatformError(`Unsicherer Moduldateipfad: ${raw || '(leer)'}`, {
|
||||
code: 'MODULE_PACKAGE_PATH_INVALID', status: 400, expose: true,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateRuntime(runtime = {}) {
|
||||
const mode = String(runtime.mode || 'declarative').trim();
|
||||
if (!['builtin-reference', 'declarative', 'isolated'].includes(mode)) {
|
||||
throw new PlatformError(`Nicht unterstützter Modul-Runtime-Modus: ${mode}`, { code: 'MODULE_RUNTIME_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const entry = runtime.entry === undefined || runtime.entry === null || runtime.entry === '' ? null : safeRelativePath(runtime.entry);
|
||||
if (mode === 'declarative' && entry) {
|
||||
throw new PlatformError('Deklarative Module dürfen keinen ausführbaren Einstiegspunkt besitzen.', { code: 'MODULE_RUNTIME_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
||||
}
|
||||
if (mode === 'isolated' && !entry) {
|
||||
throw new PlatformError('Isolierte Module benötigen einen Einstiegspunkt.', { code: 'MODULE_RUNTIME_ENTRY_REQUIRED', status: 400, expose: true });
|
||||
}
|
||||
return Object.freeze({ mode, entry });
|
||||
}
|
||||
|
||||
function validateFiles(files, runtime) {
|
||||
if (!Array.isArray(files)) throw new PlatformError('Moduldateien fehlen.', { code: 'MODULE_PACKAGE_FILES_INVALID', status: 400, expose: true });
|
||||
if (files.length > MAX_FILE_COUNT) throw new PlatformError('Das Modulpaket enthält zu viele Dateien.', { code: 'MODULE_PACKAGE_TOO_MANY_FILES', status: 400, expose: true });
|
||||
const seen = new Set();
|
||||
let total = 0;
|
||||
const normalized = files.map(item => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new PlatformError('Ungültiger Moduldateieintrag.', { code: 'MODULE_PACKAGE_FILE_INVALID', status: 400, expose: true });
|
||||
const path = safeRelativePath(item.path);
|
||||
if (seen.has(path)) throw new PlatformError(`Doppelte Moduldatei: ${path}`, { code: 'MODULE_PACKAGE_FILE_DUPLICATE', status: 400, expose: true });
|
||||
seen.add(path);
|
||||
const content = String(item.content || '');
|
||||
let buffer;
|
||||
try { buffer = Buffer.from(content, 'base64'); } catch { buffer = null; }
|
||||
if (!buffer || buffer.length > MAX_FILE_BYTES) throw new PlatformError(`Moduldatei ist ungültig oder zu groß: ${path}`, { code: 'MODULE_PACKAGE_FILE_TOO_LARGE', status: 400, expose: true });
|
||||
const digest = sha256(buffer);
|
||||
if (!/^[a-f0-9]{64}$/.test(String(item.sha256 || '')) || digest !== String(item.sha256)) {
|
||||
throw new PlatformError(`Prüfsumme stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_HASH_MISMATCH', status: 400, expose: true });
|
||||
}
|
||||
if (Number(item.size) !== buffer.length) throw new PlatformError(`Dateigröße stimmt nicht: ${path}`, { code: 'MODULE_PACKAGE_FILE_SIZE_MISMATCH', status: 400, expose: true });
|
||||
total += buffer.length;
|
||||
if (runtime.mode === 'declarative' && /\.(?:mjs|cjs|js|node|exe|dll|so|dylib|ps1|bat|cmd|sh)$/i.test(path)) {
|
||||
throw new PlatformError(`Deklaratives Modul enthält ausführbaren Code: ${path}`, { code: 'MODULE_PACKAGE_EXECUTABLE_FORBIDDEN', status: 400, expose: true });
|
||||
}
|
||||
return Object.freeze({ path, sha256: digest, size: buffer.length, content });
|
||||
});
|
||||
if (total > MAX_PACKAGE_BYTES) throw new PlatformError('Das Modulpaket ist zu groß.', { code: 'MODULE_PACKAGE_TOO_LARGE', status: 400, expose: true });
|
||||
if (runtime.entry && !seen.has(runtime.entry)) throw new PlatformError('Der Runtime-Einstiegspunkt fehlt im Paket.', { code: 'MODULE_RUNTIME_ENTRY_MISSING', status: 400, expose: true });
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function verifyOptionalSignature(modulePackage, trustKeys = {}) {
|
||||
const signature = modulePackage.signature;
|
||||
if (!signature) return { signed: false, trusted: false, keyId: null };
|
||||
const algorithm = String(signature.algorithm || '').toLowerCase();
|
||||
const keyId = String(signature.keyId || '').trim();
|
||||
const value = String(signature.value || '').trim();
|
||||
if (algorithm !== 'ed25519' || !keyId || !value) {
|
||||
throw new PlatformError('Ungültige Modulsignatur.', { code: 'MODULE_SIGNATURE_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const publicKey = trustKeys[keyId];
|
||||
if (!publicKey) return { signed: true, trusted: false, keyId };
|
||||
try {
|
||||
const verified = verifySignature(null, Buffer.from(modulePackage.integrity.digest, 'hex'), createPublicKey(publicKey), Buffer.from(value, 'base64'));
|
||||
if (!verified) throw new Error('Signaturprüfung fehlgeschlagen');
|
||||
return { signed: true, trusted: true, keyId };
|
||||
} catch (error) {
|
||||
throw new PlatformError('Modulsignatur konnte nicht bestätigt werden.', { code: 'MODULE_SIGNATURE_REJECTED', status: 400, expose: true, cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateModulePackage(input, { trustKeys = {}, allowBuiltinReference = false } = {}) {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : null;
|
||||
if (!source) throw new PlatformError('Ungültiges Modulpaket.', { code: 'MODULE_PACKAGE_INVALID', status: 400, expose: true });
|
||||
if (source.format !== MODULE_PACKAGE_FORMAT || Number(source.schemaVersion) !== MODULE_PACKAGE_SCHEMA) {
|
||||
throw new PlatformError('Nicht unterstütztes Modulpaketformat.', { code: 'MODULE_PACKAGE_VERSION_UNSUPPORTED', status: 400, expose: true });
|
||||
}
|
||||
const manifest = validateModuleManifest(source.manifest);
|
||||
const runtime = validateRuntime(source.runtime);
|
||||
if (runtime.mode === 'builtin-reference' && !allowBuiltinReference) {
|
||||
throw new PlatformError('Referenzexporte eingebauter Module können nicht installiert werden.', { code: 'MODULE_BUILTIN_REFERENCE_NOT_INSTALLABLE', status: 400, expose: true });
|
||||
}
|
||||
if (manifest.type !== 'extension-host' && runtime.mode !== 'builtin-reference') {
|
||||
throw new PlatformError('Installierbare Fremdmodule müssen den Typ extension-host verwenden.', { code: 'MODULE_EXTERNAL_TYPE_REQUIRED', status: 400, expose: true });
|
||||
}
|
||||
const files = validateFiles(source.files || [], runtime);
|
||||
const normalized = {
|
||||
format: MODULE_PACKAGE_FORMAT,
|
||||
schemaVersion: MODULE_PACKAGE_SCHEMA,
|
||||
manifest,
|
||||
runtime,
|
||||
files,
|
||||
createdAt: String(source.createdAt || new Date().toISOString()),
|
||||
integrity: {
|
||||
algorithm: String(source.integrity?.algorithm || '').toLowerCase(),
|
||||
digest: String(source.integrity?.digest || '').toLowerCase(),
|
||||
},
|
||||
signature: source.signature || null,
|
||||
};
|
||||
if (normalized.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(normalized.integrity.digest)) {
|
||||
throw new PlatformError('Paketintegrität fehlt oder ist ungültig.', { code: 'MODULE_PACKAGE_INTEGRITY_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const expected = calculatePackageDigest(normalized);
|
||||
if (expected !== normalized.integrity.digest) {
|
||||
throw new PlatformError('Paketintegrität stimmt nicht.', { code: 'MODULE_PACKAGE_INTEGRITY_MISMATCH', status: 400, expose: true });
|
||||
}
|
||||
const trust = verifyOptionalSignature(normalized, trustKeys);
|
||||
return Object.freeze({ ...normalized, trust: Object.freeze(trust) });
|
||||
}
|
||||
|
||||
export function buildModulePackage({ manifest, runtime = { mode: 'builtin-reference', entry: null }, files = [], createdAt = new Date().toISOString() }) {
|
||||
const normalizedFiles = files.map(file => {
|
||||
const path = safeRelativePath(file.path);
|
||||
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') };
|
||||
});
|
||||
// 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 });
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs';
|
||||
import { dirname, join, resolve, sep } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { validateModulePackage } from './module-package-contract.mjs';
|
||||
|
||||
function ensureInside(root, pathname) {
|
||||
const base = resolve(root);
|
||||
const target = resolve(pathname);
|
||||
if (target !== base && !target.startsWith(`${base}${sep}`)) {
|
||||
throw new PlatformError('Moduldatei liegt außerhalb des Modulspeichers.', { code: 'MODULE_STORAGE_ESCAPE', status: 400, expose: true });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export class ModulePackageStore {
|
||||
#root;
|
||||
#trustKeys;
|
||||
|
||||
constructor(root, { trustKeys = {} } = {}) {
|
||||
this.#root = resolve(root);
|
||||
this.#trustKeys = { ...trustKeys };
|
||||
mkdirSync(this.#root, { recursive: true });
|
||||
}
|
||||
|
||||
#moduleRoot(id) {
|
||||
return ensureInside(this.#root, join(this.#root, id));
|
||||
}
|
||||
|
||||
#packagePath(id) {
|
||||
return join(this.#moduleRoot(id), 'package.vmod');
|
||||
}
|
||||
|
||||
list() {
|
||||
if (!existsSync(this.#root)) return [];
|
||||
const result = [];
|
||||
for (const entry of readdirSync(this.#root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
try {
|
||||
const packagePath = this.#packagePath(entry.name);
|
||||
if (!existsSync(packagePath)) continue;
|
||||
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
|
||||
const pkg = validateModulePackage(parsed, { trustKeys: this.#trustKeys });
|
||||
result.push({
|
||||
id: pkg.manifest.id,
|
||||
manifest: pkg.manifest,
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
installedAt: String(parsed.installedAt || parsed.createdAt || ''),
|
||||
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
|
||||
packagePath,
|
||||
});
|
||||
} catch (error) {
|
||||
result.push({ id: entry.name, invalid: true, error: error?.message || String(error), activatable: false });
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const packagePath = this.#packagePath(id);
|
||||
if (!existsSync(packagePath)) return null;
|
||||
const parsed = JSON.parse(readFileSync(packagePath, 'utf8'));
|
||||
return validateModulePackage(parsed, { trustKeys: this.#trustKeys });
|
||||
}
|
||||
|
||||
install(input) {
|
||||
const pkg = validateModulePackage(input, { trustKeys: this.#trustKeys });
|
||||
const root = this.#moduleRoot(pkg.manifest.id);
|
||||
if (existsSync(root)) throw new PlatformError('Dieses Fremdmodul ist bereits installiert.', { code: 'MODULE_ALREADY_INSTALLED', status: 409, expose: true });
|
||||
const temporary = `${root}.${process.pid}.tmp`;
|
||||
mkdirSync(temporary, { recursive: true });
|
||||
try {
|
||||
for (const file of pkg.files) {
|
||||
const destination = ensureInside(temporary, join(temporary, file.path));
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
writeFileSync(destination, Buffer.from(file.content, 'base64'), { mode: 0o600 });
|
||||
}
|
||||
const persisted = { ...pkg, trust: undefined, installedAt: new Date().toISOString() };
|
||||
writeFileSync(join(temporary, 'package.vmod'), `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(temporary, root);
|
||||
} catch (error) {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return this.describe(pkg.manifest.id);
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
const root = this.#moduleRoot(id);
|
||||
if (!existsSync(root)) return false;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export(id) {
|
||||
const packagePath = this.#packagePath(id);
|
||||
if (!existsSync(packagePath)) throw new PlatformError('Installiertes Modulpaket nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
return readFileSync(packagePath);
|
||||
}
|
||||
|
||||
describe(id) {
|
||||
const pkg = this.get(id);
|
||||
if (!pkg) return null;
|
||||
return {
|
||||
id: pkg.manifest.id,
|
||||
manifest: pkg.manifest,
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
activatable: pkg.runtime.mode === 'declarative' || (pkg.runtime.mode === 'isolated' && pkg.trust.trusted),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
function normalizeState(input = {}) {
|
||||
const modules = {};
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input.modules : null;
|
||||
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
||||
for (const [id, record] of Object.entries(source)) {
|
||||
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(id)) continue;
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
||||
modules[id] = {
|
||||
enabled: record.enabled !== false,
|
||||
source: String(record.source || 'admin'),
|
||||
updatedAt: String(record.updatedAt || new Date(0).toISOString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { schemaVersion: SCHEMA_VERSION, modules };
|
||||
}
|
||||
|
||||
export class ModuleStateStore {
|
||||
#path;
|
||||
#state;
|
||||
|
||||
constructor(pathname) {
|
||||
this.#path = pathname;
|
||||
this.#state = this.#read();
|
||||
}
|
||||
|
||||
#read() {
|
||||
try {
|
||||
if (!existsSync(this.#path)) return normalizeState();
|
||||
const parsed = JSON.parse(readFileSync(this.#path, 'utf8'));
|
||||
if (Number(parsed?.schemaVersion || 0) !== SCHEMA_VERSION) {
|
||||
throw new Error(`Nicht unterstützte Modulstatus-Version: ${parsed?.schemaVersion}`);
|
||||
}
|
||||
return normalizeState(parsed);
|
||||
} catch (error) {
|
||||
throw new PlatformError('Der persistente Modulstatus konnte nicht gelesen werden.', {
|
||||
code: 'MODULE_STATE_READ_FAILED',
|
||||
cause: error,
|
||||
details: { path: this.#path },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#persist() {
|
||||
mkdirSync(dirname(this.#path), { recursive: true });
|
||||
const temporary = `${this.#path}.${process.pid}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, { mode: 0o600 });
|
||||
renameSync(temporary, this.#path);
|
||||
}
|
||||
|
||||
has(id) { return Boolean(this.#state.modules[id]); }
|
||||
|
||||
isEnabled(id, fallback = true) {
|
||||
const record = this.#state.modules[id];
|
||||
return record ? record.enabled !== false : Boolean(fallback);
|
||||
}
|
||||
|
||||
setEnabled(id, enabled, { source = 'admin' } = {}) {
|
||||
if (!/^vendoo\.[a-z][a-z0-9-]*$/.test(String(id || ''))) {
|
||||
throw new PlatformError('Ungültige Modul-ID für den Modulstatus.', { code: 'MODULE_ID_INVALID', status: 400, expose: true });
|
||||
}
|
||||
this.#state.modules[id] = {
|
||||
enabled: Boolean(enabled),
|
||||
source: String(source || 'admin').slice(0, 80),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.#persist();
|
||||
return { id, ...this.#state.modules[id] };
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
if (!this.#state.modules[id]) return false;
|
||||
delete this.#state.modules[id];
|
||||
this.#persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return JSON.parse(JSON.stringify(this.#state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const SECRET_DEFINITIONS = Object.freeze({
|
||||
ANTHROPIC_API_KEY: { label: 'Anthropic API-Key', category: 'AI', editable: true },
|
||||
OPENAI_API_KEY: { label: 'OpenAI API-Key', category: 'AI', editable: true },
|
||||
OPENROUTER_API_KEY: { label: 'OpenRouter API-Key', category: 'AI', editable: true },
|
||||
ETSY_API_KEY: { label: 'Etsy API-Key', category: 'Marktplatz', editable: true },
|
||||
ETSY_ACCESS_TOKEN: { label: 'Etsy Access Token', category: 'Marktplatz', editable: false },
|
||||
ETSY_REFRESH_TOKEN: { label: 'Etsy Refresh Token', category: 'Marktplatz', editable: false },
|
||||
EBAY_CLIENT_SECRET: { label: 'eBay Client Secret', category: 'Marktplatz', editable: true },
|
||||
EBAY_ACCESS_TOKEN: { label: 'eBay Access Token', category: 'Marktplatz', editable: false },
|
||||
EBAY_REFRESH_TOKEN: { label: 'eBay Refresh Token', category: 'Marktplatz', editable: false },
|
||||
LOCAL_IMAGE_TOKEN: { label: 'FLUX Dienst-Token', category: 'FLUX', editable: true },
|
||||
SMTP_PASS: { label: 'SMTP-Passwort', category: 'E-Mail', editable: true },
|
||||
VENDOO_SETUP_TOKEN: { label: 'Ersteinrichtungs-Token', category: 'System', editable: false },
|
||||
COOLIFY_API_TOKEN: { label: 'Coolify API-Token', category: 'Deployment', editable: true },
|
||||
COOLIFY_DEPLOY_WEBHOOK_URL: { label: 'Coolify Deploy-Webhook', category: 'Deployment', editable: true },
|
||||
});
|
||||
|
||||
export const SECRET_NAMES = Object.freeze(Object.keys(SECRET_DEFINITIONS));
|
||||
const SECRET_NAME = /^[A-Z][A-Z0-9_]{2,80}$/;
|
||||
|
||||
export function validateSecretName(name) {
|
||||
const normalized = String(name || '').trim();
|
||||
if (!SECRET_NAME.test(normalized) || !SECRET_DEFINITIONS[normalized]) {
|
||||
const error = new Error(`Unbekanntes Secret: ${normalized || '(leer)'}`);
|
||||
error.code = 'SECRET_NAME_INVALID';
|
||||
throw error;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateSecretValue(value) {
|
||||
const normalized = String(value ?? '');
|
||||
if (/\r|\n|\0/.test(normalized)) {
|
||||
const error = new Error('Secrets dürfen keine Zeilenumbrüche oder Nullbytes enthalten.');
|
||||
error.code = 'SECRET_VALUE_INVALID';
|
||||
throw error;
|
||||
}
|
||||
if (Buffer.byteLength(normalized, 'utf8') > 16_384) {
|
||||
const error = new Error('Secret ist zu groß.');
|
||||
error.code = 'SECRET_VALUE_TOO_LARGE';
|
||||
throw error;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function maskSecret(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return '';
|
||||
if (text.length <= 4) return '••••';
|
||||
return `••••••••${text.slice(-4)}`;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { APP_ROOT } from '../../../lib/runtime-paths.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const source = JSON.parse(readFileSync(join(APP_ROOT, 'public', 'design-system', 'tokens', 'source.json'), 'utf8'));
|
||||
const BUILTIN_THEME_IDS = new Set([...source.themes, 'system', 'inherit']);
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
const RGB_COLOR = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i;
|
||||
const DIMENSION = /^-?\d+(?:\.\d+)?px$/i;
|
||||
const THEME_ID = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
|
||||
function expandHex(value) {
|
||||
const hex = value.replace('#', '');
|
||||
if (hex.length === 3 || hex.length === 4) return hex.split('').map(char => char + char).join('');
|
||||
return hex;
|
||||
}
|
||||
|
||||
function parseColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (HEX_COLOR.test(raw)) {
|
||||
const hex = expandHex(raw);
|
||||
return [0, 2, 4].map(index => Number.parseInt(hex.slice(index, index + 2), 16));
|
||||
}
|
||||
const match = raw.match(RGB_COLOR);
|
||||
if (!match) return null;
|
||||
const channels = match.slice(1, 4).map(Number);
|
||||
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels : null;
|
||||
}
|
||||
|
||||
function validColor(value) {
|
||||
if (!parseColor(value)) return false;
|
||||
const match = String(value).trim().match(RGB_COLOR);
|
||||
if (!match) return true;
|
||||
const alpha = match[4] === undefined ? 1 : Number(match[4]);
|
||||
return Number.isFinite(alpha) && alpha >= 0 && alpha <= 1;
|
||||
}
|
||||
|
||||
function validateValue(token, value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (token.type === 'color') return validColor(raw);
|
||||
if (token.type === 'dimension') {
|
||||
if (!DIMENSION.test(raw)) return false;
|
||||
const numeric = Number.parseFloat(raw);
|
||||
if (token.min !== undefined && numeric < Number(token.min)) return false;
|
||||
if (token.max !== undefined && numeric > Number(token.max)) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function relativeLuminance(value) {
|
||||
const rgb = parseColor(value);
|
||||
if (!rgb) return null;
|
||||
const linear = rgb.map(channel => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
export function contrastRatio(foreground, background) {
|
||||
const first = relativeLuminance(foreground);
|
||||
const second = relativeLuminance(background);
|
||||
if (first === null || second === null) return null;
|
||||
const lighter = Math.max(first, second);
|
||||
const darker = Math.min(first, second);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
export function getBaseThemeValues(themeId = 'light') {
|
||||
const base = source.themes.includes(themeId) ? themeId : 'light';
|
||||
return Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, token[base]]));
|
||||
}
|
||||
|
||||
export function resolveThemeValues({ baseTheme = 'light', values = {} } = {}) {
|
||||
return Object.freeze({ ...getBaseThemeValues(baseTheme), ...values });
|
||||
}
|
||||
|
||||
export function evaluateThemeAccessibility({ baseTheme = 'light', values = {} } = {}) {
|
||||
const resolved = resolveThemeValues({ baseTheme, values });
|
||||
const checks = [
|
||||
['primary-on-canvas', 'color.ink', 'color.canvas', 4.5],
|
||||
['primary-on-surface', 'color.ink', 'color.surface', 4.5],
|
||||
['secondary-on-surface', 'color.inkSecondary', 'color.surface', 4.5],
|
||||
['muted-on-surface', 'color.inkMuted', 'color.surface', 3],
|
||||
['brand-on-surface', 'color.brand', 'color.surface', 3],
|
||||
['danger-on-surface', 'color.danger', 'color.surface', 3],
|
||||
['line-on-surface', 'color.lineStrong', 'color.surface', 3],
|
||||
].map(([id, foregroundToken, backgroundToken, minimum]) => {
|
||||
const ratio = contrastRatio(resolved[foregroundToken], resolved[backgroundToken]);
|
||||
return {
|
||||
id,
|
||||
foregroundToken,
|
||||
backgroundToken,
|
||||
ratio: ratio === null ? null : Number(ratio.toFixed(2)),
|
||||
minimum,
|
||||
ok: ratio !== null && ratio >= minimum,
|
||||
};
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: checks.every(check => check.ok),
|
||||
checks: Object.freeze(checks),
|
||||
failures: Object.freeze(checks.filter(check => !check.ok)),
|
||||
});
|
||||
}
|
||||
|
||||
export function getThemeContract() {
|
||||
return {
|
||||
schemaVersion: source.schemaVersion,
|
||||
contractVersion: source.contractVersion,
|
||||
themes: [...source.themes],
|
||||
tokens: Object.fromEntries(Object.entries(source.tokens).map(([name, token]) => [name, {
|
||||
css: token.css,
|
||||
type: token.type,
|
||||
customizable: Boolean(token.customizable),
|
||||
label: token.label || name,
|
||||
group: token.group || name.split('.')[0],
|
||||
...(token.min !== undefined ? { min: token.min } : {}),
|
||||
...(token.max !== undefined ? { max: token.max } : {}),
|
||||
}])),
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuiltinThemes() {
|
||||
return source.themes.map(id => ({
|
||||
id,
|
||||
name: ({ light: 'Hell', dark: 'Dunkel', contrast: 'Hoher Kontrast' })[id] || id,
|
||||
base_theme: id,
|
||||
builtin: true,
|
||||
values: {},
|
||||
resolved_values: getBaseThemeValues(id),
|
||||
accessibility: evaluateThemeAccessibility({ baseTheme: id }),
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateThemeDefinition(theme, { allowExistingId = false } = {}) {
|
||||
if (!theme || typeof theme !== 'object' || Array.isArray(theme)) {
|
||||
throw new PlatformError('Theme-Definition ist ungültig.', { code: 'THEME_INVALID', status: 400, expose: true });
|
||||
}
|
||||
const id = String(theme.id || '').trim().toLowerCase();
|
||||
const name = String(theme.name || '').trim();
|
||||
const baseTheme = String(theme.base_theme || theme.baseTheme || 'light').trim().toLowerCase();
|
||||
if (!THEME_ID.test(id) || BUILTIN_THEME_IDS.has(id)) throw new PlatformError('Theme-ID ist ungültig oder reserviert.', { code: 'THEME_ID_INVALID', status: 400, expose: true });
|
||||
if (!allowExistingId && !id.startsWith('custom-')) throw new PlatformError('Eigene Theme-IDs müssen mit custom- beginnen.', { code: 'THEME_ID_PREFIX', status: 400, expose: true });
|
||||
if (!name || name.length > 80) throw new PlatformError('Theme-Name ist ungültig.', { code: 'THEME_NAME_INVALID', status: 400, expose: true });
|
||||
if (!source.themes.includes(baseTheme)) throw new PlatformError('Basis-Theme ist ungültig.', { code: 'THEME_BASE_INVALID', status: 400, expose: true });
|
||||
const values = {};
|
||||
for (const [tokenName, value] of Object.entries(theme.values || {})) {
|
||||
const token = source.tokens[tokenName];
|
||||
if (!token || !token.customizable) throw new PlatformError(`Token ist nicht anpassbar: ${tokenName}`, { code: 'THEME_TOKEN_FORBIDDEN', status: 400, expose: true });
|
||||
if (!validateValue(token, value)) throw new PlatformError(`Ungültiger Wert für ${tokenName}.`, { code: 'THEME_VALUE_INVALID', status: 400, expose: true });
|
||||
values[tokenName] = String(value).trim();
|
||||
}
|
||||
const accessibility = evaluateThemeAccessibility({ baseTheme, values });
|
||||
return Object.freeze({ schemaVersion: 1, id, name, base_theme: baseTheme, values: Object.freeze(values), accessibility });
|
||||
}
|
||||
|
||||
export function validateThemePreference(input, { customThemeExists = () => false } = {}) {
|
||||
const themeId = String(input?.theme_id || input?.themeId || 'inherit').trim().toLowerCase();
|
||||
const density = String(input?.density || 'comfortable').trim().toLowerCase();
|
||||
const reducedMotion = String(input?.reduced_motion || input?.reducedMotion || 'system').trim().toLowerCase();
|
||||
if (![...BUILTIN_THEME_IDS].includes(themeId) && !customThemeExists(themeId)) {
|
||||
throw new PlatformError('Gewähltes Theme existiert nicht.', { code: 'THEME_NOT_FOUND', status: 404, expose: true });
|
||||
}
|
||||
if (!['compact', 'comfortable', 'spacious'].includes(density)) throw new PlatformError('UI-Dichte ist ungültig.', { code: 'THEME_DENSITY_INVALID', status: 400, expose: true });
|
||||
if (!['system', 'reduce', 'allow'].includes(reducedMotion)) throw new PlatformError('Bewegungseinstellung ist ungültig.', { code: 'THEME_MOTION_INVALID', status: 400, expose: true });
|
||||
return Object.freeze({ theme_id: themeId, density, reduced_motion: reducedMotion });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export class PlatformError extends Error {
|
||||
constructor(message, { code = 'PLATFORM_ERROR', status = 500, details = null, expose = false, cause = undefined } = {}) {
|
||||
super(String(message || 'Unbekannter Plattformfehler'), { cause });
|
||||
this.name = 'PlatformError';
|
||||
this.code = String(code || 'PLATFORM_ERROR');
|
||||
this.status = Number.isInteger(status) ? status : 500;
|
||||
this.details = details;
|
||||
this.expose = Boolean(expose || this.status < 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePlatformError(error) {
|
||||
if (error instanceof PlatformError) return error;
|
||||
return new PlatformError(error?.message || 'Interner Serverfehler', {
|
||||
code: error?.code || 'INTERNAL_ERROR',
|
||||
status: Number.isInteger(error?.status) ? error.status : 500,
|
||||
details: error?.details || null,
|
||||
expose: Boolean(error?.expose),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
export function platformErrorPayload(error, requestId = null) {
|
||||
const normalized = normalizePlatformError(error);
|
||||
return {
|
||||
error: normalized.expose ? normalized.message : 'Interner Serverfehler',
|
||||
code: normalized.code,
|
||||
request_id: requestId || null,
|
||||
...(normalized.expose && normalized.details ? { details: normalized.details } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
const EVENT_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
export class EventBus {
|
||||
#listeners = new Map();
|
||||
#maxListeners;
|
||||
|
||||
constructor({ maxListenersPerEvent = 50 } = {}) {
|
||||
this.#maxListeners = Math.max(1, Number(maxListenersPerEvent) || 50);
|
||||
}
|
||||
|
||||
on(eventName, handler, { owner = 'core', once = false } = {}) {
|
||||
if (!EVENT_PATTERN.test(String(eventName))) {
|
||||
throw new PlatformError(`Ungültiger Eventname: ${eventName}`, { code: 'EVENT_NAME_INVALID', status: 500 });
|
||||
}
|
||||
if (typeof handler !== 'function') {
|
||||
throw new PlatformError('Event-Handler muss eine Funktion sein.', { code: 'EVENT_HANDLER_INVALID', status: 500 });
|
||||
}
|
||||
const listeners = this.#listeners.get(eventName) || [];
|
||||
if (listeners.length >= this.#maxListeners) {
|
||||
throw new PlatformError(`Zu viele Listener für ${eventName}.`, { code: 'EVENT_LISTENER_LIMIT', status: 500 });
|
||||
}
|
||||
const entry = Object.freeze({ handler, owner: String(owner || 'core'), once: Boolean(once) });
|
||||
listeners.push(entry);
|
||||
this.#listeners.set(eventName, listeners);
|
||||
return () => this.off(eventName, handler);
|
||||
}
|
||||
|
||||
once(eventName, handler, options = {}) {
|
||||
return this.on(eventName, handler, { ...options, once: true });
|
||||
}
|
||||
|
||||
off(eventName, handler) {
|
||||
const listeners = this.#listeners.get(eventName) || [];
|
||||
const remaining = listeners.filter(entry => entry.handler !== handler);
|
||||
if (remaining.length) this.#listeners.set(eventName, remaining);
|
||||
else this.#listeners.delete(eventName);
|
||||
return remaining.length !== listeners.length;
|
||||
}
|
||||
|
||||
removeOwner(owner) {
|
||||
let removed = 0;
|
||||
for (const [eventName, listeners] of this.#listeners) {
|
||||
const remaining = listeners.filter(entry => {
|
||||
const match = entry.owner === owner;
|
||||
if (match) removed += 1;
|
||||
return !match;
|
||||
});
|
||||
if (remaining.length) this.#listeners.set(eventName, remaining);
|
||||
else this.#listeners.delete(eventName);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async emit(eventName, payload = null, context = {}) {
|
||||
const listeners = [...(this.#listeners.get(eventName) || [])];
|
||||
const results = [];
|
||||
for (const entry of listeners) {
|
||||
try {
|
||||
results.push({ owner: entry.owner, ok: true, value: await entry.handler(payload, Object.freeze({ ...context, eventName })) });
|
||||
} catch (error) {
|
||||
results.push({ owner: entry.owner, ok: false, error });
|
||||
} finally {
|
||||
if (entry.once) this.off(eventName, entry.handler);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return [...this.#listeners.entries()].map(([event, listeners]) => ({
|
||||
event,
|
||||
listeners: listeners.map(entry => ({ owner: entry.owner, once: entry.once })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
const FLAG_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
function envKey(flag) {
|
||||
return `VENDOO_FEATURE_${flag.replace(/[^a-z0-9]/gi, '_').toUpperCase()}`;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (/^(1|true|yes|on)$/i.test(String(value))) return true;
|
||||
if (/^(0|false|no|off)$/i.test(String(value))) return false;
|
||||
throw new PlatformError(`Ungültiger Feature-Flag-Wert: ${value}`, { code: 'FEATURE_FLAG_VALUE_INVALID', status: 500 });
|
||||
}
|
||||
|
||||
export class FeatureFlagRegistry {
|
||||
#flags = new Map();
|
||||
|
||||
define(name, { defaultValue = false, owner = 'vendoo.system', description = '', mutable = false } = {}) {
|
||||
const normalized = String(name || '');
|
||||
if (!FLAG_PATTERN.test(normalized)) {
|
||||
throw new PlatformError(`Ungültiger Feature-Flag: ${normalized}`, { code: 'FEATURE_FLAG_NAME_INVALID', status: 500 });
|
||||
}
|
||||
if (this.#flags.has(normalized)) {
|
||||
throw new PlatformError(`Feature-Flag bereits definiert: ${normalized}`, { code: 'FEATURE_FLAG_DUPLICATE', status: 500 });
|
||||
}
|
||||
const configured = parseBoolean(process.env[envKey(normalized)], Boolean(defaultValue));
|
||||
this.#flags.set(normalized, {
|
||||
name: normalized,
|
||||
value: configured,
|
||||
defaultValue: Boolean(defaultValue),
|
||||
owner: String(owner),
|
||||
description: String(description),
|
||||
mutable: Boolean(mutable),
|
||||
source: process.env[envKey(normalized)] === undefined ? 'default' : 'environment',
|
||||
});
|
||||
return configured;
|
||||
}
|
||||
|
||||
isEnabled(name) {
|
||||
if (!this.#flags.has(name)) {
|
||||
throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 500 });
|
||||
}
|
||||
return this.#flags.get(name).value;
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
const flag = this.#flags.get(name);
|
||||
if (!flag) throw new PlatformError(`Unbekannter Feature-Flag: ${name}`, { code: 'FEATURE_FLAG_UNKNOWN', status: 404, expose: true });
|
||||
if (!flag.mutable) throw new PlatformError(`Feature-Flag ist nicht zur Laufzeit änderbar: ${name}`, { code: 'FEATURE_FLAG_IMMUTABLE', status: 409, expose: true });
|
||||
flag.value = Boolean(value);
|
||||
flag.source = 'runtime';
|
||||
return flag.value;
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.#flags.values()].map(flag => ({ ...flag }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
function fail(message, field) {
|
||||
throw new PlatformError(message, { code: 'INPUT_VALIDATION_FAILED', status: 400, expose: true, details: { field } });
|
||||
}
|
||||
|
||||
export function objectSchema(fields, { allowUnknown = false } = {}) {
|
||||
const contract = Object.freeze({ ...fields });
|
||||
return input => {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||
const output = {};
|
||||
if (!allowUnknown) for (const key of Object.keys(source)) if (!contract[key]) fail(`Unbekanntes Eingabefeld: ${key}`, key);
|
||||
for (const [name, rule] of Object.entries(contract)) {
|
||||
let value = source[name];
|
||||
if (value === undefined || value === null) {
|
||||
if (rule.required) fail(`${name} ist erforderlich.`, name);
|
||||
continue;
|
||||
}
|
||||
if (rule.type === 'string') {
|
||||
value = String(value);
|
||||
if (rule.trim !== false) value = value.trim();
|
||||
if (rule.minLength && value.length < rule.minLength) fail(`${name} ist zu kurz.`, name);
|
||||
if (rule.maxLength && value.length > rule.maxLength) fail(`${name} ist zu lang.`, name);
|
||||
if (rule.pattern && !rule.pattern.test(value)) fail(`${name} besitzt ein ungültiges Format.`, name);
|
||||
} else if (rule.type === 'boolean') {
|
||||
if (typeof value !== 'boolean') fail(`${name} muss true oder false sein.`, name);
|
||||
} else if (rule.type === 'number' || rule.type === 'integer') {
|
||||
value = Number(value);
|
||||
if (!Number.isFinite(value)) fail(`${name} muss eine Zahl sein.`, name);
|
||||
if (rule.type === 'integer' && !Number.isInteger(value)) fail(`${name} muss eine ganze Zahl sein.`, name);
|
||||
if (rule.min !== undefined && value < rule.min) fail(`${name} ist zu klein.`, name);
|
||||
if (rule.max !== undefined && value > rule.max) fail(`${name} ist zu groß.`, name);
|
||||
}
|
||||
if (rule.enum && !rule.enum.includes(value)) fail(`${name} enthält einen nicht erlaubten Wert.`, name);
|
||||
if (typeof rule.validate === 'function') {
|
||||
const result = rule.validate(value);
|
||||
if (result !== true) fail(typeof result === 'string' ? result : `${name} ist ungültig.`, name);
|
||||
}
|
||||
output[name] = value;
|
||||
}
|
||||
return Object.freeze(output);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
export class LifecycleManager {
|
||||
#state = 'created';
|
||||
#hooks = [];
|
||||
|
||||
register({ id, order = 100, start = async () => {}, stop = async () => {} }) {
|
||||
if (!id || this.#hooks.some(hook => hook.id === id)) {
|
||||
throw new PlatformError(`Ungültiger oder doppelter Lifecycle-Hook: ${id}`, { code: 'LIFECYCLE_HOOK_INVALID' });
|
||||
}
|
||||
this.#hooks.push({ id: String(id), order: Number(order) || 100, start, stop, state: 'registered' });
|
||||
}
|
||||
|
||||
async start(context) {
|
||||
if (!['created', 'stopped'].includes(this.#state)) throw new PlatformError(`Kernel kann aus Zustand ${this.#state} nicht starten.`, { code: 'LIFECYCLE_STATE_INVALID' });
|
||||
this.#state = 'starting';
|
||||
const started = [];
|
||||
try {
|
||||
for (const hook of [...this.#hooks].sort((a, b) => a.order - b.order)) {
|
||||
hook.state = 'starting';
|
||||
await hook.start(context);
|
||||
hook.state = 'running';
|
||||
started.push(hook);
|
||||
}
|
||||
this.#state = 'running';
|
||||
} catch (error) {
|
||||
for (const hook of started.reverse()) {
|
||||
try { await hook.stop({ ...context, rollback: true }); } catch {}
|
||||
hook.state = 'stopped';
|
||||
}
|
||||
this.#state = 'failed';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(context) {
|
||||
if (!['running', 'starting'].includes(this.#state)) return;
|
||||
this.#state = 'stopping';
|
||||
for (const hook of [...this.#hooks].sort((a, b) => b.order - a.order)) {
|
||||
if (hook.state !== 'running') continue;
|
||||
try { await hook.stop(context); } finally { hook.state = 'stopped'; }
|
||||
}
|
||||
this.#state = 'stopped';
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return { state: this.#state, hooks: this.#hooks.map(({ start: _s, stop: _t, ...hook }) => ({ ...hook })) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const CAPABILITY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
const TYPES = new Set(['core', 'feature', 'adapter', 'extension-host']);
|
||||
const STATUSES = new Set(['native', 'legacy-bridge', 'disabled']);
|
||||
|
||||
function uniqueStrings(values, label, pattern = CAPABILITY) {
|
||||
if (!Array.isArray(values)) throw new PlatformError(`${label} muss ein Array sein.`, { code: 'MODULE_MANIFEST_INVALID' });
|
||||
const normalized = values.map(value => String(value || '').trim());
|
||||
if (normalized.some(value => !pattern.test(value))) {
|
||||
throw new PlatformError(`${label} enthält einen ungültigen Eintrag.`, { code: 'MODULE_MANIFEST_INVALID' });
|
||||
}
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new PlatformError(`${label} enthält Duplikate.`, { code: 'MODULE_MANIFEST_INVALID' });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateModuleManifest(manifest) {
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new PlatformError('Modulmanifest fehlt oder ist ungültig.', { code: 'MODULE_MANIFEST_INVALID' });
|
||||
}
|
||||
const normalized = {
|
||||
schemaVersion: Number(manifest.schemaVersion || 1),
|
||||
id: String(manifest.id || '').trim(),
|
||||
name: String(manifest.name || '').trim(),
|
||||
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')),
|
||||
provides: Object.freeze(uniqueStrings(manifest.provides || [], 'provides')),
|
||||
consumes: Object.freeze(uniqueStrings(manifest.consumes || [], 'consumes')),
|
||||
owns: Object.freeze(uniqueStrings(manifest.owns || [], 'owns')),
|
||||
};
|
||||
if (normalized.schemaVersion !== 1) throw new PlatformError('Nicht unterstützte Manifestversion.', { code: 'MODULE_MANIFEST_VERSION' });
|
||||
if (!MODULE_ID.test(normalized.id)) throw new PlatformError(`Ungültige Modul-ID: ${normalized.id}`, { code: 'MODULE_ID_INVALID' });
|
||||
if (!normalized.name) throw new PlatformError(`Modulname fehlt: ${normalized.id}`, { code: 'MODULE_NAME_REQUIRED' });
|
||||
if (!SEMVER.test(normalized.version)) throw new PlatformError(`Ungültige Modulversion: ${normalized.id}`, { code: 'MODULE_VERSION_INVALID' });
|
||||
if (!TYPES.has(normalized.type)) throw new PlatformError(`Ungültiger Modultyp: ${normalized.id}`, { code: 'MODULE_TYPE_INVALID' });
|
||||
if (!STATUSES.has(normalized.status)) throw new PlatformError(`Ungültiger Modulstatus: ${normalized.id}`, { code: 'MODULE_STATUS_INVALID' });
|
||||
if (normalized.requires.includes(normalized.id)) throw new PlatformError(`Modul darf sich nicht selbst benötigen: ${normalized.id}`, { code: 'MODULE_SELF_DEPENDENCY' });
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
import { validateModuleManifest } from './module-contract.mjs';
|
||||
|
||||
export class ModuleRegistry {
|
||||
#modules = new Map();
|
||||
#startOrder = [];
|
||||
#ownership = new Map();
|
||||
#started = false;
|
||||
|
||||
register({ manifest, lifecycle = {}, source = 'builtin' }, { enabled = true } = {}) {
|
||||
const validated = validateModuleManifest(manifest);
|
||||
if (this.#modules.has(validated.id)) {
|
||||
throw new PlatformError(`Modul bereits registriert: ${validated.id}`, { code: 'MODULE_DUPLICATE' });
|
||||
}
|
||||
for (const resource of validated.owns) {
|
||||
const owner = this.#ownership.get(resource);
|
||||
if (owner) throw new PlatformError(`Ressource ${resource} wird bereits von ${owner} besessen.`, { code: 'MODULE_OWNERSHIP_CONFLICT' });
|
||||
}
|
||||
const active = validated.status !== 'disabled' && enabled !== false;
|
||||
const record = {
|
||||
manifest: validated,
|
||||
lifecycle: {
|
||||
start: typeof lifecycle.start === 'function' ? lifecycle.start : async () => {},
|
||||
stop: typeof lifecycle.stop === 'function' ? lifecycle.stop : async () => {},
|
||||
health: typeof lifecycle.health === 'function' ? lifecycle.health : async () => ({ ok: true }),
|
||||
},
|
||||
source: String(source || 'builtin'),
|
||||
enabled: active,
|
||||
state: active ? 'registered' : 'disabled',
|
||||
startedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.#modules.set(validated.id, record);
|
||||
for (const resource of validated.owns) this.#ownership.set(resource, validated.id);
|
||||
return validated;
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.#modules.get(id) || null;
|
||||
}
|
||||
|
||||
has(id) {
|
||||
return this.#modules.has(id);
|
||||
}
|
||||
|
||||
isEnabled(id) {
|
||||
return this.#modules.get(id)?.enabled === true;
|
||||
}
|
||||
|
||||
dependentsOf(id, { enabledOnly = false, recursive = true } = {}) {
|
||||
const found = new Set();
|
||||
const visit = dependency => {
|
||||
for (const [candidateId, record] of this.#modules) {
|
||||
if (enabledOnly && !record.enabled) continue;
|
||||
if (!record.manifest.requires.includes(dependency) || found.has(candidateId)) continue;
|
||||
found.add(candidateId);
|
||||
if (recursive) visit(candidateId);
|
||||
}
|
||||
};
|
||||
visit(id);
|
||||
return [...found];
|
||||
}
|
||||
|
||||
#resolveStartOrder() {
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
const order = [];
|
||||
const visit = id => {
|
||||
if (visited.has(id)) return;
|
||||
if (visiting.has(id)) throw new PlatformError(`Zyklische Modulabhängigkeit bei ${id}.`, { code: 'MODULE_DEPENDENCY_CYCLE' });
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError(`Fehlende Modulabhängigkeit: ${id}`, { code: 'MODULE_DEPENDENCY_MISSING' });
|
||||
if (!record.enabled) return;
|
||||
visiting.add(id);
|
||||
for (const dependency of record.manifest.requires) {
|
||||
const dependencyRecord = this.#modules.get(dependency);
|
||||
if (!dependencyRecord) throw new PlatformError(`Fehlende Modulabhängigkeit: ${dependency}`, { code: 'MODULE_DEPENDENCY_MISSING' });
|
||||
if (!dependencyRecord.enabled) {
|
||||
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
|
||||
code: 'MODULE_DEPENDENCY_DISABLED', details: { module: id, dependency },
|
||||
});
|
||||
}
|
||||
visit(dependency);
|
||||
}
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
order.push(id);
|
||||
};
|
||||
for (const [id, record] of this.#modules) if (record.enabled) visit(id);
|
||||
const capabilities = new Set([...this.#modules.values()].filter(record => record.enabled).flatMap(record => record.manifest.provides));
|
||||
for (const record of this.#modules.values()) {
|
||||
if (!record.enabled) continue;
|
||||
for (const capability of record.manifest.consumes) {
|
||||
if (!capabilities.has(capability)) throw new PlatformError(`Fehlende Capability ${capability} für ${record.manifest.id}.`, { code: 'MODULE_CAPABILITY_MISSING' });
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
validateGraph() {
|
||||
const order = this.#resolveStartOrder();
|
||||
return Object.freeze({
|
||||
order: Object.freeze([...order]),
|
||||
enabledModules: Object.freeze(order.map(id => this.#modules.get(id)?.manifest.id).filter(Boolean)),
|
||||
capabilities: Object.freeze([...new Set([...this.#modules.values()]
|
||||
.filter(record => record.enabled)
|
||||
.flatMap(record => record.manifest.provides))].sort()),
|
||||
});
|
||||
}
|
||||
|
||||
async #startRecord(id, context) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record || !record.enabled || record.state === 'running') return;
|
||||
for (const dependency of record.manifest.requires) {
|
||||
const dependencyRecord = this.#modules.get(dependency);
|
||||
if (!dependencyRecord?.enabled) {
|
||||
throw new PlatformError(`Modul ${id} benötigt das deaktivierte Modul ${dependency}.`, {
|
||||
code: 'MODULE_DEPENDENCY_DISABLED', status: 409, expose: true,
|
||||
details: { module: id, dependency },
|
||||
});
|
||||
}
|
||||
if (dependencyRecord.state !== 'running') await this.#startRecord(dependency, context);
|
||||
}
|
||||
try {
|
||||
record.state = 'starting';
|
||||
record.error = null;
|
||||
await record.lifecycle.start(Object.freeze({ ...context, module: record.manifest }));
|
||||
record.state = 'running';
|
||||
record.startedAt = new Date().toISOString();
|
||||
if (!this.#startOrder.includes(id)) this.#startOrder.push(id);
|
||||
} catch (error) {
|
||||
record.state = 'failed';
|
||||
record.error = error?.message || String(error);
|
||||
throw new PlatformError(`Modulstart fehlgeschlagen: ${id}`, { code: 'MODULE_START_FAILED', cause: error, details: { module: id } });
|
||||
}
|
||||
}
|
||||
|
||||
async startAll(context) {
|
||||
this.#startOrder = this.#resolveStartOrder();
|
||||
const started = [];
|
||||
for (const id of this.#startOrder) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record.enabled) continue;
|
||||
try {
|
||||
await this.#startRecord(id, context);
|
||||
started.push(id);
|
||||
} catch (error) {
|
||||
for (const startedId of started.reverse()) {
|
||||
const startedRecord = this.#modules.get(startedId);
|
||||
try { await startedRecord.lifecycle.stop(Object.freeze({ ...context, module: startedRecord.manifest, rollback: true })); } catch {}
|
||||
startedRecord.state = 'stopped';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.#started = true;
|
||||
}
|
||||
|
||||
async enable(id, context) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (record.enabled && record.state === 'running') return this.describe(id);
|
||||
record.enabled = true;
|
||||
record.state = 'registered';
|
||||
try {
|
||||
this.#resolveStartOrder();
|
||||
if (this.#started) await this.#startRecord(id, context);
|
||||
return this.describe(id);
|
||||
} catch (error) {
|
||||
record.enabled = false;
|
||||
record.state = 'disabled';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disable(id, context, { cascade = false } = {}) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (!record.enabled) return { module: this.describe(id), disabled: [] };
|
||||
const dependents = this.dependentsOf(id, { enabledOnly: true, recursive: true });
|
||||
if (dependents.length && !cascade) {
|
||||
throw new PlatformError('Das Modul wird von aktiven Modulen benötigt.', {
|
||||
code: 'MODULE_HAS_ACTIVE_DEPENDENTS', status: 409, expose: true, details: { dependents },
|
||||
});
|
||||
}
|
||||
const orderIndex = new Map(this.#startOrder.map((moduleId, index) => [moduleId, index]));
|
||||
const toDisable = [...dependents, id].sort((a, b) => (orderIndex.get(b) ?? -1) - (orderIndex.get(a) ?? -1));
|
||||
for (const moduleId of toDisable) {
|
||||
const target = this.#modules.get(moduleId);
|
||||
if (!target?.enabled) continue;
|
||||
if (target.state === 'running') {
|
||||
target.state = 'stopping';
|
||||
try {
|
||||
await target.lifecycle.stop(Object.freeze({ ...context, module: target.manifest, disabled: true }));
|
||||
target.state = 'disabled';
|
||||
} catch (error) {
|
||||
target.state = 'failed';
|
||||
target.error = error?.message || String(error);
|
||||
throw new PlatformError(`Modul konnte nicht deaktiviert werden: ${moduleId}`, { code: 'MODULE_STOP_FAILED', status: 500, cause: error });
|
||||
}
|
||||
} else {
|
||||
target.state = 'disabled';
|
||||
}
|
||||
target.enabled = false;
|
||||
target.startedAt = null;
|
||||
}
|
||||
return { module: this.describe(id), disabled: toDisable };
|
||||
}
|
||||
|
||||
async unregister(id, context) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) return false;
|
||||
if (record.enabled || record.state === 'running') await this.disable(id, context, { cascade: false });
|
||||
const dependents = this.dependentsOf(id, { enabledOnly: false, recursive: false });
|
||||
if (dependents.length) {
|
||||
throw new PlatformError('Modul kann wegen vorhandener Abhängigkeiten nicht entfernt werden.', {
|
||||
code: 'MODULE_HAS_DEPENDENTS', status: 409, expose: true, details: { dependents },
|
||||
});
|
||||
}
|
||||
for (const resource of record.manifest.owns) if (this.#ownership.get(resource) === id) this.#ownership.delete(resource);
|
||||
this.#modules.delete(id);
|
||||
this.#startOrder = this.#startOrder.filter(moduleId => moduleId !== id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async stopAll(context) {
|
||||
for (const id of [...this.#startOrder].reverse()) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record || record.state !== 'running') continue;
|
||||
try {
|
||||
record.state = 'stopping';
|
||||
await record.lifecycle.stop(Object.freeze({ ...context, module: record.manifest }));
|
||||
record.state = 'stopped';
|
||||
} catch (error) {
|
||||
record.state = 'failed';
|
||||
record.error = error?.message || String(error);
|
||||
}
|
||||
}
|
||||
this.#started = false;
|
||||
}
|
||||
|
||||
async health() {
|
||||
const checks = [];
|
||||
for (const [id, record] of this.#modules) {
|
||||
if (!record.enabled || record.state === 'disabled') {
|
||||
checks.push({ id, ok: true, state: 'disabled' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await record.lifecycle.health({ module: record.manifest });
|
||||
checks.push({ id, state: record.state, ok: record.state === 'running' && result?.ok !== false, ...(result || {}) });
|
||||
} catch (error) {
|
||||
checks.push({ id, state: record.state, ok: false, error: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
describe(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) return null;
|
||||
return {
|
||||
...record.manifest,
|
||||
source: record.source,
|
||||
enabled: record.enabled,
|
||||
state: record.state,
|
||||
startedAt: record.startedAt,
|
||||
error: record.error,
|
||||
dependents: this.dependentsOf(id, { enabledOnly: false, recursive: false }),
|
||||
};
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return [...this.#modules.keys()].map(id => this.describe(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { EventBus } from './event-bus.mjs';
|
||||
import { FeatureFlagRegistry } from './feature-flags.mjs';
|
||||
import { LifecycleManager } from './lifecycle.mjs';
|
||||
import { ModuleRegistry } from './module-registry.mjs';
|
||||
import { PolicyEngine } from './policy-engine.mjs';
|
||||
import { RouteRegistry } from './route-registry.mjs';
|
||||
import { loadBuiltInModules } from '../modules/catalog.mjs';
|
||||
import { ModuleStateStore } from '../core/modules/module-state-store.mjs';
|
||||
import { ModulePackageStore } from '../core/modules/module-package-store.mjs';
|
||||
import { createExternalLifecycle, PROTECTED_MODULE_IDS } from '../modules/module-manager/service.mjs';
|
||||
import { MODULES_DIR, MODULE_STATE_PATH } from '../../lib/runtime-paths.mjs';
|
||||
|
||||
export function createPlatformKernel({ version, hasPermission, moduleTrustKeys = {} }) {
|
||||
const events = new EventBus();
|
||||
const features = new FeatureFlagRegistry();
|
||||
const modules = new ModuleRegistry();
|
||||
const moduleStateStore = new ModuleStateStore(MODULE_STATE_PATH);
|
||||
const modulePackageStore = new ModulePackageStore(MODULES_DIR, { trustKeys: moduleTrustKeys });
|
||||
const policies = new PolicyEngine();
|
||||
const routes = new RouteRegistry({ policyEngine: policies, moduleRegistry: modules });
|
||||
const lifecycle = new LifecycleManager();
|
||||
|
||||
policies.register('platform.authenticated', ({ user }) => Boolean(user), {
|
||||
description: 'Erfordert eine gültige Vendoo-Sitzung.',
|
||||
});
|
||||
policies.register('platform.security-admin', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), {
|
||||
description: 'Erfordert die Berechtigung security.manage.',
|
||||
});
|
||||
policies.register('security.secrets.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'security.manage')), {
|
||||
owner: 'vendoo.security', description: 'Erfordert security.manage für verschlüsselte Zugangsdaten.',
|
||||
});
|
||||
policies.register('auth.public', () => true, { owner: 'vendoo.auth', description: 'Öffentliche Authentifizierungsroute mit eigener Eingabe- und Rate-Limit-Prüfung.' });
|
||||
policies.register('auth.authenticated', ({ user }) => Boolean(user), { owner: 'vendoo.auth', description: 'Erfordert eine gültige Vendoo-Identität.' });
|
||||
policies.register('users.self', ({ user }) => Boolean(user), { owner: 'vendoo.users', description: 'Erlaubt Zugriff auf das eigene Benutzerprofil.' });
|
||||
policies.register('users.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'users.manage')), { owner: 'vendoo.users', description: 'Erfordert users.manage.' });
|
||||
policies.register('sessions.self', ({ user }) => Boolean(user), { owner: 'vendoo.sessions', description: 'Erlaubt Zugriff auf eigene Sitzungen.' });
|
||||
policies.register('sessions.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'sessions.manage')), { owner: 'vendoo.sessions', description: 'Erfordert sessions.manage.' });
|
||||
policies.register('settings.read', ({ user }) => Boolean(user), { owner: 'vendoo.settings', description: 'Erfordert eine gültige Sitzung zum Lesen der Anwendungseinstellungen.' });
|
||||
policies.register('settings.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), { owner: 'vendoo.settings', description: 'Erfordert settings.manage.' });
|
||||
policies.register('listings.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.listings', description: 'Erfordert listings.view.' });
|
||||
policies.register('listings.edit', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.edit')), { owner: 'vendoo.listings', description: 'Erfordert listings.edit.' });
|
||||
policies.register('listings.delete', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.delete')), { owner: 'vendoo.listings', description: 'Erfordert listings.delete.' });
|
||||
policies.register('listings.permanent-delete', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.permanent_delete')), { owner: 'vendoo.listings', description: 'Erfordert listings.permanent_delete.' });
|
||||
policies.register('inventory.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.inventory', description: 'Erfordert listings.view für Lagerübersichten.' });
|
||||
policies.register('inventory.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'inventory.edit')), { owner: 'vendoo.inventory', description: 'Erfordert inventory.edit.' });
|
||||
policies.register('media.view', ({ user }) => Boolean(user && hasPermission(user.role, 'media.view')), { owner: 'vendoo.media', description: 'Erfordert media.view.' });
|
||||
policies.register('media.edit', ({ user }) => Boolean(user && hasPermission(user.role, 'media.edit')), { owner: 'vendoo.media', description: 'Erfordert media.edit.' });
|
||||
policies.register('media.delete', ({ user }) => Boolean(user && hasPermission(user.role, 'media.delete')), { owner: 'vendoo.media', description: 'Erfordert media.delete.' });
|
||||
policies.register('themes.use', ({ user }) => Boolean(user), {
|
||||
owner: 'vendoo.themes', description: 'Erfordert eine gültige Sitzung für persönliche Darstellungseinstellungen.',
|
||||
});
|
||||
policies.register('themes.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'settings.manage')), {
|
||||
owner: 'vendoo.themes', description: 'Erfordert settings.manage für systemweite und eigene Theme-Profile.',
|
||||
});
|
||||
policies.register('modules.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'modules.manage')), {
|
||||
owner: 'vendoo.module-manager', description: 'Erfordert modules.manage für Installation, Aktivierung, Export und Entfernung von Modulen.',
|
||||
});
|
||||
policies.register('publishing.view', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.view')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.view.' });
|
||||
policies.register('publishing.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.execute')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.execute.' });
|
||||
policies.register('publishing.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'publishing.manage')), { owner: 'vendoo.publishing', description: 'Erfordert publishing.manage.' });
|
||||
policies.register('ai.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'generation.execute')), { owner: 'vendoo.ai', description: 'Erfordert generation.execute.' });
|
||||
policies.register('flux.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'generation.execute')), { owner: 'vendoo.flux-studio', description: 'Erfordert generation.execute.' });
|
||||
policies.register('quality.view', ({ user }) => Boolean(user && hasPermission(user.role, 'listings.view')), { owner: 'vendoo.quality', description: 'Erfordert listings.view.' });
|
||||
policies.register('quality.execute', ({ user }) => Boolean(user && hasPermission(user.role, 'quality.execute')), { owner: 'vendoo.quality', description: 'Erfordert quality.execute.' });
|
||||
policies.register('updates.manage', ({ user }) => Boolean(user && hasPermission(user.role, 'updates.manage')), { owner: 'vendoo.deployments', description: 'Erfordert updates.manage für Coolify-Deployment und Web-Updates.' });
|
||||
|
||||
features.define('platform.module-kernel', { defaultValue: true, description: 'Aktiviert den modularen Plattformkernel.' });
|
||||
features.define('platform.route-contracts', { defaultValue: true, description: 'Aktiviert registrierte Routenverträge.' });
|
||||
features.define('security.encrypted-secret-store', { defaultValue: true, owner: 'vendoo.security', description: 'Aktiviert AES-256-GCM-verschlüsselte Zugangsdaten außerhalb der Runtime-Konfiguration.' });
|
||||
features.define('security.request-telemetry', { defaultValue: true, owner: 'vendoo.security', description: 'Aktiviert Request-/Correlation-IDs und strukturierte Telemetrie.' });
|
||||
features.define('security.strict-script-csp', { defaultValue: true, owner: 'vendoo.security', description: 'Blockiert Inline-Skripte und Inline-Eventhandler per CSP.' });
|
||||
features.define('identity.native-auth', { defaultValue: true, owner: 'vendoo.auth', description: 'Aktiviert native Setup-, Login- und Einladungsabläufe.' });
|
||||
features.define('identity.native-users', { defaultValue: true, owner: 'vendoo.users', description: 'Aktiviert native Benutzer- und Rollenverwaltung.' });
|
||||
features.define('identity.native-sessions', { defaultValue: true, owner: 'vendoo.sessions', description: 'Aktiviert eigentümergebundene Sitzungsverwaltung und Revocation.' });
|
||||
features.define('settings.native-module', { defaultValue: true, owner: 'vendoo.settings', description: 'Aktiviert allowlist-basierte Anwendungs- und SMTP-Einstellungen.' });
|
||||
features.define('catalog.native-listings', { defaultValue: true, owner: 'vendoo.listings', description: 'Aktiviert native Artikel-, Duplikat- und Papierkorbverträge.' });
|
||||
features.define('catalog.native-inventory', { defaultValue: true, owner: 'vendoo.inventory', description: 'Aktiviert native Lagerort- und Bulk-Bestandsverträge.' });
|
||||
features.define('catalog.native-media', { defaultValue: true, owner: 'vendoo.media', description: 'Aktiviert native Medienbibliothek und Referenzschutz.' });
|
||||
features.define('themes.token-runtime', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den versionierten Design-Token-Vertrag.' });
|
||||
features.define('themes.manager', { defaultValue: true, owner: 'vendoo.themes', description: 'Aktiviert den sicheren Theme Manager mit Benutzerpräferenzen.' });
|
||||
features.define('themes.accessibility-gate', { defaultValue: true, owner: 'vendoo.themes', description: 'Prüft Theme-Kontraste und sichere Tokenwerte.' });
|
||||
features.define('extensions.isolated-host', { defaultValue: false, owner: 'vendoo.security', description: 'Reserviert für einen späteren isolierten Extension-Host.' });
|
||||
features.define('modules.manager', { defaultValue: true, owner: 'vendoo.module-manager', description: 'Aktiviert den sicheren Modulmanager und den persistenten Modulstatus.' });
|
||||
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.' });
|
||||
features.define('updates.web-trigger', { defaultValue: true, owner: 'vendoo.deployments', description: 'Erlaubt Administratoren, einen geprüften Coolify-Deployment-Auftrag aus dem Update Center anzufordern.' });
|
||||
|
||||
const initialDisabledModules = new Set(String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '')
|
||||
.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' && module.manifest.defaultEnabled !== false && !initialDisabledModules.has(module.manifest.id);
|
||||
const enabled = forcedEnabled || moduleStateStore.isEnabled(module.manifest.id, defaultEnabled);
|
||||
modules.register(module, { enabled });
|
||||
}
|
||||
for (const installed of modulePackageStore.list()) {
|
||||
if (installed.invalid || modules.has(installed.id)) continue;
|
||||
try {
|
||||
const pkg = modulePackageStore.get(installed.id);
|
||||
const enabled = installed.activatable && moduleStateStore.isEnabled(installed.id, false);
|
||||
modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled });
|
||||
} catch {
|
||||
// Ungültige oder konfliktbehaftete Fremdmodule bleiben quarantänisiert und erscheinen im Modulmanager.
|
||||
}
|
||||
}
|
||||
lifecycle.register({
|
||||
id: 'modules',
|
||||
order: 20,
|
||||
start: context => modules.startAll(context),
|
||||
stop: context => modules.stopAll(context),
|
||||
});
|
||||
|
||||
const context = Object.freeze({ version, events, features, policies, modules, moduleStateStore, modulePackageStore });
|
||||
return Object.freeze({
|
||||
version,
|
||||
events,
|
||||
features,
|
||||
lifecycle,
|
||||
modules,
|
||||
policies,
|
||||
routes,
|
||||
async start() {
|
||||
await lifecycle.start(context);
|
||||
await events.emit('platform.started', { version }, { owner: 'vendoo.system' });
|
||||
},
|
||||
async stop() {
|
||||
await events.emit('platform.stopping', { version }, { owner: 'vendoo.system' });
|
||||
await lifecycle.stop(context);
|
||||
},
|
||||
async health() {
|
||||
const moduleChecks = await modules.health();
|
||||
return {
|
||||
ok: lifecycle.snapshot().state === 'running' && moduleChecks.every(check => check.ok),
|
||||
lifecycle: lifecycle.snapshot(),
|
||||
modules: moduleChecks,
|
||||
};
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
version,
|
||||
architecture: 'modular-monolith',
|
||||
lifecycle: lifecycle.snapshot(),
|
||||
modules: modules.snapshot(),
|
||||
features: features.list(),
|
||||
policies: policies.list(),
|
||||
routes: routes.list(),
|
||||
moduleState: moduleStateStore.snapshot(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PlatformError } from './errors.mjs';
|
||||
|
||||
const POLICY_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
export class PolicyEngine {
|
||||
#policies = new Map();
|
||||
|
||||
register(id, evaluator, { owner = 'vendoo.security', description = '' } = {}) {
|
||||
const normalized = String(id || '');
|
||||
if (!POLICY_ID.test(normalized)) throw new PlatformError(`Ungültige Policy-ID: ${normalized}`, { code: 'POLICY_ID_INVALID' });
|
||||
if (this.#policies.has(normalized)) throw new PlatformError(`Policy bereits registriert: ${normalized}`, { code: 'POLICY_DUPLICATE' });
|
||||
if (typeof evaluator !== 'function') throw new PlatformError(`Policy benötigt eine Prüffunktion: ${normalized}`, { code: 'POLICY_EVALUATOR_INVALID' });
|
||||
this.#policies.set(normalized, { id: normalized, evaluator, owner, description });
|
||||
}
|
||||
|
||||
async evaluate(id, context) {
|
||||
const policy = this.#policies.get(id);
|
||||
if (!policy) {
|
||||
return { allowed: false, reason: 'policy_not_registered', policy: id };
|
||||
}
|
||||
const result = await policy.evaluator(Object.freeze({ ...context }));
|
||||
if (result === true) return { allowed: true, policy: id };
|
||||
if (result === false || result == null) return { allowed: false, reason: 'denied', policy: id };
|
||||
return { allowed: Boolean(result.allowed), reason: result.reason || null, policy: id, details: result.details || null };
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.#policies.values()].map(({ evaluator: _evaluator, ...policy }) => ({ ...policy }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PlatformError, platformErrorPayload } from './errors.mjs';
|
||||
|
||||
const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
||||
const ROUTE_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
export class RouteRegistry {
|
||||
#routes = new Map();
|
||||
#policyEngine;
|
||||
#moduleRegistry;
|
||||
|
||||
constructor({ policyEngine, moduleRegistry = null }) {
|
||||
this.#policyEngine = policyEngine;
|
||||
this.#moduleRegistry = moduleRegistry;
|
||||
}
|
||||
|
||||
register(app, definition) {
|
||||
const method = String(definition.method || '').toLowerCase();
|
||||
const id = String(definition.id || '');
|
||||
const path = String(definition.path || '');
|
||||
if (!ROUTE_ID.test(id)) throw new PlatformError(`Ungültige Route-ID: ${id}`, { code: 'ROUTE_ID_INVALID' });
|
||||
if (!METHODS.has(method)) throw new PlatformError(`Ungültige HTTP-Methode für ${id}.`, { code: 'ROUTE_METHOD_INVALID' });
|
||||
if (!path.startsWith('/')) throw new PlatformError(`Ungültiger Routenpfad für ${id}.`, { code: 'ROUTE_PATH_INVALID' });
|
||||
if (!definition.policy) throw new PlatformError(`Route ${id} benötigt eine explizite Policy.`, { code: 'ROUTE_POLICY_REQUIRED' });
|
||||
if (!definition.owner) throw new PlatformError(`Route ${id} benötigt einen Modulbesitzer.`, { code: 'ROUTE_OWNER_REQUIRED' });
|
||||
if (typeof definition.handler !== 'function') throw new PlatformError(`Route ${id} benötigt einen Handler.`, { code: 'ROUTE_HANDLER_REQUIRED' });
|
||||
const key = `${method.toUpperCase()} ${path}`;
|
||||
if (this.#routes.has(key)) throw new PlatformError(`Route bereits registriert: ${key}`, { code: 'ROUTE_DUPLICATE' });
|
||||
|
||||
const contract = Object.freeze({
|
||||
id,
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
owner: String(definition.owner),
|
||||
policy: String(definition.policy),
|
||||
audit: String(definition.audit || ''),
|
||||
input: definition.input ? 'validated' : 'none',
|
||||
rateLimit: String(definition.rateLimit || 'default'),
|
||||
csrf: definition.csrf !== false,
|
||||
stability: String(definition.stability || 'internal'),
|
||||
});
|
||||
this.#routes.set(key, contract);
|
||||
|
||||
app[method](path, ...(definition.middleware || []), async (req, res, next) => {
|
||||
try {
|
||||
if (this.#moduleRegistry && !this.#moduleRegistry.isEnabled(contract.owner)) {
|
||||
throw new PlatformError('Dieses Modul ist deaktiviert.', {
|
||||
code: 'MODULE_DISABLED', status: 503, expose: true,
|
||||
details: { module: contract.owner },
|
||||
});
|
||||
}
|
||||
const decision = await this.#policyEngine.evaluate(contract.policy, { req, user: req.user, route: contract });
|
||||
if (!decision.allowed) {
|
||||
throw new PlatformError('Keine Berechtigung für diese Aktion.', {
|
||||
code: 'POLICY_DENIED', status: 403, expose: true,
|
||||
details: { policy: contract.policy, reason: decision.reason },
|
||||
});
|
||||
}
|
||||
if (typeof definition.input === 'function') req.validatedBody = definition.input(req.body);
|
||||
await definition.handler(req, res, { route: contract, decision });
|
||||
} catch (error) {
|
||||
if (res.headersSent) return next(error);
|
||||
const status = Number.isInteger(error?.status) ? error.status : 500;
|
||||
res.status(status).json(platformErrorPayload(error, req.requestId));
|
||||
}
|
||||
});
|
||||
return contract;
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.#routes.values()].map(route => ({ ...route }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configureAiModule(deps = {}) {
|
||||
runtime = {
|
||||
startModelJobs: deps.startModelJobs || (() => {}),
|
||||
stopModelJobs: deps.stopModelJobs || (() => {}),
|
||||
startBatchJobs: deps.startBatchJobs || (() => {}),
|
||||
stopBatchJobs: deps.stopBatchJobs || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAiModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() {
|
||||
if (!runtime) throw new Error('AI-Modul ist nicht konfiguriert.');
|
||||
runtime.startModelJobs();
|
||||
runtime.startBatchJobs();
|
||||
started = true;
|
||||
},
|
||||
async stop() {
|
||||
runtime?.stopModelJobs();
|
||||
runtime?.stopBatchJobs();
|
||||
started = false;
|
||||
},
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, workers: ['model-photos', 'image-batch'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.ai",
|
||||
"name": "AI Services",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native AI-Services für Text-, Modellfoto- und Batch-Jobs mit kontrolliertem Worker-Lifecycle.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.media"
|
||||
],
|
||||
"permissions": [
|
||||
"generation.execute"
|
||||
],
|
||||
"provides": [
|
||||
"ai.text",
|
||||
"ai.images"
|
||||
],
|
||||
"consumes": [
|
||||
"media.assets",
|
||||
"security.policies"
|
||||
],
|
||||
"owns": [
|
||||
"ai.providers",
|
||||
"ai.jobs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
let setupTokenMatcher = () => false;
|
||||
|
||||
export function configureAuthModule({ setupTokenMatches }) {
|
||||
if (typeof setupTokenMatches === 'function') setupTokenMatcher = setupTokenMatches;
|
||||
}
|
||||
|
||||
export function getAuthService() {
|
||||
if (!service) throw new PlatformError('Auth-Modul ist noch nicht gestartet.', { code: 'AUTH_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createAuthModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() {
|
||||
const { createAuthService } = await import('./service.mjs');
|
||||
service = createAuthService({ setupTokenMatches: setupTokenMatcher });
|
||||
},
|
||||
async stop() { service = null; },
|
||||
async health() { return getAuthService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.auth",
|
||||
"name": "Authentication",
|
||||
"version": "2.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Native Authentifizierung mit Setup, Login, Einladungen, Passwortregeln und sicheren Sitzungsübergaben.",
|
||||
"requires": ["vendoo.security"],
|
||||
"permissions": ["sessions.manage"],
|
||||
"provides": ["auth.identity", "auth.tokens"],
|
||||
"consumes": ["security.policies"],
|
||||
"owns": ["auth.tokens", "auth.login-history"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getAuthService } from './index.mjs';
|
||||
|
||||
const emailRule = { type: 'string', required: true, minLength: 3, maxLength: 254, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ };
|
||||
|
||||
export function registerPublicAuthRoutes({ app, routes, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'auth.setup-status.read', method: 'GET', path: '/auth/setup-check', owner: 'vendoo.auth', policy: 'auth.public',
|
||||
csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getAuthService().setupStatus({ mailConfigured: deps.isMailerConfigured(), setupTokenRequired: deps.setupTokenRequired })),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.setup.create', method: 'POST', path: '/auth/setup', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, password: { type: 'string', required: true, maxLength: 512, trim: false }, setup_token: { type: 'string', maxLength: 512, trim: false } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().setup({ ...req.validatedBody, setupToken: req.get('x-vendoo-setup-token') || req.validatedBody.setup_token, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.login.create', method: 'POST', path: '/auth/login', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, password: { type: 'string', required: true, maxLength: 512, trim: false } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().login({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.invite.create', method: 'POST', path: '/auth/invite', owner: 'vendoo.auth', policy: 'auth.authenticated',
|
||||
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
|
||||
input: objectSchema({ email: emailRule, name: { type: 'string', maxLength: 120 }, role: { type: 'string', enum: ['admin','manager','editor','publisher','viewer'] } }),
|
||||
handler: async (req, res) => {
|
||||
const { token } = getAuthService().createInvite({ email: req.validatedBody.email, role: req.validatedBody.role || 'editor', createdBy: req.user.id });
|
||||
const result = await deps.sendMagicLink(req.validatedBody.email, token, `${req.protocol}://${req.get('host')}`, 'invite');
|
||||
deps.auditLog(req.user.id, req.user.email, 'user.invited', 'user', req.validatedBody.email, `Rolle: ${req.validatedBody.role || 'editor'}`, deps.getClientIp(req));
|
||||
res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.magic.redirect', method: 'GET', path: '/auth/magic', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const token = String(req.query?.token || '');
|
||||
if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben'));
|
||||
res.redirect(`/login.html?invite=${encodeURIComponent(token)}`);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.invite.accept', method: 'POST', path: '/auth/accept-invite', owner: 'vendoo.auth', policy: 'auth.public', csrf: false, stability: 'stable',
|
||||
input: objectSchema({ token: { type: 'string', required: true, minLength: 16, maxLength: 512, trim: false }, password: { type: 'string', required: true, maxLength: 512, trim: false }, name: { type: 'string', maxLength: 120 } }),
|
||||
handler: async (req, res) => {
|
||||
const result = getAuthService().acceptInvite({ ...req.validatedBody, ip: deps.getClientIp(req), userAgent: req.get('user-agent') || '' });
|
||||
deps.setSessionCookies(req, res, result.session, result.csrf);
|
||||
res.json({ ok: true, redirect: '/' });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'auth.logout.create', method: 'POST', path: '/auth/logout', owner: 'vendoo.auth', policy: 'auth.authenticated',
|
||||
middleware: [deps.requireAuth, deps.csrfProtect], csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
getAuthService().logout({ token: req.cookies?.auth_token, ip: deps.getClientIp(req) });
|
||||
deps.clearSessionCookies(res);
|
||||
res.json({ ok: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
auditLog, checkRateLimit, clearFailedLogins, createAuthToken, createSession, createUser,
|
||||
generateCsrfToken, getUserByEmail, isSetupMode, isUserLocked, recordLogin,
|
||||
registerFailedLogin, setPassword, updateUser, validateAuthToken, validatePasswordStrength, verifyPassword,
|
||||
validateSession, destroySession,
|
||||
} from '../../core/identity/identity-store.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
function fail(message, status = 400, code = 'AUTH_FAILED', details = null) {
|
||||
throw new PlatformError(message, { status, code, expose: true, details });
|
||||
}
|
||||
|
||||
export function createAuthService({ setupTokenMatches }) {
|
||||
return Object.freeze({
|
||||
setupStatus({ mailConfigured, setupTokenRequired }) {
|
||||
return { setupMode: isSetupMode(), mailConfigured: Boolean(mailConfigured), setupTokenRequired: Boolean(setupTokenRequired) };
|
||||
},
|
||||
|
||||
setup({ email, name, password, setupToken, ip, userAgent }) {
|
||||
if (!isSetupMode()) fail('Setup bereits abgeschlossen', 400, 'SETUP_COMPLETED');
|
||||
if (!setupTokenMatches(setupToken)) fail('Ungültiger Einrichtungs-Code.', 403, 'SETUP_TOKEN_INVALID');
|
||||
const passwordCheck = validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
|
||||
try {
|
||||
const user = createUser(email, name || email.split('@')[0], 'admin', null, password);
|
||||
auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip);
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, email, 'login.success', null, null, 'via setup', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
} catch (error) {
|
||||
fail(error?.message || 'Setup fehlgeschlagen.', 500, 'SETUP_FAILED');
|
||||
}
|
||||
},
|
||||
|
||||
login({ email, password, ip, userAgent }) {
|
||||
const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!rl.allowed) fail(`Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.`, 429, 'LOGIN_RATE_LIMIT', { retry_after: rl.retryAfter });
|
||||
const user = getUserByEmail(email);
|
||||
if (user && isUserLocked(user)) {
|
||||
auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip);
|
||||
fail('Account nach mehreren Fehlversuchen vorübergehend gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: user.locked_until });
|
||||
}
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
if (user) {
|
||||
recordLogin(user.id, ip, userAgent, false);
|
||||
const lockResult = registerFailedLogin(user.id);
|
||||
if (lockResult?.locked) {
|
||||
auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip);
|
||||
fail('Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', 423, 'ACCOUNT_LOCKED', { locked_until: lockResult.lockedUntil });
|
||||
}
|
||||
}
|
||||
auditLog(user?.id || null, email, 'login.failed', null, null, null, ip);
|
||||
fail('E-Mail oder Passwort falsch', 401, 'LOGIN_INVALID');
|
||||
}
|
||||
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
|
||||
clearFailedLogins(user.id);
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, email, 'login.success', null, null, 'via password', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
},
|
||||
|
||||
createInvite({ email, role, createdBy }) {
|
||||
return createAuthToken(email, 'invite', role || 'editor', createdBy);
|
||||
},
|
||||
|
||||
acceptInvite({ token, password, name, ip, userAgent }) {
|
||||
const passwordCheck = validatePasswordStrength(password);
|
||||
if (!passwordCheck.valid) fail(`Passwort benötigt ${passwordCheck.problems.join(', ')}.`, 400, 'PASSWORD_WEAK');
|
||||
const authToken = validateAuthToken(token);
|
||||
if (!authToken) fail('Link ungültig oder abgelaufen', 400, 'INVITE_INVALID');
|
||||
let user = getUserByEmail(authToken.email);
|
||||
if (!user) {
|
||||
user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password);
|
||||
auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip);
|
||||
} else {
|
||||
setPassword(user.id, password);
|
||||
if (name) user = updateUser(user.id, { name });
|
||||
}
|
||||
if (!user.active) fail('Account deaktiviert', 403, 'ACCOUNT_DISABLED');
|
||||
const session = createSession(user.id, ip, userAgent);
|
||||
recordLogin(user.id, ip, userAgent, true);
|
||||
auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip);
|
||||
return { user, session, csrf: generateCsrfToken() };
|
||||
},
|
||||
|
||||
logout({ token, ip }) {
|
||||
if (!token) return { ok: true };
|
||||
const session = validateSession(token);
|
||||
if (session) auditLog(session.user_id, session.email, 'logout', null, null, null, ip);
|
||||
destroySession(token);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
health() {
|
||||
return { ok: true, setup_mode: isSetupMode() };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createAuthModule } from './auth/index.mjs';
|
||||
import { createSecurityModule } from './security/index.mjs';
|
||||
import { createSessionsModule } from './sessions/index.mjs';
|
||||
import { createSettingsModule } from './settings/index.mjs';
|
||||
import { createListingsModule } from './listings/index.mjs';
|
||||
import { createInventoryModule } from './inventory/index.mjs';
|
||||
import { createMediaModule } from './media/index.mjs';
|
||||
import { createThemesModule } from './themes/index.mjs';
|
||||
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';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const legacyModuleNames = ['system', 'operations'];
|
||||
|
||||
function legacyModule(name) {
|
||||
return {
|
||||
manifest: JSON.parse(readFileSync(join(here, name, 'module.json'), 'utf8')),
|
||||
lifecycle: {
|
||||
async start() {}, async stop() {}, async health() { return { ok: true, bridge: 'legacy' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadBuiltInModules() {
|
||||
return [
|
||||
...legacyModuleNames.map(legacyModule),
|
||||
createSecurityModule(), createAuthModule(), createSessionsModule(), createUsersModule(), createSettingsModule(), createThemesModule(),
|
||||
createMediaModule(), createListingsModule(), createInventoryModule(), createAiModule(), createProductImageToolsModule(), createFluxStudioModule(), createQualityModule(), createPublishingModule(), createModuleManagerModule(), createDeploymentsModule(),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { createDeploymentService } from './service.mjs';
|
||||
|
||||
let service = null;
|
||||
let adapters = {};
|
||||
|
||||
export function configureDeploymentsModule(nextAdapters = {}) {
|
||||
adapters = { ...adapters, ...nextAdapters };
|
||||
}
|
||||
|
||||
export function getDeploymentService() {
|
||||
if (!service) throw new PlatformError('Deployment-Modul ist noch nicht gestartet.', {
|
||||
code: 'DEPLOYMENT_MODULE_NOT_READY', status: 503, expose: true,
|
||||
});
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createDeploymentsModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = createDeploymentService(adapters); },
|
||||
async stop() { service?.stop?.(); service = null; },
|
||||
async health() { return getDeploymentService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.deployments",
|
||||
"name": "Deployments & Updates",
|
||||
"version": "1.1.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Persistente, kontrollierte Coolify-Deployments mit Vorab-Backup, Idempotenz, Statusverfolgung, Readiness-, Zielversions- und Produktionschecks ohne Docker-Socket.",
|
||||
"requires": [
|
||||
"vendoo.security",
|
||||
"vendoo.settings",
|
||||
"vendoo.operations",
|
||||
"vendoo.module-manager"
|
||||
],
|
||||
"permissions": [
|
||||
"updates.manage"
|
||||
],
|
||||
"provides": [
|
||||
"deployments.control",
|
||||
"deployments.coolify",
|
||||
"deployments.production-checks",
|
||||
"deployments.state-machine",
|
||||
"updates.web"
|
||||
],
|
||||
"consumes": [
|
||||
"security.secrets",
|
||||
"settings.configuration",
|
||||
"operations.backup",
|
||||
"modules.management"
|
||||
],
|
||||
"owns": [
|
||||
"deployments.configuration",
|
||||
"deployments.events",
|
||||
"deployments.history",
|
||||
"deployments.production-checks",
|
||||
"updates.coolify"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,132 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { getDeploymentService } from './index.mjs';
|
||||
|
||||
const settingsSchema = objectSchema({
|
||||
provider: { type: 'string', enum: ['none', 'coolify'] },
|
||||
coolify_url: { type: 'string', maxLength: 1000 },
|
||||
resource_uuid: { type: 'string', maxLength: 120 },
|
||||
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 },
|
||||
update_channel: { type: 'string', enum: ['stable', 'beta'] },
|
||||
backup_before_deploy: { type: 'boolean' },
|
||||
backup_uploads: { type: 'boolean' },
|
||||
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 },
|
||||
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(await getDeploymentService().status()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.settings.update', method: 'PUT', path: '/api/admin/deployment', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.settings.updated', rateLimit: 'administration', csrf: true, stability: 'stable', input: settingsSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = getDeploymentService().update(req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.settings.updated', 'deployment', 'coolify', null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'deployments.connection.test', method: 'POST', path: '/api/admin/deployment/test', owner: 'vendoo.deployments',
|
||||
policy: 'updates.manage', audit: 'deployments.connection.tested', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = await getDeploymentService().testConnection();
|
||||
deps.auditLog(req.user.id, req.user.email, 'deployment.connection.tested', 'deployment', 'coolify', JSON.stringify({ ok: result.ok }), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
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) => {
|
||||
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 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() }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
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, 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 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 });
|
||||
}
|
||||
|
||||
function clean(value, max = 1000) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > max) fail('Deployment-Einstellung ist zu lang.');
|
||||
if (/\r|\n|\0/.test(text)) fail('Deployment-Einstellung enthält unzulässige Steuerzeichen.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function cleanUuid(value) {
|
||||
const text = clean(value, 120);
|
||||
if (text && !/^[a-zA-Z0-9_-]{6,120}$/.test(text)) fail('Coolify Resource UUID besitzt ein ungültiges Format.');
|
||||
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 '';
|
||||
let url;
|
||||
try { url = new URL(text); } catch { fail('Coolify URL ist ungültig.'); }
|
||||
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify URL muss HTTP oder HTTPS verwenden.');
|
||||
if (url.username || url.password || url.search || url.hash) fail('Coolify URL darf keine Zugangsdaten, Query oder Fragment enthalten.');
|
||||
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
||||
fail('In Produktion muss die Coolify URL HTTPS verwenden.');
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normalizeWebhookUrl(value) {
|
||||
const text = clean(value, 2000);
|
||||
if (!text) return '';
|
||||
let url;
|
||||
try { url = new URL(text); } catch { fail('Coolify Deploy-Webhook ist ungültig.'); }
|
||||
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify Deploy-Webhook muss HTTP oder HTTPS verwenden.');
|
||||
if (url.username || url.password || url.hash) fail('Coolify Deploy-Webhook darf keine URL-Zugangsdaten oder Fragmente enthalten.');
|
||||
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
||||
fail('In Produktion muss der Coolify Deploy-Webhook HTTPS verwenden.');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
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 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() {
|
||||
const provider = PROVIDERS.has(process.env.VENDOO_DEPLOY_PROVIDER) ? process.env.VENDOO_DEPLOY_PROVIDER : 'none';
|
||||
const mode = MODES.has(process.env.COOLIFY_TRIGGER_MODE) ? process.env.COOLIFY_TRIGGER_MODE : 'webhook';
|
||||
const channel = CHANNELS.has(process.env.VENDOO_UPDATE_CHANNEL) ? process.env.VENDOO_UPDATE_CHANNEL : 'stable';
|
||||
const method = METHODS.has(process.env.COOLIFY_DEPLOY_METHOD) ? process.env.COOLIFY_DEPLOY_METHOD : 'GET';
|
||||
return {
|
||||
provider,
|
||||
coolify_url: String(process.env.COOLIFY_API_URL || '').replace(/\/$/, ''),
|
||||
resource_uuid: String(process.env.COOLIFY_RESOURCE_UUID || ''),
|
||||
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: 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 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: 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,
|
||||
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 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}`;
|
||||
}
|
||||
|
||||
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 fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
||||
catch (error) {
|
||||
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, 64 * 1024);
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
|
||||
}
|
||||
|
||||
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 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: redact(body) });
|
||||
return { ok: true, status: response.status, response: redact(body), url: settings.coolify_url };
|
||||
}
|
||||
|
||||
async function requestCoolify(settings, force) {
|
||||
let url;
|
||||
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
||||
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.');
|
||||
} else {
|
||||
const token = getSecret('COOLIFY_API_TOKEN');
|
||||
if (!settings.coolify_url || !settings.resource_uuid || !token) fail('Coolify API URL, Resource UUID oder API Token fehlt.');
|
||||
url = buildApiDeployUrl(settings, Boolean(force));
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000);
|
||||
const body = await readSafeResponse(response);
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
function update(input = {}) {
|
||||
const provider = clean(input.provider || 'none', 20);
|
||||
const mode = clean(input.trigger_mode || 'webhook', 20);
|
||||
const channel = clean(input.update_channel || 'stable', 20);
|
||||
const method = clean(input.deploy_method || 'GET', 10).toUpperCase();
|
||||
if (!PROVIDERS.has(provider)) fail('Unbekannter Deployment-Provider.');
|
||||
if (!MODES.has(mode)) fail('Unbekannter Coolify Trigger-Modus.');
|
||||
if (!CHANNELS.has(channel)) fail('Unbekannter Update-Kanal.');
|
||||
if (!METHODS.has(method)) fail('Coolify Deploy-Methode muss GET oder POST sein.');
|
||||
|
||||
const values = {
|
||||
VENDOO_DEPLOY_PROVIDER: provider,
|
||||
COOLIFY_API_URL: normalizeBaseUrl(input.coolify_url || ''),
|
||||
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
|
||||
COOLIFY_TRIGGER_MODE: mode,
|
||||
COOLIFY_DEPLOY_METHOD: method,
|
||||
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)) {
|
||||
content = setConfigValue(content, key, value);
|
||||
process.env[key] = value;
|
||||
}
|
||||
writeRuntimeConfig(content);
|
||||
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 !== MASKED_SECRET) {
|
||||
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
|
||||
}
|
||||
return publicSettings(repository);
|
||||
}
|
||||
|
||||
function runProductionCheck(context = {}) {
|
||||
const results = productionChecks({ getPlatformSnapshot });
|
||||
return repository.createProductionCheck({ createdBy: context.userId || null, results });
|
||||
}
|
||||
|
||||
scheduleMonitor(2500);
|
||||
|
||||
return Object.freeze({
|
||||
status,
|
||||
update,
|
||||
testConnection,
|
||||
trigger,
|
||||
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 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; },
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configureFluxStudioModule(deps = {}) {
|
||||
runtime = { start: deps.start || (() => {}), stop: deps.stop || (() => {}) };
|
||||
}
|
||||
|
||||
export function createFluxStudioModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() { if (!runtime) throw new Error('FLUX-Modul ist nicht konfiguriert.'); runtime.start(); started = true; },
|
||||
async stop() { runtime?.stop(); started = false; },
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, worker: 'flux-prompt' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.flux-studio",
|
||||
"name": "FLUX Studio",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native FLUX-Studio-Verträge und kontrollierter Prompt-Job-Worker.",
|
||||
"requires": [
|
||||
"vendoo.ai"
|
||||
],
|
||||
"permissions": [
|
||||
"generation.execute"
|
||||
],
|
||||
"provides": [
|
||||
"flux.prompt-jobs"
|
||||
],
|
||||
"consumes": [
|
||||
"ai.images"
|
||||
],
|
||||
"owns": [
|
||||
"flux.generations",
|
||||
"flux.history"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import manifest from './module.json' with { type:'json' };
|
||||
import { InventoryService } from './service.mjs';
|
||||
let service = null;
|
||||
export function configureInventoryModule({ repository }) { service = new InventoryService(repository); }
|
||||
export function getInventoryService(){ if (!service) throw new Error('Inventory-Modul ist nicht konfiguriert.'); return service; }
|
||||
export function createInventoryModule(){ return { manifest, lifecycle:{ async start(){}, async stop(){}, async health(){ return {ok:Boolean(service),native:true,contracts:['locations','bulk-update']}; } } }; }
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.inventory",
|
||||
"name": "Inventory",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Lagerort- und Bestandsverwaltung mit begrenzten Bulk-Aktionen.",
|
||||
"requires": [
|
||||
"vendoo.auth",
|
||||
"vendoo.listings"
|
||||
],
|
||||
"permissions": [
|
||||
"listings.view",
|
||||
"inventory.edit"
|
||||
],
|
||||
"provides": [
|
||||
"inventory.stock"
|
||||
],
|
||||
"consumes": [
|
||||
"auth.identity",
|
||||
"listings.catalog"
|
||||
],
|
||||
"owns": [
|
||||
"inventory.locations"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation } from '../../../lib/db.mjs';
|
||||
export const inventoryRepository = Object.freeze({ locations:getStorageLocations, byLocation:getListingsByLocation, unassigned:getUnassignedListings, bulkUpdateLocation });
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getInventoryService } from './index.mjs';
|
||||
export function registerInventoryRoutes({app,routes,deps}){ const service=getInventoryService();
|
||||
routes.register(app,{id:'inventory.locations.list',method:'GET',path:'/api/inventory/locations',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.locations())});
|
||||
routes.register(app,{id:'inventory.unassigned.list',method:'GET',path:'/api/inventory/unassigned',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(_q,r)=>r.json(service.unassigned())});
|
||||
routes.register(app,{id:'inventory.location.read',method:'GET',path:'/api/inventory/location/:loc',owner:'vendoo.inventory',policy:'inventory.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(service.byLocation(q.params.loc))});
|
||||
routes.register(app,{id:'inventory.location.bulk-update',method:'PUT',path:'/api/inventory/bulk-location',owner:'vendoo.inventory',policy:'inventory.manage',stability:'stable',input:body=>service.validateBulk(body),handler:async(q,r)=>{const result=service.bulk(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'inventory.location_bulk_updated','listing',null,JSON.stringify({count:result.updated,location:q.validatedBody.location}),deps.getClientIp(q));r.json(result);}});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
function cleanLocation(value){ const text=String(value ?? '').trim(); if(text.length>160 || /[\u0000\r\n]/.test(text)) throw new PlatformError('Lagerort ist ungültig.',{code:'INVENTORY_LOCATION_INVALID',status:400,expose:true}); return text; }
|
||||
export class InventoryService {
|
||||
constructor(repository) { this.repository = repository; }
|
||||
locations(){ return this.repository.locations(); }
|
||||
unassigned(){ return this.repository.unassigned(); }
|
||||
byLocation(location){ return this.repository.byLocation(cleanLocation(location)); }
|
||||
validateBulk(input={}){ for(const key of Object.keys(input||{})) if(!['ids','location'].includes(key)) throw new PlatformError(`Unbekanntes Lagerfeld: ${key}`,{code:'INVENTORY_INPUT_INVALID',status:400,expose:true}); const ids=Array.isArray(input.ids)?[...new Set(input.ids.map(Number).filter(Number.isInteger).filter(id=>id>0))]:[]; if(!ids.length) throw new PlatformError('Mindestens eine Artikel-ID ist erforderlich.',{code:'INVENTORY_IDS_REQUIRED',status:400,expose:true}); if(ids.length>500) throw new PlatformError('Maximal 500 Artikel pro Vorgang.',{code:'INVENTORY_BATCH_TOO_LARGE',status:400,expose:true}); return Object.freeze({ ids, location:cleanLocation(input.location) }); }
|
||||
bulk(input){ const valid=this.validateBulk(input); return { ok:true, updated:this.repository.bulkUpdateLocation(valid.ids, valid.location) }; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { ListingsService } from './service.mjs';
|
||||
let service = null;
|
||||
export function configureListingsModule({ repository }) { service = new ListingsService(repository); }
|
||||
export function getListingsService() { if (!service) throw new Error('Listings-Modul ist nicht konfiguriert.'); return service; }
|
||||
export function createListingsModule() { return { manifest, lifecycle: { async start(){}, async stop(){}, async health(){ return { ok:Boolean(service), native:true, contracts:['catalog','trash'] }; } } }; }
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.listings",
|
||||
"name": "Listings",
|
||||
"version": "2.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Artikelverwaltung mit validierten Payloads, Papierkorb, Duplikaten und Audit-Verträgen.",
|
||||
"requires": [
|
||||
"vendoo.auth",
|
||||
"vendoo.media"
|
||||
],
|
||||
"permissions": [
|
||||
"listings.view",
|
||||
"listings.edit",
|
||||
"listings.delete",
|
||||
"listings.permanent-delete"
|
||||
],
|
||||
"provides": [
|
||||
"listings.catalog"
|
||||
],
|
||||
"consumes": [
|
||||
"media.assets",
|
||||
"auth.identity"
|
||||
],
|
||||
"owns": [
|
||||
"listings.records",
|
||||
"listings.trash"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
createListing, getListing, getListings, updateListing, softDeleteListing,
|
||||
restoreListing, getDeletedListings, permanentlyDeleteListing, getTrashCount,
|
||||
duplicateListing,
|
||||
} from '../../../lib/db.mjs';
|
||||
|
||||
export const listingsRepository = Object.freeze({
|
||||
create: createListing,
|
||||
get: getListing,
|
||||
list: getListings,
|
||||
update: updateListing,
|
||||
softDelete: softDeleteListing,
|
||||
restore: restoreListing,
|
||||
listDeleted: getDeletedListings,
|
||||
permanentDelete: permanentlyDeleteListing,
|
||||
trashCount: getTrashCount,
|
||||
duplicate: duplicateListing,
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getListingsService } from './index.mjs';
|
||||
|
||||
export function registerListingsRoutes({ app, routes, deps }) {
|
||||
const service = getListingsService();
|
||||
routes.register(app, { id:'listings.catalog.create', method:'POST', path:'/api/listings', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateCreate(body),
|
||||
handler: async (req,res) => { const item = service.create(req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.catalog.list', method:'GET', path:'/api/listings', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable',
|
||||
handler: async (req,res) => res.json(service.list(req.query || {})) });
|
||||
routes.register(app, { id:'listings.catalog.read', method:'GET', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler: async (req,res)=>res.json(service.get(req.params.id)) });
|
||||
routes.register(app, { id:'listings.catalog.update', method:'PUT', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', input: body => service.validateUpdate(Object.fromEntries(Object.entries(body || {}).filter(([k]) => k !== '_edit_lock'))),
|
||||
handler: async (req,res) => { const lock = deps.validateResourceLock({ resourceType:'listing', resourceId:req.params.id, userId:req.user.id, token:req.get('x-edit-lock') || req.body?._edit_lock }); if (!lock.valid) return res.status(409).json({ error:`${lock.lock?.user_name || lock.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code:'EDIT_LOCKED', lock:lock.lock }); const item=service.update(req.params.id, req.validatedBody, req.user.id); deps.auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.catalog.delete', method:'DELETE', path:'/api/listings/:id', owner:'vendoo.listings', policy:'listings.delete', stability:'stable', handler: async (req,res)=>{ const result=service.softDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.catalog.duplicate', method:'POST', path:'/api/listings/:id/duplicate', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler: async (req,res)=>{ const item=service.duplicate(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(item.id), item.title, deps.getClientIp(req)); res.json(item); } });
|
||||
routes.register(app, { id:'listings.trash.list', method:'GET', path:'/api/trash', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trash()) });
|
||||
routes.register(app, { id:'listings.trash.count', method:'GET', path:'/api/trash/count', owner:'vendoo.listings', policy:'listings.view', csrf:false, stability:'stable', handler:async(_req,res)=>res.json(service.trashCount()) });
|
||||
routes.register(app, { id:'listings.trash.restore', method:'POST', path:'/api/trash/:id/restore', owner:'vendoo.listings', policy:'listings.edit', stability:'stable', handler:async(req,res)=>{ const result=service.restore(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.trash.permanent-delete', method:'DELETE', path:'/api/trash/:id', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.permanentDelete(req.params.id); deps.auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, deps.getClientIp(req)); res.json(result); } });
|
||||
routes.register(app, { id:'listings.trash.empty', method:'DELETE', path:'/api/trash', owner:'vendoo.listings', policy:'listings.permanent-delete', stability:'stable', handler:async(req,res)=>{ const result=service.emptyTrash(); deps.auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${result.deleted} Einträge`, deps.getClientIp(req)); res.json(result); } });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { sanitizeRichHtml } from '../../../lib/html-templates.mjs';
|
||||
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'platform','ai_provider','title','description','description_html','tags','photos','seller_notes',
|
||||
'brand','category','size','color','condition','price','status','language','sku','storage_location',
|
||||
]);
|
||||
const ARRAY_LIMITS = { tags: 50, photos: 100 };
|
||||
|
||||
function fail(message, field, status = 400, code = 'LISTING_INPUT_INVALID') {
|
||||
throw new PlatformError(message, { code, status, expose: true, details: { field } });
|
||||
}
|
||||
function htmlToPlainText(html) {
|
||||
return String(html || '').replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<')
|
||||
.replace(/>/gi, '>').replace(/"/gi, '"').replace(/'/gi, "'").replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
function cleanText(value, max, field, { required = false } = {}) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (required && !text) fail(`${field} ist erforderlich.`, field);
|
||||
if (text.length > max) fail(`${field} ist zu lang.`, field);
|
||||
if (/\u0000/.test(text)) fail(`${field} enthält ungültige Steuerzeichen.`, field);
|
||||
return text;
|
||||
}
|
||||
function normalizePayload(input = {}, { partial = false } = {}) {
|
||||
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
||||
for (const key of Object.keys(source)) if (!ALLOWED_FIELDS.has(key)) fail(`Unbekanntes Artikelfeld: ${key}`, key);
|
||||
const out = {};
|
||||
const textRules = {
|
||||
platform:64, ai_provider:64, title:240, description:12000, seller_notes:6000, brand:160,
|
||||
category:240, size:120, color:120, condition:120, status:64, language:16, sku:80, storage_location:160,
|
||||
};
|
||||
for (const [field,max] of Object.entries(textRules)) if (source[field] !== undefined) out[field] = cleanText(source[field], max, field, { required: field === 'title' && !partial });
|
||||
if (!partial && source.title === undefined) fail('title ist erforderlich.', 'title');
|
||||
if (source.description_html !== undefined) {
|
||||
const html = String(source.description_html || '');
|
||||
if (html.length > 50000) fail('description_html ist zu lang.', 'description_html');
|
||||
out.description_html = sanitizeRichHtml(html);
|
||||
if (source.description === undefined) out.description = htmlToPlainText(out.description_html).slice(0, 12000);
|
||||
}
|
||||
for (const field of ['tags','photos']) if (source[field] !== undefined) {
|
||||
if (!Array.isArray(source[field])) fail(`${field} muss eine Liste sein.`, field);
|
||||
out[field] = source[field].map(v => cleanText(v, field === 'photos' ? 500 : 120, field)).filter(Boolean).slice(0, ARRAY_LIMITS[field]);
|
||||
}
|
||||
if (source.price !== undefined && source.price !== null && source.price !== '') {
|
||||
const price = Number(source.price);
|
||||
if (!Number.isFinite(price) || price < 0 || price > 100000000) fail('price ist ungültig.', 'price');
|
||||
out.price = price;
|
||||
} else if (source.price !== undefined) out.price = null;
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
export class ListingsService {
|
||||
constructor(repository) { this.repository = repository; }
|
||||
validateCreate(input) { return normalizePayload(input, { partial: false }); }
|
||||
validateUpdate(input) { return normalizePayload(input, { partial: true }); }
|
||||
create(input, actorId) { return this.repository.create({ ...this.validateCreate(input), created_by: actorId || null, updated_by: actorId || null }); }
|
||||
list(filters = {}) { return this.repository.list(cleanText(filters.q || '', 240, 'q'), cleanText(filters.platform || '', 64, 'platform'), cleanText(filters.status || '', 64, 'status')); }
|
||||
get(id) { const item = this.repository.get(Number(id)); if (!item) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return item; }
|
||||
update(id, input, actorId) { this.get(id); return this.repository.update(Number(id), { ...this.validateUpdate(input), updated_by: actorId || null }); }
|
||||
softDelete(id) { this.get(id); this.repository.softDelete(Number(id)); return { ok:true }; }
|
||||
duplicate(id) { const copy = this.repository.duplicate(Number(id)); if (!copy) throw new PlatformError('Artikel nicht gefunden.', { code:'LISTING_NOT_FOUND', status:404, expose:true }); return copy; }
|
||||
trash() { return this.repository.listDeleted(); }
|
||||
trashCount() { return { count: this.repository.trashCount() }; }
|
||||
restore(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.restore(Number(id)); return { ok:true }; }
|
||||
permanentDelete(id) { const item = this.repository.listDeleted().find(entry => Number(entry.id) === Number(id)); if (!item) throw new PlatformError('Artikel befindet sich nicht im Papierkorb.', { code:'LISTING_TRASH_NOT_FOUND', status:404, expose:true }); this.repository.permanentDelete(Number(id)); return { ok:true }; }
|
||||
emptyTrash() { const items = this.repository.listDeleted(); for (const item of items) this.repository.permanentDelete(item.id); return { ok:true, deleted:items.length }; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import manifest from './module.json' with {type:'json'}; import { MediaService } from './service.mjs'; let service=null; export function configureMediaModule(deps){service=new MediaService(deps);} export function getMediaService(){if(!service)throw new Error('Media-Modul ist nicht konfiguriert.');return service;} export function createMediaModule(){return {manifest,lifecycle:{async start(){},async stop(){},async health(){return {ok:Boolean(service),native:true,contracts:['library','reference-protection']};}}};}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.media",
|
||||
"name": "Media Library",
|
||||
"version": "2.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Medienbibliothek mit sicherer Pfadbehandlung, Referenzschutz, Favoriten und Metadaten.",
|
||||
"requires": [
|
||||
"vendoo.auth"
|
||||
],
|
||||
"permissions": [
|
||||
"media.view",
|
||||
"media.edit",
|
||||
"media.delete"
|
||||
],
|
||||
"provides": [
|
||||
"media.assets",
|
||||
"media.derivatives"
|
||||
],
|
||||
"consumes": [
|
||||
"auth.identity"
|
||||
],
|
||||
"owns": [
|
||||
"media.library",
|
||||
"media.uploads"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords } from '../../../lib/db.mjs';
|
||||
export const mediaRepository=Object.freeze({list:listMediaLibraryItems,setFavorite:setMediaLibraryFavorite,referenced:getListingReferencedMediaPaths,removeRecords:removeMediaLibraryRecords});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { getMediaService } from './index.mjs';
|
||||
export function registerMediaRoutes({app,routes,deps}){const service=getMediaService();routes.register(app,{id:'media.library.list',method:'GET',path:'/api/media-library',owner:'vendoo.media',policy:'media.view',csrf:false,stability:'stable',handler:async(q,r)=>r.json(await service.list(q.query||{}))});routes.register(app,{id:'media.library.favorite',method:'PATCH',path:'/api/media-library/favorite',owner:'vendoo.media',policy:'media.edit',stability:'stable',input:body=>service.validateFavorite(body),handler:async(q,r)=>{const result=service.favorite(q.validatedBody);if(!result.ok)return r.status(400).json({error:'Nur FLUX-Bilder können als Favorit markiert werden.'});deps.auditLog(q.user.id,q.user.email,'media.favorite_updated','media',q.validatedBody.image_path,String(q.validatedBody.favorite),deps.getClientIp(q));r.json(result);}});routes.register(app,{id:'media.library.delete',method:'DELETE',path:'/api/media-library/items',owner:'vendoo.media',policy:'media.delete',stability:'stable',input:body=>service.validateDelete(body),handler:async(q,r)=>{const result=service.delete(q.validatedBody);deps.auditLog(q.user.id,q.user.email,'media.deleted','media',null,JSON.stringify({deleted:result.deleted,protected:result.protected.length}),deps.getClientIp(q));r.json(result);}});}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { existsSync, statSync, unlinkSync } from 'node:fs'; import { resolve, sep, extname } from 'node:path'; import { PlatformError } from '../../kernel/errors.mjs'; function normalizePath(value){const p=String(value||'').replace(/\\/g,'/').replace(/^\/+/, '').trim();if(!p||p.includes('..')||/[\u0000\r\n]/.test(p)) throw new PlatformError('Ungültiger Medienpfad.',{code:'MEDIA_PATH_INVALID',status:400,expose:true});return p;}
|
||||
export class MediaService { constructor({uploadRoot,sharp,repository}){this.uploadRoot=resolve(uploadRoot);this.sharp=sharp;this.repository=repository;}
|
||||
async list(query={}){const source=String(query.source||'all').slice(0,32),search=String(query.q||'').slice(0,240),favoritesOnly=String(query.favorites||'0')==='1',sort=['newest','oldest','name'].includes(String(query.sort))?String(query.sort):'newest';const all=this.repository.list({source,search,favoritesOnly,sort});const pageSize=Math.max(12,Math.min(96,Number(query.page_size)||24)),total=all.length,totalPages=Math.max(1,Math.ceil(total/pageSize)),page=Math.min(Math.max(1,Number(query.page)||1),totalPages);const pageItems=all.slice((page-1)*pageSize,page*pageSize);const items=await Promise.all(pageItems.map(async item=>{const imagePath=normalizePath(item.image_path),absolute=resolve(this.uploadRoot,imagePath);let fileSize=null,width=Number(item.width)||null,height=Number(item.height)||null,format=item.format||extname(imagePath).replace('.','').toLowerCase()||null;if(absolute!==this.uploadRoot&&absolute.startsWith(this.uploadRoot+sep)&&existsSync(absolute)){try{fileSize=statSync(absolute).size;}catch{}if((!width||!height)&&this.sharp){try{const m=await this.sharp(absolute,{failOn:'none'}).metadata();width=m.width||width;height=m.height||height;format=m.format||format;}catch{}}}return {...item,image_path:imagePath,public_url:`/uploads/${imagePath}`,file_size:fileSize,width,height,format};}));const summary=this.repository.list({source:'all',search,favoritesOnly,sort});const counts=summary.reduce((acc,item)=>{for(const s of item.sources||[])acc[s]=(acc[s]||0)+1;return acc;},{});return {items,pagination:{page,page_size:pageSize,total,total_pages:totalPages},summary:{total:summary.length,sources:counts}};}
|
||||
validateFavorite(input={}){for(const key of Object.keys(input||{}))if(!['image_path','favorite'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});return Object.freeze({image_path:normalizePath(input.image_path),favorite:input.favorite!==false});}
|
||||
favorite(input={}){const valid=this.validateFavorite(input);return this.repository.setFavorite(valid.image_path,valid.favorite);}
|
||||
validateDelete(input={}){for(const key of Object.keys(input||{}))if(!['image_paths'].includes(key))throw new PlatformError(`Unbekanntes Medienfeld: ${key}`,{code:'MEDIA_INPUT_INVALID',status:400,expose:true});if(!Array.isArray(input.image_paths))throw new PlatformError('image_paths muss eine Liste sein.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});const paths=[...new Set(input.image_paths.map(normalizePath))].slice(0,500);if(!paths.length)throw new PlatformError('Keine Medien ausgewählt.',{code:'MEDIA_PATHS_REQUIRED',status:400,expose:true});return Object.freeze({image_paths:paths});}
|
||||
delete(input={}){const requested=this.validateDelete(input).image_paths,referenced=this.repository.referenced(),protectedPaths=requested.filter(p=>referenced.has(p)),deletable=requested.filter(p=>!referenced.has(p)),deletedFiles=[];for(const p of deletable){const absolute=resolve(this.uploadRoot,p);if(absolute===this.uploadRoot||!absolute.startsWith(this.uploadRoot+sep))continue;try{if(existsSync(absolute)&&statSync(absolute).isFile()){unlinkSync(absolute);deletedFiles.push(p);}}catch{}}this.repository.removeRecords(deletable);return {ok:true,deleted:deletable.length,deleted_files:deletedFiles.length,protected:protectedPaths};}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
import { ModuleManagerService } from './service.mjs';
|
||||
|
||||
let service = null;
|
||||
|
||||
export function createModuleManagerModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start(context) {
|
||||
service = new ModuleManagerService({
|
||||
modules: context.modules,
|
||||
stateStore: context.moduleStateStore,
|
||||
packageStore: context.modulePackageStore,
|
||||
context,
|
||||
});
|
||||
},
|
||||
async stop() { service = null; },
|
||||
async health() { return { ok: Boolean(service), protected: true }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getModuleManagerService() {
|
||||
if (!service) throw new Error('Module Manager ist noch nicht gestartet.');
|
||||
return service;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.module-manager",
|
||||
"name": "Module Manager",
|
||||
"version": "1.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Sichere Verwaltung eingebauter und installierter Vendoo-Module mit Aktivierung, Export, Quarantäne und kontrollierter Entfernung.",
|
||||
"requires": ["vendoo.security", "vendoo.settings"],
|
||||
"permissions": ["modules.manage"],
|
||||
"provides": ["modules.management", "modules.packages"],
|
||||
"consumes": ["security.policies", "settings.configuration"],
|
||||
"owns": ["modules.state", "modules.packages"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { objectSchema } from '../../kernel/input-schema.mjs';
|
||||
import { getModuleManagerService } from './index.mjs';
|
||||
|
||||
const toggleSchema = objectSchema({
|
||||
cascade: { type: 'boolean' },
|
||||
});
|
||||
|
||||
export function registerModuleManagerRoutes({ app, routes, upload, deps }) {
|
||||
routes.register(app, {
|
||||
id: 'modules.catalog.read', method: 'GET', path: '/api/admin/modules', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.catalog.read', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (_req, res) => res.json(getModuleManagerService().catalog()),
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.enable', method: 'POST', path: '/api/admin/modules/:id/enable', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.enabled', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const module = await getModuleManagerService().enable(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.enabled', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.json({ module });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.disable', method: 'POST', path: '/api/admin/modules/:id/disable', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.disabled', rateLimit: 'administration', csrf: true, stability: 'stable', input: toggleSchema,
|
||||
handler: async (req, res) => {
|
||||
const result = await getModuleManagerService().disable(req.params.id, req.validatedBody || {});
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.disabled', 'module', req.params.id, JSON.stringify(result.disabled), deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.install', method: 'POST', path: '/api/admin/modules/install', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.installed', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
middleware: [upload.single('module')],
|
||||
handler: async (req, res) => {
|
||||
const module = getModuleManagerService().install(req.file?.buffer);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.installed', 'module', module.id, module.version, deps.getClientIp(req));
|
||||
res.status(201).json({ module });
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.remove', method: 'DELETE', path: '/api/admin/modules/:id', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.removed', rateLimit: 'administration', csrf: true, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const result = await getModuleManagerService().remove(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.removed', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.json(result);
|
||||
},
|
||||
});
|
||||
routes.register(app, {
|
||||
id: 'modules.export', method: 'GET', path: '/api/admin/modules/:id/export', owner: 'vendoo.module-manager',
|
||||
policy: 'modules.manage', audit: 'modules.exported', rateLimit: 'administration', csrf: false, stability: 'stable',
|
||||
handler: async (req, res) => {
|
||||
const payload = getModuleManagerService().export(req.params.id);
|
||||
deps.auditLog(req.user.id, req.user.email, 'module.exported', 'module', req.params.id, null, deps.getClientIp(req));
|
||||
res.setHeader('Content-Type', 'application/vnd.vendoo.module+json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${String(req.params.id).replace(/[^a-z0-9.-]/gi, '_')}.vmod"`);
|
||||
res.send(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
import { buildModulePackage } from '../../core/modules/module-package-contract.mjs';
|
||||
|
||||
export const PROTECTED_MODULE_IDS = Object.freeze([
|
||||
'vendoo.system',
|
||||
'vendoo.security',
|
||||
'vendoo.auth',
|
||||
'vendoo.users',
|
||||
'vendoo.sessions',
|
||||
'vendoo.settings',
|
||||
'vendoo.themes',
|
||||
'vendoo.listings',
|
||||
'vendoo.inventory',
|
||||
'vendoo.media',
|
||||
'vendoo.operations',
|
||||
'vendoo.module-manager',
|
||||
'vendoo.deployments',
|
||||
]);
|
||||
const PROTECTED_MODULES = new Set(PROTECTED_MODULE_IDS);
|
||||
|
||||
function asPublicModule(record, packagesById) {
|
||||
const external = record.source === 'external';
|
||||
const pkg = packagesById.get(record.id);
|
||||
const protectedModule = PROTECTED_MODULES.has(record.id);
|
||||
const canDisable = !protectedModule && record.enabled;
|
||||
const canEnable = !record.enabled && (!external || pkg?.activatable === true);
|
||||
return {
|
||||
...record,
|
||||
protected: protectedModule,
|
||||
removable: external && !record.enabled,
|
||||
exportable: true,
|
||||
installSource: external ? 'installed' : 'builtin',
|
||||
package: pkg ? {
|
||||
runtime: pkg.runtime,
|
||||
trust: pkg.trust,
|
||||
activatable: pkg.activatable,
|
||||
invalid: Boolean(pkg.invalid),
|
||||
error: pkg.error || null,
|
||||
} : null,
|
||||
actions: {
|
||||
enable: canEnable,
|
||||
disable: canDisable,
|
||||
remove: external && !record.enabled,
|
||||
export: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLifecycle(pkg) {
|
||||
return {
|
||||
async start() {
|
||||
if (pkg.runtime.mode === 'isolated') {
|
||||
throw new PlatformError('Der isolierte Extension-Host ist für ausführbare Fremdmodule noch nicht aktiviert.', {
|
||||
code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
async stop() {},
|
||||
async health() {
|
||||
return { ok: true, runtime: pkg.runtime.mode, external: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ModuleManagerService {
|
||||
#modules;
|
||||
#stateStore;
|
||||
#packageStore;
|
||||
#context;
|
||||
|
||||
constructor({ modules, stateStore, packageStore, context }) {
|
||||
this.#modules = modules;
|
||||
this.#stateStore = stateStore;
|
||||
this.#packageStore = packageStore;
|
||||
this.#context = context;
|
||||
}
|
||||
|
||||
registerInstalledPackages() {
|
||||
const installed = this.#packageStore.list();
|
||||
for (const entry of installed) {
|
||||
if (entry.invalid || this.#modules.has(entry.id)) continue;
|
||||
const pkg = this.#packageStore.get(entry.id);
|
||||
const enabled = entry.activatable && this.#stateStore.isEnabled(entry.id, false);
|
||||
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled });
|
||||
}
|
||||
return installed.length;
|
||||
}
|
||||
|
||||
catalog() {
|
||||
const packages = this.#packageStore.list();
|
||||
const packagesById = new Map(packages.filter(item => item.id).map(item => [item.id, item]));
|
||||
const modules = this.#modules.snapshot().map(record => asPublicModule(record, packagesById));
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.invalid || modules.some(item => item.id === pkg.id)) continue;
|
||||
modules.push({
|
||||
id: pkg.id,
|
||||
name: pkg.id,
|
||||
version: null,
|
||||
type: 'extension-host',
|
||||
status: 'invalid',
|
||||
source: 'external',
|
||||
enabled: false,
|
||||
state: 'invalid',
|
||||
protected: false,
|
||||
removable: true,
|
||||
exportable: false,
|
||||
installSource: 'installed',
|
||||
package: { invalid: true, error: pkg.error, activatable: false },
|
||||
actions: { enable: false, disable: false, remove: true, export: false },
|
||||
});
|
||||
}
|
||||
return {
|
||||
modules: modules.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id), 'de')),
|
||||
summary: {
|
||||
total: modules.length,
|
||||
enabled: modules.filter(item => item.enabled).length,
|
||||
disabled: modules.filter(item => !item.enabled && item.state !== 'invalid').length,
|
||||
native: modules.filter(item => item.status === 'native').length,
|
||||
legacy: modules.filter(item => item.status === 'legacy-bridge').length,
|
||||
external: modules.filter(item => item.source === 'external').length,
|
||||
protected: modules.filter(item => item.protected).length,
|
||||
},
|
||||
safety: {
|
||||
unsignedDeclarativeAllowed: true,
|
||||
executableRequiresTrustedSignature: true,
|
||||
isolatedHostAvailable: false,
|
||||
directMainProcessExecution: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async enable(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (record.source === 'external') {
|
||||
const pkg = this.#packageStore.describe(id);
|
||||
if (!pkg?.activatable) {
|
||||
throw new PlatformError('Dieses Fremdmodul ist nicht aktivierbar. Ausführbarer Code benötigt eine vertrauenswürdige Signatur und den isolierten Extension-Host.', {
|
||||
code: 'MODULE_NOT_ACTIVATABLE', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
if (pkg.runtime.mode === 'isolated') {
|
||||
throw new PlatformError('Der isolierte Extension-Host ist noch nicht verfügbar.', { code: 'MODULE_EXTENSION_HOST_UNAVAILABLE', status: 409, expose: true });
|
||||
}
|
||||
}
|
||||
const enabling = new Set();
|
||||
const enableWithDependencies = async moduleId => {
|
||||
if (enabling.has(moduleId)) throw new PlatformError('Zyklische Modulabhängigkeit erkannt.', { code: 'MODULE_DEPENDENCY_CYCLE', status: 409, expose: true });
|
||||
const target = this.#modules.get(moduleId);
|
||||
if (!target) throw new PlatformError(`Fehlende Modulabhängigkeit: ${moduleId}`, { code: 'MODULE_DEPENDENCY_MISSING', status: 409, expose: true });
|
||||
if (target.enabled && target.state === 'running') return this.#modules.describe(moduleId);
|
||||
enabling.add(moduleId);
|
||||
for (const dependency of target.manifest.requires) await enableWithDependencies(dependency);
|
||||
const enabled = await this.#modules.enable(moduleId, this.#context);
|
||||
this.#stateStore.setEnabled(moduleId, true, { source: moduleId === id ? 'module-manager' : 'module-manager-dependency' });
|
||||
enabling.delete(moduleId);
|
||||
return enabled;
|
||||
};
|
||||
return enableWithDependencies(id);
|
||||
}
|
||||
|
||||
async disable(id, { cascade = false } = {}) {
|
||||
if (PROTECTED_MODULES.has(id)) {
|
||||
throw new PlatformError('Dieses Kernmodul ist geschützt und kann nicht deaktiviert werden.', {
|
||||
code: 'MODULE_PROTECTED', status: 409, expose: true,
|
||||
});
|
||||
}
|
||||
const dependents = this.#modules.dependentsOf(id, { enabledOnly: true, recursive: true });
|
||||
const protectedDependents = dependents.filter(dependent => PROTECTED_MODULES.has(dependent));
|
||||
if (protectedDependents.length) {
|
||||
throw new PlatformError('Das Modul wird von geschützten Kernmodulen benötigt.', {
|
||||
code: 'MODULE_REQUIRED_BY_PROTECTED', status: 409, expose: true, details: { dependents: protectedDependents },
|
||||
});
|
||||
}
|
||||
const result = await this.#modules.disable(id, this.#context, { cascade: Boolean(cascade) });
|
||||
for (const disabledId of result.disabled) this.#stateStore.setEnabled(disabledId, false, { source: 'module-manager' });
|
||||
return result;
|
||||
}
|
||||
|
||||
install(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new PlatformError('Modulpaket fehlt.', { code: 'MODULE_PACKAGE_REQUIRED', status: 400, expose: true });
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(buffer.toString('utf8')); }
|
||||
catch (error) { throw new PlatformError('Das .vmod-Paket enthält kein gültiges JSON.', { code: 'MODULE_PACKAGE_JSON_INVALID', status: 400, expose: true, cause: error }); }
|
||||
const id = String(parsed?.manifest?.id || '');
|
||||
if (this.#modules.has(id)) throw new PlatformError('Eine Modul-ID mit diesem Namen ist bereits registriert.', { code: 'MODULE_DUPLICATE', status: 409, expose: true });
|
||||
const installed = this.#packageStore.install(parsed);
|
||||
const pkg = this.#packageStore.get(installed.id);
|
||||
try {
|
||||
this.#modules.register({ manifest: pkg.manifest, lifecycle: createExternalLifecycle(pkg), source: 'external' }, { enabled: false });
|
||||
this.#stateStore.setEnabled(installed.id, false, { source: 'module-install' });
|
||||
} catch (error) {
|
||||
this.#packageStore.remove(installed.id);
|
||||
this.#stateStore.remove(installed.id);
|
||||
throw error;
|
||||
}
|
||||
return this.catalog().modules.find(item => item.id === installed.id);
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) {
|
||||
const removedInvalid = this.#packageStore.remove(id);
|
||||
if (removedInvalid) this.#stateStore.remove(id);
|
||||
return { ok: removedInvalid, id };
|
||||
}
|
||||
if (record.source !== 'external') {
|
||||
throw new PlatformError('Eingebaute Vendoo-Module können nicht gelöscht werden.', { code: 'MODULE_BUILTIN_NOT_REMOVABLE', status: 409, expose: true });
|
||||
}
|
||||
if (record.enabled) throw new PlatformError('Das Modul muss vor dem Löschen deaktiviert werden.', { code: 'MODULE_DISABLE_BEFORE_REMOVE', status: 409, expose: true });
|
||||
await this.#modules.unregister(id, this.#context);
|
||||
this.#packageStore.remove(id);
|
||||
this.#stateStore.remove(id);
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
export(id) {
|
||||
const record = this.#modules.get(id);
|
||||
if (!record) throw new PlatformError('Modul nicht gefunden.', { code: 'MODULE_NOT_FOUND', status: 404, expose: true });
|
||||
if (record.source === 'external') return this.#packageStore.export(id);
|
||||
const pkg = buildModulePackage({
|
||||
manifest: record.manifest,
|
||||
runtime: { mode: 'builtin-reference', entry: null },
|
||||
files: [{ path: 'module.json', content: `${JSON.stringify(record.manifest, null, 2)}\n` }],
|
||||
});
|
||||
return Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
stateSnapshot() {
|
||||
return this.#stateStore.snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.operations",
|
||||
"name": "Operations Center",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "legacy-bridge",
|
||||
"description": "Vertrag für den bestehenden Vendoo-Bereich Operations Center; schrittweise Migration ohne Funktionsbruch.",
|
||||
"requires": [
|
||||
"vendoo.security"
|
||||
],
|
||||
"permissions": [
|
||||
"operations.manage"
|
||||
],
|
||||
"provides": [
|
||||
"operations.backup",
|
||||
"operations.update"
|
||||
],
|
||||
"consumes": [
|
||||
"security.audit"
|
||||
],
|
||||
"owns": [
|
||||
"operations.backups",
|
||||
"operations.updates"
|
||||
]
|
||||
}
|
||||
@@ -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'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
let runtime = null;
|
||||
let started = false;
|
||||
|
||||
export function configurePublishingModule(deps = {}) {
|
||||
runtime = {
|
||||
start: deps.start || (() => {}),
|
||||
stop: deps.stop || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPublishingModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() { if (!runtime) throw new Error('Publishing-Modul ist nicht konfiguriert.'); await runtime.start(); started = true; },
|
||||
async stop() { await runtime?.stop?.(); started = false; },
|
||||
async health() { return { ok: Boolean(runtime && started), native: true, queue: 'publishing-jobs' }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.publishing",
|
||||
"name": "Publishing",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Publishing-Jobs, Plattformadapter und kontrollierter Queue-Lifecycle.",
|
||||
"requires": [
|
||||
"vendoo.listings",
|
||||
"vendoo.security"
|
||||
],
|
||||
"permissions": [
|
||||
"publishing.view",
|
||||
"publishing.execute",
|
||||
"publishing.manage"
|
||||
],
|
||||
"provides": [
|
||||
"publishing.jobs",
|
||||
"publishing.adapters"
|
||||
],
|
||||
"consumes": [
|
||||
"listings.catalog",
|
||||
"security.policies"
|
||||
],
|
||||
"owns": [
|
||||
"publishing.queue",
|
||||
"publishing.events"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import manifest from './module.json' with { type: 'json' };
|
||||
|
||||
export function createQualityModule() {
|
||||
return {
|
||||
manifest,
|
||||
source: 'builtin',
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async health() { return { ok: true, native: true, contracts: ['reports', 'ai-suggestions'] }; },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.quality",
|
||||
"name": "Quality Center",
|
||||
"version": "1.0.0",
|
||||
"type": "feature",
|
||||
"status": "native",
|
||||
"description": "Native Qualitätsanalyse, Verlauf und bestätigte AI-Vorschläge.",
|
||||
"requires": [
|
||||
"vendoo.listings",
|
||||
"vendoo.ai"
|
||||
],
|
||||
"permissions": [
|
||||
"quality.execute"
|
||||
],
|
||||
"provides": [
|
||||
"quality.reports"
|
||||
],
|
||||
"consumes": [
|
||||
"listings.catalog",
|
||||
"ai.text"
|
||||
],
|
||||
"owns": [
|
||||
"quality.history"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { secretStoreStatus } from '../../../lib/secret-store.mjs';
|
||||
import { getRequestTelemetry } from '../../../lib/structured-logger.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
|
||||
export function createSecurityModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() {},
|
||||
async stop() {},
|
||||
async health() {
|
||||
const secrets = secretStoreStatus();
|
||||
return { ok: true, provider: secrets.provider, encrypted_secrets: secrets.encrypted };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getSecurityFoundationStatus() {
|
||||
return { secrets: secretStoreStatus(), requests: getRequestTelemetry() };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "vendoo.security",
|
||||
"name": "Security Core",
|
||||
"version": "2.0.0",
|
||||
"type": "core",
|
||||
"status": "native",
|
||||
"description": "Nativer Sicherheitskern für verschlüsselte Secrets, Request-Telemetrie, Sicherheitsheader und Security-Dashboard.",
|
||||
"requires": [
|
||||
"vendoo.system"
|
||||
],
|
||||
"permissions": [
|
||||
"security.manage"
|
||||
],
|
||||
"provides": [
|
||||
"security.policies",
|
||||
"security.audit",
|
||||
"security.secrets",
|
||||
"security.telemetry",
|
||||
"security.headers"
|
||||
],
|
||||
"consumes": [
|
||||
"platform.events"
|
||||
],
|
||||
"owns": [
|
||||
"security.permissions",
|
||||
"security.rate-limits",
|
||||
"security.secret-store",
|
||||
"security.request-telemetry"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PlatformError } from '../../kernel/errors.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(here, 'module.json'), 'utf8'));
|
||||
let service = null;
|
||||
|
||||
export function getSessionsService() {
|
||||
if (!service) throw new PlatformError('Sitzungs-Modul ist noch nicht gestartet.', { code: 'SESSIONS_MODULE_NOT_READY', status: 503, expose: true });
|
||||
return service;
|
||||
}
|
||||
|
||||
export function createSessionsModule() {
|
||||
return {
|
||||
manifest,
|
||||
lifecycle: {
|
||||
async start() { service = (await import('./service.mjs')).createSessionsService(); },
|
||||
async stop() { service = null; },
|
||||
async health() { return getSessionsService().health(); },
|
||||
},
|
||||
};
|
||||
}
|
||||